perf(browser): defer startup work and warm WebView pool
- commit
- 11bef86e58fea5d42763241e4118384bac7a1499
- author
- bnema · · 2025-12-06 02:49 UTC
- parent
- 4b825fa
- signature
- unsigned
5 files · +940 -22
M internal/app/browser/browser.go +77 -12
| @@ -65,6 +65,10 @@ type BrowserApp struct { | |||
| 65 | 65 | messageHandler *messaging.Handler | |
| 66 | 66 | shortcutHandler *ShortcutHandler | |
| 67 | 67 | windowShortcutHandler WindowShortcutHandlerInterface | |
| 68 | + | ||
| 69 | + | // Performance optimization components | |
| 70 | + | webViewPool *WebViewPool // Pre-created WebViews for instant tab/pane creation | |
| 71 | + | prefetchManager *PrefetchManager // DNS prefetching for faster page loads | |
| 68 | 72 | } | |
| 69 | 73 | ||
| 70 | 74 | // Run starts the browser application | |
| @@ -130,8 +134,8 @@ func (app *BrowserApp) Initialize() error { | |||
| 130 | 134 | ||
| 131 | 135 | logging.Info(fmt.Sprintf("Config initialized")) | |
| 132 | 136 | ||
| 133 | - | // Detect keyboard layout | |
| 134 | - | environment.DetectAndSetKeyboardLocale() | |
| 137 | + | // Detect keyboard layout in background (required for shortcuts) | |
| 138 | + | go environment.DetectAndSetKeyboardLocale() | |
| 135 | 139 | ||
| 136 | 140 | // Initialize database (migrations run automatically in InitDB) | |
| 137 | 141 | database, err := db.InitDB(app.config.Database.Path) | |
| @@ -142,10 +146,13 @@ func (app *BrowserApp) Initialize() error { | |||
| 142 | 146 | app.queries = db.New(database) | |
| 143 | 147 | logging.Info(fmt.Sprintf("Database opened at %s", app.config.Database.Path)) | |
| 144 | 148 | ||
| 145 | - | // Cleanup expired certificate validations | |
| 146 | - | if err := webkit.CleanupExpiredCertificateValidations(); err != nil { | |
| 147 | - | logging.Warn(fmt.Sprintf("Warning: failed to cleanup expired certificate validations: %v", err)) | |
| 148 | - | } | |
| 149 | + | // DEFERRED: Cleanup expired certificate validations in background | |
| 150 | + | go func() { | |
| 151 | + | time.Sleep(500 * time.Millisecond) // Low priority maintenance task | |
| 152 | + | if err := webkit.CleanupExpiredCertificateValidations(); err != nil { | |
| 153 | + | logging.Warn(fmt.Sprintf("Warning: failed to cleanup expired certificate validations: %v", err)) | |
| 154 | + | } | |
| 155 | + | }() | |
| 149 | 156 | ||
| 150 | 157 | // Initialize services | |
| 151 | 158 | app.parserService = services.NewParserService(app.config, app.queries) | |
| @@ -161,9 +168,47 @@ func (app *BrowserApp) Initialize() error { | |||
| 161 | 168 | app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService) | |
| 162 | 169 | app.messageHandler = messaging.NewHandler(app.parserService, app.browserService) | |
| 163 | 170 | ||
| 171 | + | // Initialize prefetch manager for DNS preloading | |
| 172 | + | app.prefetchManager = NewPrefetchManager(DefaultPrefetchConfig(), app.queries) | |
| 173 | + | ||
| 174 | + | // Initialize WebView pool for instant tab/pane creation | |
| 175 | + | // Pool starts warming up after main window shows (see Run method) | |
| 176 | + | app.webViewPool = NewWebViewPool(2, 4, app.getWebViewConfig) | |
| 177 | + | ||
| 164 | 178 | return nil | |
| 165 | 179 | } | |
| 166 | 180 | ||
| 181 | + | // getWebViewConfig returns the configuration for creating pooled WebViews | |
| 182 | + | func (app *BrowserApp) getWebViewConfig() *webkit.Config { | |
| 183 | + | dataDir, _ := config.GetDataDir() | |
| 184 | + | cacheDir, _ := config.GetFilterCacheDir() | |
| 185 | + | ||
| 186 | + | return &webkit.Config{ | |
| 187 | + | EnableJavaScript: true, | |
| 188 | + | EnableWebGL: true, | |
| 189 | + | DefaultFontSize: app.config.Appearance.DefaultFontSize, | |
| 190 | + | MinimumFontSize: 8, // Reasonable default | |
| 191 | + | UserAgent: app.config.CodecPreferences.CustomUserAgent, | |
| 192 | + | DataDir: dataDir, | |
| 193 | + | CacheDir: cacheDir, | |
| 194 | + | EnablePageCache: true, | |
| 195 | + | EnableSmoothScrolling: true, | |
| 196 | + | CreateWindow: false, // Pool WebViews don't create their own window | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // GetWebViewPool returns the WebView pool for creating new tabs/panes. | |
| 201 | + | // Returns nil if pool is not initialized. | |
| 202 | + | func (app *BrowserApp) GetWebViewPool() *WebViewPool { | |
| 203 | + | return app.webViewPool | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // GetPrefetchManager returns the prefetch manager for DNS preloading. | |
| 207 | + | // Returns nil if prefetch manager is not initialized. | |
| 208 | + | func (app *BrowserApp) GetPrefetchManager() *PrefetchManager { | |
| 209 | + | return app.prefetchManager | |
| 210 | + | } | |
| 211 | + | ||
| 167 | 212 | // loadCachesParallel loads all caches in parallel for fast startup. | |
| 168 | 213 | // Uses errgroup to coordinate parallel loads and capture any errors. | |
| 169 | 214 | // Target: <100ms for all caches combined. | |
| @@ -235,12 +280,14 @@ func (app *BrowserApp) Run() { | |||
| 235 | 280 | // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation) | |
| 236 | 281 | webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle) | |
| 237 | 282 | ||
| 238 | - | // Initialize content blocking BEFORE creating WebViews so the service is ready | |
| 239 | - | // to register WebViews as they are created (tabs, workspaces, popups) | |
| 240 | - | if err := app.setupContentBlocking(); err != nil { | |
| 241 | - | logging.Warn(fmt.Sprintf("Warning: failed to setup content blocking: %v", err)) | |
| 242 | - | // Continue without content blocking | |
| 243 | - | } | |
| 283 | + | // DEFERRED: Initialize content blocking AFTER window shows to speed up boot | |
| 284 | + | // Content filters load from cache quickly (~50ms) and apply to new navigations | |
| 285 | + | go func() { | |
| 286 | + | time.Sleep(100 * time.Millisecond) // Let window render first | |
| 287 | + | if err := app.setupContentBlocking(); err != nil { | |
| 288 | + | logging.Warn(fmt.Sprintf("Warning: failed to setup content blocking: %v", err)) | |
| 289 | + | } | |
| 290 | + | }() | |
| 244 | 291 | ||
| 245 | 292 | // Create and setup WebView (this also creates TabManager which creates more WebViews) | |
| 246 | 293 | if err := app.createWebView(); err != nil { | |
| @@ -253,6 +300,17 @@ func (app *BrowserApp) Run() { | |||
| 253 | 300 | logging.Warn(fmt.Sprintf("Warning: failed to register URI scheme handlers: %v", err)) | |
| 254 | 301 | } | |
| 255 | 302 | ||
| 303 | + | // POST-VISIBLE: Start WebView pool warmup for instant tab/pane creation | |
| 304 | + | if app.webViewPool != nil { | |
| 305 | + | app.webViewPool.Start() | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // POST-VISIBLE: Start DNS prefetching from history and common domains | |
| 309 | + | if app.prefetchManager != nil { | |
| 310 | + | app.prefetchManager.PrefetchCommonDomains() | |
| 311 | + | app.prefetchManager.PrefetchFromHistory(context.Background()) | |
| 312 | + | } | |
| 313 | + | ||
| 256 | 314 | // Handle browse command if present (must use active tab's navigation controller) | |
| 257 | 315 | if app.tabManager != nil { | |
| 258 | 316 | activeTab := app.tabManager.GetActiveTab() | |
| @@ -278,6 +336,13 @@ func (app *BrowserApp) Run() { | |||
| 278 | 336 | func (app *BrowserApp) cleanup() { | |
| 279 | 337 | logging.Info(fmt.Sprintf("Starting browser cleanup...")) | |
| 280 | 338 | ||
| 339 | + | // Stop WebView pool first (destroys pre-created WebViews) | |
| 340 | + | if app.webViewPool != nil { | |
| 341 | + | logging.Info(fmt.Sprintf("Stopping WebView pool")) | |
| 342 | + | app.webViewPool.Stop() | |
| 343 | + | app.webViewPool = nil | |
| 344 | + | } | |
| 345 | + | ||
| 281 | 346 | // Cleanup window shortcuts first | |
| 282 | 347 | if app.windowShortcutHandler != nil { | |
| 283 | 348 | logging.Info(fmt.Sprintf("Cleaning up window shortcuts")) | |
| @@ -65,6 +65,10 @@ type BrowserApp struct { | |||||
| 65 | messageHandler *messaging.Handler | 65 | messageHandler *messaging.Handler | ||
| 66 | shortcutHandler *ShortcutHandler | 66 | shortcutHandler *ShortcutHandler | ||
| 67 | windowShortcutHandler WindowShortcutHandlerInterface | 67 | windowShortcutHandler WindowShortcutHandlerInterface | ||
| 68 | + | ||||
| 69 | + | // Performance optimization components | |||
| 70 | + | webViewPool *WebViewPool // Pre-created WebViews for instant tab/pane creation | |||
| 71 | + | prefetchManager *PrefetchManager // DNS prefetching for faster page loads | |||
| 68 | } | 72 | } | ||
| 69 | 73 | ||||
| 70 | // Run starts the browser application | 74 | // Run starts the browser application | ||
| @@ -130,8 +134,8 @@ func (app *BrowserApp) Initialize() error { | |||||
| 130 | 134 | ||||
| 131 | logging.Info(fmt.Sprintf("Config initialized")) | 135 | logging.Info(fmt.Sprintf("Config initialized")) | ||
| 132 | 136 | ||||
| 133 | - | // Detect keyboard layout | 137 | + | // Detect keyboard layout in background (required for shortcuts) |
| 134 | - | environment.DetectAndSetKeyboardLocale() | 138 | + | go environment.DetectAndSetKeyboardLocale() |
| 135 | 139 | ||||
| 136 | // Initialize database (migrations run automatically in InitDB) | 140 | // Initialize database (migrations run automatically in InitDB) | ||
| 137 | database, err := db.InitDB(app.config.Database.Path) | 141 | database, err := db.InitDB(app.config.Database.Path) | ||
| @@ -142,10 +146,13 @@ func (app *BrowserApp) Initialize() error { | |||||
| 142 | app.queries = db.New(database) | 146 | app.queries = db.New(database) | ||
| 143 | logging.Info(fmt.Sprintf("Database opened at %s", app.config.Database.Path)) | 147 | logging.Info(fmt.Sprintf("Database opened at %s", app.config.Database.Path)) | ||
| 144 | 148 | ||||
| 145 | - | // Cleanup expired certificate validations | 149 | + | // DEFERRED: Cleanup expired certificate validations in background |
| 146 | - | if err := webkit.CleanupExpiredCertificateValidations(); err != nil { | 150 | + | go func() { |
| 147 | - | logging.Warn(fmt.Sprintf("Warning: failed to cleanup expired certificate validations: %v", err)) | 151 | + | time.Sleep(500 * time.Millisecond) // Low priority maintenance task |
| 148 | - | } | 152 | + | if err := webkit.CleanupExpiredCertificateValidations(); err != nil { |
| 153 | + | logging.Warn(fmt.Sprintf("Warning: failed to cleanup expired certificate validations: %v", err)) | |||
| 154 | + | } | |||
| 155 | + | }() | |||
| 149 | 156 | ||||
| 150 | // Initialize services | 157 | // Initialize services | ||
| 151 | app.parserService = services.NewParserService(app.config, app.queries) | 158 | app.parserService = services.NewParserService(app.config, app.queries) | ||
| @@ -161,9 +168,47 @@ func (app *BrowserApp) Initialize() error { | |||||
| 161 | app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService) | 168 | app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService) | ||
| 162 | app.messageHandler = messaging.NewHandler(app.parserService, app.browserService) | 169 | app.messageHandler = messaging.NewHandler(app.parserService, app.browserService) | ||
| 163 | 170 | ||||
| 171 | + | // Initialize prefetch manager for DNS preloading | |||
| 172 | + | app.prefetchManager = NewPrefetchManager(DefaultPrefetchConfig(), app.queries) | |||
| 173 | + | ||||
| 174 | + | // Initialize WebView pool for instant tab/pane creation | |||
| 175 | + | // Pool starts warming up after main window shows (see Run method) | |||
| 176 | + | app.webViewPool = NewWebViewPool(2, 4, app.getWebViewConfig) | |||
| 177 | + | ||||
| 164 | return nil | 178 | return nil | ||
| 165 | } | 179 | } | ||
| 166 | 180 | ||||
| 181 | + | // getWebViewConfig returns the configuration for creating pooled WebViews | |||
| 182 | + | func (app *BrowserApp) getWebViewConfig() *webkit.Config { | |||
| 183 | + | dataDir, _ := config.GetDataDir() | |||
| 184 | + | cacheDir, _ := config.GetFilterCacheDir() | |||
| 185 | + | ||||
| 186 | + | return &webkit.Config{ | |||
| 187 | + | EnableJavaScript: true, | |||
| 188 | + | EnableWebGL: true, | |||
| 189 | + | DefaultFontSize: app.config.Appearance.DefaultFontSize, | |||
| 190 | + | MinimumFontSize: 8, // Reasonable default | |||
| 191 | + | UserAgent: app.config.CodecPreferences.CustomUserAgent, | |||
| 192 | + | DataDir: dataDir, | |||
| 193 | + | CacheDir: cacheDir, | |||
| 194 | + | EnablePageCache: true, | |||
| 195 | + | EnableSmoothScrolling: true, | |||
| 196 | + | CreateWindow: false, // Pool WebViews don't create their own window | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // GetWebViewPool returns the WebView pool for creating new tabs/panes. | |||
| 201 | + | // Returns nil if pool is not initialized. | |||
| 202 | + | func (app *BrowserApp) GetWebViewPool() *WebViewPool { | |||
| 203 | + | return app.webViewPool | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // GetPrefetchManager returns the prefetch manager for DNS preloading. | |||
| 207 | + | // Returns nil if prefetch manager is not initialized. | |||
| 208 | + | func (app *BrowserApp) GetPrefetchManager() *PrefetchManager { | |||
| 209 | + | return app.prefetchManager | |||
| 210 | + | } | |||
| 211 | + | ||||
| 167 | // loadCachesParallel loads all caches in parallel for fast startup. | 212 | // loadCachesParallel loads all caches in parallel for fast startup. | ||
| 168 | // Uses errgroup to coordinate parallel loads and capture any errors. | 213 | // Uses errgroup to coordinate parallel loads and capture any errors. | ||
| 169 | // Target: <100ms for all caches combined. | 214 | // Target: <100ms for all caches combined. | ||
| @@ -235,12 +280,14 @@ func (app *BrowserApp) Run() { | |||||
| 235 | // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation) | 280 | // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation) | ||
| 236 | webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle) | 281 | webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle) | ||
| 237 | 282 | ||||
| 238 | - | // Initialize content blocking BEFORE creating WebViews so the service is ready | 283 | + | // DEFERRED: Initialize content blocking AFTER window shows to speed up boot |
| 239 | - | // to register WebViews as they are created (tabs, workspaces, popups) | 284 | + | // Content filters load from cache quickly (~50ms) and apply to new navigations |
| 240 | - | if err := app.setupContentBlocking(); err != nil { | 285 | + | go func() { |
| 241 | - | logging.Warn(fmt.Sprintf("Warning: failed to setup content blocking: %v", err)) | 286 | + | time.Sleep(100 * time.Millisecond) // Let window render first |
| 242 | - | // Continue without content blocking | 287 | + | if err := app.setupContentBlocking(); err != nil { |
| 243 | - | } | 288 | + | logging.Warn(fmt.Sprintf("Warning: failed to setup content blocking: %v", err)) |
| 289 | + | } | |||
| 290 | + | }() | |||
| 244 | 291 | ||||
| 245 | // Create and setup WebView (this also creates TabManager which creates more WebViews) | 292 | // Create and setup WebView (this also creates TabManager which creates more WebViews) | ||
| 246 | if err := app.createWebView(); err != nil { | 293 | if err := app.createWebView(); err != nil { | ||
| @@ -253,6 +300,17 @@ func (app *BrowserApp) Run() { | |||||
| 253 | logging.Warn(fmt.Sprintf("Warning: failed to register URI scheme handlers: %v", err)) | 300 | logging.Warn(fmt.Sprintf("Warning: failed to register URI scheme handlers: %v", err)) | ||
| 254 | } | 301 | } | ||
| 255 | 302 | ||||
| 303 | + | // POST-VISIBLE: Start WebView pool warmup for instant tab/pane creation | |||
| 304 | + | if app.webViewPool != nil { | |||
| 305 | + | app.webViewPool.Start() | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // POST-VISIBLE: Start DNS prefetching from history and common domains | |||
| 309 | + | if app.prefetchManager != nil { | |||
| 310 | + | app.prefetchManager.PrefetchCommonDomains() | |||
| 311 | + | app.prefetchManager.PrefetchFromHistory(context.Background()) | |||
| 312 | + | } | |||
| 313 | + | ||||
| 256 | // Handle browse command if present (must use active tab's navigation controller) | 314 | // Handle browse command if present (must use active tab's navigation controller) | ||
| 257 | if app.tabManager != nil { | 315 | if app.tabManager != nil { | ||
| 258 | activeTab := app.tabManager.GetActiveTab() | 316 | activeTab := app.tabManager.GetActiveTab() | ||
| @@ -278,6 +336,13 @@ func (app *BrowserApp) Run() { | |||||
| 278 | func (app *BrowserApp) cleanup() { | 336 | func (app *BrowserApp) cleanup() { | ||
| 279 | logging.Info(fmt.Sprintf("Starting browser cleanup...")) | 337 | logging.Info(fmt.Sprintf("Starting browser cleanup...")) | ||
| 280 | 338 | ||||
| 339 | + | // Stop WebView pool first (destroys pre-created WebViews) | |||
| 340 | + | if app.webViewPool != nil { | |||
| 341 | + | logging.Info(fmt.Sprintf("Stopping WebView pool")) | |||
| 342 | + | app.webViewPool.Stop() | |||
| 343 | + | app.webViewPool = nil | |||
| 344 | + | } | |||
| 345 | + | ||||
| 281 | // Cleanup window shortcuts first | 346 | // Cleanup window shortcuts first | ||
| 282 | if app.windowShortcutHandler != nil { | 347 | if app.windowShortcutHandler != nil { | ||
| 283 | logging.Info(fmt.Sprintf("Cleaning up window shortcuts")) | 348 | logging.Info(fmt.Sprintf("Cleaning up window shortcuts")) | ||
A internal/app/browser/deferred_init.go +204 -0
| @@ -0,0 +1,204 @@ | |||
| 1 | + | // Package browser provides the main browser application components. | |
| 2 | + | package browser | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "sync" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/logging" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // DeferredState represents the state of a deferred initialization | |
| 15 | + | type DeferredState int32 | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | DeferredStatePending DeferredState = iota | |
| 19 | + | DeferredStateInitializing | |
| 20 | + | DeferredStateReady | |
| 21 | + | DeferredStateFailed | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // DeferredInit manages lazy initialization of optional services. | |
| 25 | + | // Services are only initialized when first accessed, reducing boot time. | |
| 26 | + | type DeferredInit[T any] struct { | |
| 27 | + | name string | |
| 28 | + | initFunc func(ctx context.Context) (T, error) | |
| 29 | + | timeout time.Duration | |
| 30 | + | ||
| 31 | + | mu sync.RWMutex | |
| 32 | + | state int32 // atomic: DeferredState | |
| 33 | + | value T | |
| 34 | + | err error | |
| 35 | + | initOnce sync.Once | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // NewDeferredInit creates a new deferred initializer. | |
| 39 | + | // name: identifier for logging | |
| 40 | + | // initFunc: function to create the value (called at most once) | |
| 41 | + | // timeout: maximum time to wait for initialization (0 = no timeout) | |
| 42 | + | func NewDeferredInit[T any](name string, initFunc func(ctx context.Context) (T, error), timeout time.Duration) *DeferredInit[T] { | |
| 43 | + | return &DeferredInit[T]{ | |
| 44 | + | name: name, | |
| 45 | + | initFunc: initFunc, | |
| 46 | + | timeout: timeout, | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Get returns the initialized value, initializing it on first call. | |
| 51 | + | // Subsequent calls return the cached value or error. | |
| 52 | + | // Thread-safe: multiple goroutines can call Get() simultaneously. | |
| 53 | + | func (d *DeferredInit[T]) Get(ctx context.Context) (T, error) { | |
| 54 | + | // Fast path: already initialized | |
| 55 | + | state := DeferredState(atomic.LoadInt32(&d.state)) | |
| 56 | + | if state == DeferredStateReady { | |
| 57 | + | d.mu.RLock() | |
| 58 | + | v := d.value | |
| 59 | + | d.mu.RUnlock() | |
| 60 | + | return v, nil | |
| 61 | + | } | |
| 62 | + | if state == DeferredStateFailed { | |
| 63 | + | d.mu.RLock() | |
| 64 | + | err := d.err | |
| 65 | + | d.mu.RUnlock() | |
| 66 | + | var zero T | |
| 67 | + | return zero, err | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Slow path: need to initialize | |
| 71 | + | d.initOnce.Do(func() { | |
| 72 | + | d.initialize(ctx) | |
| 73 | + | }) | |
| 74 | + | ||
| 75 | + | // Return result | |
| 76 | + | d.mu.RLock() | |
| 77 | + | defer d.mu.RUnlock() | |
| 78 | + | ||
| 79 | + | if d.err != nil { | |
| 80 | + | var zero T | |
| 81 | + | return zero, d.err | |
| 82 | + | } | |
| 83 | + | return d.value, nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // initialize performs the actual initialization | |
| 87 | + | func (d *DeferredInit[T]) initialize(ctx context.Context) { | |
| 88 | + | atomic.StoreInt32(&d.state, int32(DeferredStateInitializing)) | |
| 89 | + | startTime := time.Now() | |
| 90 | + | ||
| 91 | + | logging.Debug(fmt.Sprintf("[deferred] Initializing %s...", d.name)) | |
| 92 | + | ||
| 93 | + | // Apply timeout if configured | |
| 94 | + | if d.timeout > 0 { | |
| 95 | + | var cancel context.CancelFunc | |
| 96 | + | ctx, cancel = context.WithTimeout(ctx, d.timeout) | |
| 97 | + | defer cancel() | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | value, err := d.initFunc(ctx) | |
| 101 | + | ||
| 102 | + | d.mu.Lock() | |
| 103 | + | defer d.mu.Unlock() | |
| 104 | + | ||
| 105 | + | elapsed := time.Since(startTime) | |
| 106 | + | ||
| 107 | + | if err != nil { | |
| 108 | + | d.err = fmt.Errorf("deferred init %s failed: %w", d.name, err) | |
| 109 | + | atomic.StoreInt32(&d.state, int32(DeferredStateFailed)) | |
| 110 | + | logging.Error(fmt.Sprintf("[deferred] %s initialization failed after %v: %v", d.name, elapsed, err)) | |
| 111 | + | return | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | d.value = value | |
| 115 | + | atomic.StoreInt32(&d.state, int32(DeferredStateReady)) | |
| 116 | + | logging.Info(fmt.Sprintf("[deferred] %s initialized in %v", d.name, elapsed)) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // TryGet returns the value if already initialized, without blocking. | |
| 120 | + | // Returns (value, true) if ready, or (zero, false) if not yet initialized. | |
| 121 | + | func (d *DeferredInit[T]) TryGet() (T, bool) { | |
| 122 | + | state := DeferredState(atomic.LoadInt32(&d.state)) | |
| 123 | + | if state != DeferredStateReady { | |
| 124 | + | var zero T | |
| 125 | + | return zero, false | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | d.mu.RLock() | |
| 129 | + | v := d.value | |
| 130 | + | d.mu.RUnlock() | |
| 131 | + | return v, true | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // IsReady returns true if initialization has completed successfully | |
| 135 | + | func (d *DeferredInit[T]) IsReady() bool { | |
| 136 | + | return DeferredState(atomic.LoadInt32(&d.state)) == DeferredStateReady | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // IsFailed returns true if initialization failed | |
| 140 | + | func (d *DeferredInit[T]) IsFailed() bool { | |
| 141 | + | return DeferredState(atomic.LoadInt32(&d.state)) == DeferredStateFailed | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // State returns the current initialization state | |
| 145 | + | func (d *DeferredInit[T]) State() DeferredState { | |
| 146 | + | return DeferredState(atomic.LoadInt32(&d.state)) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Name returns the identifier of this deferred initializer | |
| 150 | + | func (d *DeferredInit[T]) Name() string { | |
| 151 | + | return d.name | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // Error returns the initialization error, if any | |
| 155 | + | func (d *DeferredInit[T]) Error() error { | |
| 156 | + | d.mu.RLock() | |
| 157 | + | defer d.mu.RUnlock() | |
| 158 | + | return d.err | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // DeferredInitGroup manages a collection of deferred initializers | |
| 162 | + | type DeferredInitGroup struct { | |
| 163 | + | mu sync.RWMutex | |
| 164 | + | items map[string]interface{ State() DeferredState } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // NewDeferredInitGroup creates a new group for tracking deferred initializers | |
| 168 | + | func NewDeferredInitGroup() *DeferredInitGroup { | |
| 169 | + | return &DeferredInitGroup{ | |
| 170 | + | items: make(map[string]interface{ State() DeferredState }), | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // Register adds a deferred initializer to the group | |
| 175 | + | func (g *DeferredInitGroup) Register(name string, item interface{ State() DeferredState }) { | |
| 176 | + | g.mu.Lock() | |
| 177 | + | defer g.mu.Unlock() | |
| 178 | + | g.items[name] = item | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // Stats returns statistics about all registered initializers | |
| 182 | + | func (g *DeferredInitGroup) Stats() map[string]DeferredState { | |
| 183 | + | g.mu.RLock() | |
| 184 | + | defer g.mu.RUnlock() | |
| 185 | + | ||
| 186 | + | stats := make(map[string]DeferredState, len(g.items)) | |
| 187 | + | for name, item := range g.items { | |
| 188 | + | stats[name] = item.State() | |
| 189 | + | } | |
| 190 | + | return stats | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // AllReady returns true if all registered initializers are ready | |
| 194 | + | func (g *DeferredInitGroup) AllReady() bool { | |
| 195 | + | g.mu.RLock() | |
| 196 | + | defer g.mu.RUnlock() | |
| 197 | + | ||
| 198 | + | for _, item := range g.items { | |
| 199 | + | if item.State() != DeferredStateReady { | |
| 200 | + | return false | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | return true | |
| 204 | + | } | |
| @@ -0,0 +1,204 @@ | |||||
| 1 | + | // Package browser provides the main browser application components. | |||
| 2 | + | package browser | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "sync" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/logging" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // DeferredState represents the state of a deferred initialization | |||
| 15 | + | type DeferredState int32 | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | DeferredStatePending DeferredState = iota | |||
| 19 | + | DeferredStateInitializing | |||
| 20 | + | DeferredStateReady | |||
| 21 | + | DeferredStateFailed | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // DeferredInit manages lazy initialization of optional services. | |||
| 25 | + | // Services are only initialized when first accessed, reducing boot time. | |||
| 26 | + | type DeferredInit[T any] struct { | |||
| 27 | + | name string | |||
| 28 | + | initFunc func(ctx context.Context) (T, error) | |||
| 29 | + | timeout time.Duration | |||
| 30 | + | ||||
| 31 | + | mu sync.RWMutex | |||
| 32 | + | state int32 // atomic: DeferredState | |||
| 33 | + | value T | |||
| 34 | + | err error | |||
| 35 | + | initOnce sync.Once | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // NewDeferredInit creates a new deferred initializer. | |||
| 39 | + | // name: identifier for logging | |||
| 40 | + | // initFunc: function to create the value (called at most once) | |||
| 41 | + | // timeout: maximum time to wait for initialization (0 = no timeout) | |||
| 42 | + | func NewDeferredInit[T any](name string, initFunc func(ctx context.Context) (T, error), timeout time.Duration) *DeferredInit[T] { | |||
| 43 | + | return &DeferredInit[T]{ | |||
| 44 | + | name: name, | |||
| 45 | + | initFunc: initFunc, | |||
| 46 | + | timeout: timeout, | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Get returns the initialized value, initializing it on first call. | |||
| 51 | + | // Subsequent calls return the cached value or error. | |||
| 52 | + | // Thread-safe: multiple goroutines can call Get() simultaneously. | |||
| 53 | + | func (d *DeferredInit[T]) Get(ctx context.Context) (T, error) { | |||
| 54 | + | // Fast path: already initialized | |||
| 55 | + | state := DeferredState(atomic.LoadInt32(&d.state)) | |||
| 56 | + | if state == DeferredStateReady { | |||
| 57 | + | d.mu.RLock() | |||
| 58 | + | v := d.value | |||
| 59 | + | d.mu.RUnlock() | |||
| 60 | + | return v, nil | |||
| 61 | + | } | |||
| 62 | + | if state == DeferredStateFailed { | |||
| 63 | + | d.mu.RLock() | |||
| 64 | + | err := d.err | |||
| 65 | + | d.mu.RUnlock() | |||
| 66 | + | var zero T | |||
| 67 | + | return zero, err | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Slow path: need to initialize | |||
| 71 | + | d.initOnce.Do(func() { | |||
| 72 | + | d.initialize(ctx) | |||
| 73 | + | }) | |||
| 74 | + | ||||
| 75 | + | // Return result | |||
| 76 | + | d.mu.RLock() | |||
| 77 | + | defer d.mu.RUnlock() | |||
| 78 | + | ||||
| 79 | + | if d.err != nil { | |||
| 80 | + | var zero T | |||
| 81 | + | return zero, d.err | |||
| 82 | + | } | |||
| 83 | + | return d.value, nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // initialize performs the actual initialization | |||
| 87 | + | func (d *DeferredInit[T]) initialize(ctx context.Context) { | |||
| 88 | + | atomic.StoreInt32(&d.state, int32(DeferredStateInitializing)) | |||
| 89 | + | startTime := time.Now() | |||
| 90 | + | ||||
| 91 | + | logging.Debug(fmt.Sprintf("[deferred] Initializing %s...", d.name)) | |||
| 92 | + | ||||
| 93 | + | // Apply timeout if configured | |||
| 94 | + | if d.timeout > 0 { | |||
| 95 | + | var cancel context.CancelFunc | |||
| 96 | + | ctx, cancel = context.WithTimeout(ctx, d.timeout) | |||
| 97 | + | defer cancel() | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | value, err := d.initFunc(ctx) | |||
| 101 | + | ||||
| 102 | + | d.mu.Lock() | |||
| 103 | + | defer d.mu.Unlock() | |||
| 104 | + | ||||
| 105 | + | elapsed := time.Since(startTime) | |||
| 106 | + | ||||
| 107 | + | if err != nil { | |||
| 108 | + | d.err = fmt.Errorf("deferred init %s failed: %w", d.name, err) | |||
| 109 | + | atomic.StoreInt32(&d.state, int32(DeferredStateFailed)) | |||
| 110 | + | logging.Error(fmt.Sprintf("[deferred] %s initialization failed after %v: %v", d.name, elapsed, err)) | |||
| 111 | + | return | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | d.value = value | |||
| 115 | + | atomic.StoreInt32(&d.state, int32(DeferredStateReady)) | |||
| 116 | + | logging.Info(fmt.Sprintf("[deferred] %s initialized in %v", d.name, elapsed)) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // TryGet returns the value if already initialized, without blocking. | |||
| 120 | + | // Returns (value, true) if ready, or (zero, false) if not yet initialized. | |||
| 121 | + | func (d *DeferredInit[T]) TryGet() (T, bool) { | |||
| 122 | + | state := DeferredState(atomic.LoadInt32(&d.state)) | |||
| 123 | + | if state != DeferredStateReady { | |||
| 124 | + | var zero T | |||
| 125 | + | return zero, false | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | d.mu.RLock() | |||
| 129 | + | v := d.value | |||
| 130 | + | d.mu.RUnlock() | |||
| 131 | + | return v, true | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // IsReady returns true if initialization has completed successfully | |||
| 135 | + | func (d *DeferredInit[T]) IsReady() bool { | |||
| 136 | + | return DeferredState(atomic.LoadInt32(&d.state)) == DeferredStateReady | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // IsFailed returns true if initialization failed | |||
| 140 | + | func (d *DeferredInit[T]) IsFailed() bool { | |||
| 141 | + | return DeferredState(atomic.LoadInt32(&d.state)) == DeferredStateFailed | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // State returns the current initialization state | |||
| 145 | + | func (d *DeferredInit[T]) State() DeferredState { | |||
| 146 | + | return DeferredState(atomic.LoadInt32(&d.state)) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Name returns the identifier of this deferred initializer | |||
| 150 | + | func (d *DeferredInit[T]) Name() string { | |||
| 151 | + | return d.name | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // Error returns the initialization error, if any | |||
| 155 | + | func (d *DeferredInit[T]) Error() error { | |||
| 156 | + | d.mu.RLock() | |||
| 157 | + | defer d.mu.RUnlock() | |||
| 158 | + | return d.err | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // DeferredInitGroup manages a collection of deferred initializers | |||
| 162 | + | type DeferredInitGroup struct { | |||
| 163 | + | mu sync.RWMutex | |||
| 164 | + | items map[string]interface{ State() DeferredState } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // NewDeferredInitGroup creates a new group for tracking deferred initializers | |||
| 168 | + | func NewDeferredInitGroup() *DeferredInitGroup { | |||
| 169 | + | return &DeferredInitGroup{ | |||
| 170 | + | items: make(map[string]interface{ State() DeferredState }), | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // Register adds a deferred initializer to the group | |||
| 175 | + | func (g *DeferredInitGroup) Register(name string, item interface{ State() DeferredState }) { | |||
| 176 | + | g.mu.Lock() | |||
| 177 | + | defer g.mu.Unlock() | |||
| 178 | + | g.items[name] = item | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // Stats returns statistics about all registered initializers | |||
| 182 | + | func (g *DeferredInitGroup) Stats() map[string]DeferredState { | |||
| 183 | + | g.mu.RLock() | |||
| 184 | + | defer g.mu.RUnlock() | |||
| 185 | + | ||||
| 186 | + | stats := make(map[string]DeferredState, len(g.items)) | |||
| 187 | + | for name, item := range g.items { | |||
| 188 | + | stats[name] = item.State() | |||
| 189 | + | } | |||
| 190 | + | return stats | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // AllReady returns true if all registered initializers are ready | |||
| 194 | + | func (g *DeferredInitGroup) AllReady() bool { | |||
| 195 | + | g.mu.RLock() | |||
| 196 | + | defer g.mu.RUnlock() | |||
| 197 | + | ||||
| 198 | + | for _, item := range g.items { | |||
| 199 | + | if item.State() != DeferredStateReady { | |||
| 200 | + | return false | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | return true | |||
| 204 | + | } | |||
A internal/app/browser/prefetch.go +306 -0
| @@ -0,0 +1,306 @@ | |||
| 1 | + | // Package browser provides the main browser application components. | |
| 2 | + | package browser | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "net/url" | |
| 8 | + | "sync" | |
| 9 | + | "sync/atomic" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/db" | |
| 13 | + | "github.com/bnema/dumber/internal/logging" | |
| 14 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // PrefetchManager handles DNS prefetching and resource preloading. | |
| 18 | + | // Improves page load times by resolving DNS before navigation. | |
| 19 | + | type PrefetchManager struct { | |
| 20 | + | mu sync.RWMutex | |
| 21 | + | ||
| 22 | + | // Configuration | |
| 23 | + | enabled int32 // atomic: 1 = enabled, 0 = disabled | |
| 24 | + | maxPrefetches int // Maximum concurrent prefetches | |
| 25 | + | throttleDelay time.Duration // Minimum delay between prefetches for same domain | |
| 26 | + | maxCacheSize int // Maximum entries in recentPrefetches before cleanup | |
| 27 | + | ||
| 28 | + | // State tracking | |
| 29 | + | recentPrefetches map[string]time.Time // domain -> last prefetch time | |
| 30 | + | prefetchCount int64 | |
| 31 | + | ||
| 32 | + | // Database access for history-based prefetching | |
| 33 | + | queries db.DatabaseQuerier | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // PrefetchConfig contains configuration for the prefetch manager | |
| 37 | + | type PrefetchConfig struct { | |
| 38 | + | Enabled bool | |
| 39 | + | MaxPrefetches int | |
| 40 | + | ThrottleDelay time.Duration | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // DefaultPrefetchConfig returns sensible default configuration | |
| 44 | + | func DefaultPrefetchConfig() PrefetchConfig { | |
| 45 | + | return PrefetchConfig{ | |
| 46 | + | Enabled: true, | |
| 47 | + | MaxPrefetches: 10, | |
| 48 | + | ThrottleDelay: 100 * time.Millisecond, | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // NewPrefetchManager creates a new prefetch manager | |
| 53 | + | func NewPrefetchManager(cfg PrefetchConfig, queries db.DatabaseQuerier) *PrefetchManager { | |
| 54 | + | var enabled int32 | |
| 55 | + | if cfg.Enabled { | |
| 56 | + | enabled = 1 | |
| 57 | + | } | |
| 58 | + | return &PrefetchManager{ | |
| 59 | + | enabled: enabled, | |
| 60 | + | maxPrefetches: cfg.MaxPrefetches, | |
| 61 | + | throttleDelay: cfg.ThrottleDelay, | |
| 62 | + | maxCacheSize: 500, // Limit cache to prevent unbounded growth | |
| 63 | + | recentPrefetches: make(map[string]time.Time), | |
| 64 | + | queries: queries, | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // isEnabled returns true if prefetching is enabled (atomic read) | |
| 69 | + | func (pm *PrefetchManager) isEnabled() bool { | |
| 70 | + | return atomic.LoadInt32(&pm.enabled) == 1 | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // cleanupOldEntries removes old entries from the cache to prevent unbounded growth | |
| 74 | + | func (pm *PrefetchManager) cleanupOldEntries() { | |
| 75 | + | // Only cleanup if we exceed max size | |
| 76 | + | if len(pm.recentPrefetches) <= pm.maxCacheSize { | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | cutoff := time.Now().Add(-10 * pm.throttleDelay) | |
| 81 | + | for hostname, lastTime := range pm.recentPrefetches { | |
| 82 | + | if lastTime.Before(cutoff) { | |
| 83 | + | delete(pm.recentPrefetches, hostname) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // PrefetchDNS prefetches DNS for the given URL. | |
| 89 | + | // Throttles requests to avoid excessive DNS queries. | |
| 90 | + | func (pm *PrefetchManager) PrefetchDNS(rawURL string) { | |
| 91 | + | if !pm.isEnabled() || rawURL == "" { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Extract hostname from URL | |
| 96 | + | hostname := pm.extractHostname(rawURL) | |
| 97 | + | if hostname == "" { | |
| 98 | + | return | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Check throttle | |
| 102 | + | pm.mu.Lock() | |
| 103 | + | if lastPrefetch, exists := pm.recentPrefetches[hostname]; exists { | |
| 104 | + | if time.Since(lastPrefetch) < pm.throttleDelay { | |
| 105 | + | pm.mu.Unlock() | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | pm.recentPrefetches[hostname] = time.Now() | |
| 110 | + | pm.prefetchCount++ | |
| 111 | + | pm.cleanupOldEntries() // Prevent unbounded growth | |
| 112 | + | pm.mu.Unlock() | |
| 113 | + | ||
| 114 | + | // Perform DNS prefetch | |
| 115 | + | webkit.PrefetchDNS(hostname) | |
| 116 | + | logging.Debug(fmt.Sprintf("[prefetch] DNS prefetch for %s", hostname)) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // PrefetchDNSBatch prefetches DNS for multiple URLs | |
| 120 | + | func (pm *PrefetchManager) PrefetchDNSBatch(urls []string) { | |
| 121 | + | if !pm.isEnabled() { | |
| 122 | + | return | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | seen := make(map[string]bool) | |
| 126 | + | count := 0 | |
| 127 | + | ||
| 128 | + | for _, rawURL := range urls { | |
| 129 | + | if count >= pm.maxPrefetches { | |
| 130 | + | break | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | hostname := pm.extractHostname(rawURL) | |
| 134 | + | if hostname == "" || seen[hostname] { | |
| 135 | + | continue | |
| 136 | + | } | |
| 137 | + | seen[hostname] = true | |
| 138 | + | ||
| 139 | + | pm.PrefetchDNS(rawURL) | |
| 140 | + | count++ | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | if count > 0 { | |
| 144 | + | logging.Debug(fmt.Sprintf("[prefetch] Batch prefetched DNS for %d domains", count)) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // PrefetchFromHistory prefetches DNS for frequently visited domains. | |
| 149 | + | // Should be called after startup to warm DNS cache. | |
| 150 | + | func (pm *PrefetchManager) PrefetchFromHistory(ctx context.Context) { | |
| 151 | + | if !pm.isEnabled() || pm.queries == nil { | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // Get most visited URLs from history | |
| 156 | + | entries, err := pm.queries.GetMostVisited(ctx, int64(pm.maxPrefetches*2)) | |
| 157 | + | if err != nil { | |
| 158 | + | logging.Warn(fmt.Sprintf("[prefetch] Failed to get history for prefetch: %v", err)) | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // Extract unique hostnames | |
| 163 | + | seen := make(map[string]bool) | |
| 164 | + | hostnames := make([]string, 0, pm.maxPrefetches) | |
| 165 | + | ||
| 166 | + | for _, entry := range entries { | |
| 167 | + | hostname := pm.extractHostname(entry.Url) | |
| 168 | + | if hostname == "" || seen[hostname] { | |
| 169 | + | continue | |
| 170 | + | } | |
| 171 | + | seen[hostname] = true | |
| 172 | + | hostnames = append(hostnames, hostname) | |
| 173 | + | ||
| 174 | + | if len(hostnames) >= pm.maxPrefetches { | |
| 175 | + | break | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // Prefetch in background | |
| 180 | + | if len(hostnames) > 0 { | |
| 181 | + | go func() { | |
| 182 | + | for _, hostname := range hostnames { | |
| 183 | + | webkit.PrefetchDNS(hostname) | |
| 184 | + | pm.mu.Lock() | |
| 185 | + | pm.recentPrefetches[hostname] = time.Now() | |
| 186 | + | pm.prefetchCount++ | |
| 187 | + | pm.mu.Unlock() | |
| 188 | + | } | |
| 189 | + | logging.Info(fmt.Sprintf("[prefetch] Prefetched DNS for %d domains from history", len(hostnames))) | |
| 190 | + | }() | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // PrefetchCommonDomains prefetches DNS for commonly used CDNs and services. | |
| 195 | + | // Call this at startup for faster loading of common resources. | |
| 196 | + | func (pm *PrefetchManager) PrefetchCommonDomains() { | |
| 197 | + | if !pm.isEnabled() { | |
| 198 | + | return | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | commonDomains := []string{ | |
| 202 | + | // CDNs | |
| 203 | + | "cdnjs.cloudflare.com", | |
| 204 | + | "cdn.jsdelivr.net", | |
| 205 | + | "unpkg.com", | |
| 206 | + | "code.jquery.com", | |
| 207 | + | // Fonts | |
| 208 | + | "fonts.googleapis.com", | |
| 209 | + | "fonts.gstatic.com", | |
| 210 | + | // Common services | |
| 211 | + | "www.google.com", | |
| 212 | + | "www.googleapis.com", | |
| 213 | + | "api.github.com", | |
| 214 | + | "raw.githubusercontent.com", | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | go func() { | |
| 218 | + | for _, domain := range commonDomains { | |
| 219 | + | webkit.PrefetchDNS(domain) | |
| 220 | + | pm.mu.Lock() | |
| 221 | + | pm.recentPrefetches[domain] = time.Now() | |
| 222 | + | pm.prefetchCount++ | |
| 223 | + | pm.mu.Unlock() | |
| 224 | + | } | |
| 225 | + | logging.Info(fmt.Sprintf("[prefetch] Prefetched DNS for %d common domains", len(commonDomains))) | |
| 226 | + | }() | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // OnLinkHover should be called when user hovers over a link. | |
| 230 | + | // Prefetches DNS for the link target. | |
| 231 | + | func (pm *PrefetchManager) OnLinkHover(linkURL string) { | |
| 232 | + | pm.PrefetchDNS(linkURL) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // extractHostname extracts the hostname from a URL string | |
| 236 | + | func (pm *PrefetchManager) extractHostname(rawURL string) string { | |
| 237 | + | if rawURL == "" { | |
| 238 | + | return "" | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // Handle URLs without scheme | |
| 242 | + | if rawURL[0] != 'h' && rawURL[0] != 'H' { | |
| 243 | + | // Check if it looks like a hostname | |
| 244 | + | if !containsScheme(rawURL) { | |
| 245 | + | rawURL = "https://" + rawURL | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | parsed, err := url.Parse(rawURL) | |
| 250 | + | if err != nil { | |
| 251 | + | return "" | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | hostname := parsed.Hostname() | |
| 255 | + | if hostname == "" || hostname == "localhost" { | |
| 256 | + | return "" | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // Skip internal URLs | |
| 260 | + | if hostname == "127.0.0.1" || hostname == "::1" { | |
| 261 | + | return "" | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | return hostname | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // containsScheme checks if URL contains a scheme | |
| 268 | + | func containsScheme(rawURL string) bool { | |
| 269 | + | for i := 0; i < len(rawURL) && i < 10; i++ { | |
| 270 | + | if rawURL[i] == ':' { | |
| 271 | + | return true | |
| 272 | + | } | |
| 273 | + | if rawURL[i] == '/' || rawURL[i] == '?' || rawURL[i] == '#' { | |
| 274 | + | return false | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | return false | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // Stats returns prefetch statistics | |
| 281 | + | func (pm *PrefetchManager) Stats() map[string]interface{} { | |
| 282 | + | pm.mu.RLock() | |
| 283 | + | defer pm.mu.RUnlock() | |
| 284 | + | ||
| 285 | + | return map[string]interface{}{ | |
| 286 | + | "enabled": pm.isEnabled(), | |
| 287 | + | "prefetch_count": pm.prefetchCount, | |
| 288 | + | "cached_domains": len(pm.recentPrefetches), | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // SetEnabled enables or disables prefetching | |
| 293 | + | func (pm *PrefetchManager) SetEnabled(enabled bool) { | |
| 294 | + | var val int32 | |
| 295 | + | if enabled { | |
| 296 | + | val = 1 | |
| 297 | + | } | |
| 298 | + | atomic.StoreInt32(&pm.enabled, val) | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // ClearCache clears the prefetch throttle cache | |
| 302 | + | func (pm *PrefetchManager) ClearCache() { | |
| 303 | + | pm.mu.Lock() | |
| 304 | + | defer pm.mu.Unlock() | |
| 305 | + | pm.recentPrefetches = make(map[string]time.Time) | |
| 306 | + | } | |
| @@ -0,0 +1,306 @@ | |||||
| 1 | + | // Package browser provides the main browser application components. | |||
| 2 | + | package browser | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net/url" | |||
| 8 | + | "sync" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/db" | |||
| 13 | + | "github.com/bnema/dumber/internal/logging" | |||
| 14 | + | "github.com/bnema/dumber/pkg/webkit" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // PrefetchManager handles DNS prefetching and resource preloading. | |||
| 18 | + | // Improves page load times by resolving DNS before navigation. | |||
| 19 | + | type PrefetchManager struct { | |||
| 20 | + | mu sync.RWMutex | |||
| 21 | + | ||||
| 22 | + | // Configuration | |||
| 23 | + | enabled int32 // atomic: 1 = enabled, 0 = disabled | |||
| 24 | + | maxPrefetches int // Maximum concurrent prefetches | |||
| 25 | + | throttleDelay time.Duration // Minimum delay between prefetches for same domain | |||
| 26 | + | maxCacheSize int // Maximum entries in recentPrefetches before cleanup | |||
| 27 | + | ||||
| 28 | + | // State tracking | |||
| 29 | + | recentPrefetches map[string]time.Time // domain -> last prefetch time | |||
| 30 | + | prefetchCount int64 | |||
| 31 | + | ||||
| 32 | + | // Database access for history-based prefetching | |||
| 33 | + | queries db.DatabaseQuerier | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // PrefetchConfig contains configuration for the prefetch manager | |||
| 37 | + | type PrefetchConfig struct { | |||
| 38 | + | Enabled bool | |||
| 39 | + | MaxPrefetches int | |||
| 40 | + | ThrottleDelay time.Duration | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // DefaultPrefetchConfig returns sensible default configuration | |||
| 44 | + | func DefaultPrefetchConfig() PrefetchConfig { | |||
| 45 | + | return PrefetchConfig{ | |||
| 46 | + | Enabled: true, | |||
| 47 | + | MaxPrefetches: 10, | |||
| 48 | + | ThrottleDelay: 100 * time.Millisecond, | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // NewPrefetchManager creates a new prefetch manager | |||
| 53 | + | func NewPrefetchManager(cfg PrefetchConfig, queries db.DatabaseQuerier) *PrefetchManager { | |||
| 54 | + | var enabled int32 | |||
| 55 | + | if cfg.Enabled { | |||
| 56 | + | enabled = 1 | |||
| 57 | + | } | |||
| 58 | + | return &PrefetchManager{ | |||
| 59 | + | enabled: enabled, | |||
| 60 | + | maxPrefetches: cfg.MaxPrefetches, | |||
| 61 | + | throttleDelay: cfg.ThrottleDelay, | |||
| 62 | + | maxCacheSize: 500, // Limit cache to prevent unbounded growth | |||
| 63 | + | recentPrefetches: make(map[string]time.Time), | |||
| 64 | + | queries: queries, | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // isEnabled returns true if prefetching is enabled (atomic read) | |||
| 69 | + | func (pm *PrefetchManager) isEnabled() bool { | |||
| 70 | + | return atomic.LoadInt32(&pm.enabled) == 1 | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // cleanupOldEntries removes old entries from the cache to prevent unbounded growth | |||
| 74 | + | func (pm *PrefetchManager) cleanupOldEntries() { | |||
| 75 | + | // Only cleanup if we exceed max size | |||
| 76 | + | if len(pm.recentPrefetches) <= pm.maxCacheSize { | |||
| 77 | + | return | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | cutoff := time.Now().Add(-10 * pm.throttleDelay) | |||
| 81 | + | for hostname, lastTime := range pm.recentPrefetches { | |||
| 82 | + | if lastTime.Before(cutoff) { | |||
| 83 | + | delete(pm.recentPrefetches, hostname) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // PrefetchDNS prefetches DNS for the given URL. | |||
| 89 | + | // Throttles requests to avoid excessive DNS queries. | |||
| 90 | + | func (pm *PrefetchManager) PrefetchDNS(rawURL string) { | |||
| 91 | + | if !pm.isEnabled() || rawURL == "" { | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // Extract hostname from URL | |||
| 96 | + | hostname := pm.extractHostname(rawURL) | |||
| 97 | + | if hostname == "" { | |||
| 98 | + | return | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // Check throttle | |||
| 102 | + | pm.mu.Lock() | |||
| 103 | + | if lastPrefetch, exists := pm.recentPrefetches[hostname]; exists { | |||
| 104 | + | if time.Since(lastPrefetch) < pm.throttleDelay { | |||
| 105 | + | pm.mu.Unlock() | |||
| 106 | + | return | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | pm.recentPrefetches[hostname] = time.Now() | |||
| 110 | + | pm.prefetchCount++ | |||
| 111 | + | pm.cleanupOldEntries() // Prevent unbounded growth | |||
| 112 | + | pm.mu.Unlock() | |||
| 113 | + | ||||
| 114 | + | // Perform DNS prefetch | |||
| 115 | + | webkit.PrefetchDNS(hostname) | |||
| 116 | + | logging.Debug(fmt.Sprintf("[prefetch] DNS prefetch for %s", hostname)) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // PrefetchDNSBatch prefetches DNS for multiple URLs | |||
| 120 | + | func (pm *PrefetchManager) PrefetchDNSBatch(urls []string) { | |||
| 121 | + | if !pm.isEnabled() { | |||
| 122 | + | return | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | seen := make(map[string]bool) | |||
| 126 | + | count := 0 | |||
| 127 | + | ||||
| 128 | + | for _, rawURL := range urls { | |||
| 129 | + | if count >= pm.maxPrefetches { | |||
| 130 | + | break | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | hostname := pm.extractHostname(rawURL) | |||
| 134 | + | if hostname == "" || seen[hostname] { | |||
| 135 | + | continue | |||
| 136 | + | } | |||
| 137 | + | seen[hostname] = true | |||
| 138 | + | ||||
| 139 | + | pm.PrefetchDNS(rawURL) | |||
| 140 | + | count++ | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | if count > 0 { | |||
| 144 | + | logging.Debug(fmt.Sprintf("[prefetch] Batch prefetched DNS for %d domains", count)) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // PrefetchFromHistory prefetches DNS for frequently visited domains. | |||
| 149 | + | // Should be called after startup to warm DNS cache. | |||
| 150 | + | func (pm *PrefetchManager) PrefetchFromHistory(ctx context.Context) { | |||
| 151 | + | if !pm.isEnabled() || pm.queries == nil { | |||
| 152 | + | return | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // Get most visited URLs from history | |||
| 156 | + | entries, err := pm.queries.GetMostVisited(ctx, int64(pm.maxPrefetches*2)) | |||
| 157 | + | if err != nil { | |||
| 158 | + | logging.Warn(fmt.Sprintf("[prefetch] Failed to get history for prefetch: %v", err)) | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // Extract unique hostnames | |||
| 163 | + | seen := make(map[string]bool) | |||
| 164 | + | hostnames := make([]string, 0, pm.maxPrefetches) | |||
| 165 | + | ||||
| 166 | + | for _, entry := range entries { | |||
| 167 | + | hostname := pm.extractHostname(entry.Url) | |||
| 168 | + | if hostname == "" || seen[hostname] { | |||
| 169 | + | continue | |||
| 170 | + | } | |||
| 171 | + | seen[hostname] = true | |||
| 172 | + | hostnames = append(hostnames, hostname) | |||
| 173 | + | ||||
| 174 | + | if len(hostnames) >= pm.maxPrefetches { | |||
| 175 | + | break | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // Prefetch in background | |||
| 180 | + | if len(hostnames) > 0 { | |||
| 181 | + | go func() { | |||
| 182 | + | for _, hostname := range hostnames { | |||
| 183 | + | webkit.PrefetchDNS(hostname) | |||
| 184 | + | pm.mu.Lock() | |||
| 185 | + | pm.recentPrefetches[hostname] = time.Now() | |||
| 186 | + | pm.prefetchCount++ | |||
| 187 | + | pm.mu.Unlock() | |||
| 188 | + | } | |||
| 189 | + | logging.Info(fmt.Sprintf("[prefetch] Prefetched DNS for %d domains from history", len(hostnames))) | |||
| 190 | + | }() | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // PrefetchCommonDomains prefetches DNS for commonly used CDNs and services. | |||
| 195 | + | // Call this at startup for faster loading of common resources. | |||
| 196 | + | func (pm *PrefetchManager) PrefetchCommonDomains() { | |||
| 197 | + | if !pm.isEnabled() { | |||
| 198 | + | return | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | commonDomains := []string{ | |||
| 202 | + | // CDNs | |||
| 203 | + | "cdnjs.cloudflare.com", | |||
| 204 | + | "cdn.jsdelivr.net", | |||
| 205 | + | "unpkg.com", | |||
| 206 | + | "code.jquery.com", | |||
| 207 | + | // Fonts | |||
| 208 | + | "fonts.googleapis.com", | |||
| 209 | + | "fonts.gstatic.com", | |||
| 210 | + | // Common services | |||
| 211 | + | "www.google.com", | |||
| 212 | + | "www.googleapis.com", | |||
| 213 | + | "api.github.com", | |||
| 214 | + | "raw.githubusercontent.com", | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | go func() { | |||
| 218 | + | for _, domain := range commonDomains { | |||
| 219 | + | webkit.PrefetchDNS(domain) | |||
| 220 | + | pm.mu.Lock() | |||
| 221 | + | pm.recentPrefetches[domain] = time.Now() | |||
| 222 | + | pm.prefetchCount++ | |||
| 223 | + | pm.mu.Unlock() | |||
| 224 | + | } | |||
| 225 | + | logging.Info(fmt.Sprintf("[prefetch] Prefetched DNS for %d common domains", len(commonDomains))) | |||
| 226 | + | }() | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // OnLinkHover should be called when user hovers over a link. | |||
| 230 | + | // Prefetches DNS for the link target. | |||
| 231 | + | func (pm *PrefetchManager) OnLinkHover(linkURL string) { | |||
| 232 | + | pm.PrefetchDNS(linkURL) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // extractHostname extracts the hostname from a URL string | |||
| 236 | + | func (pm *PrefetchManager) extractHostname(rawURL string) string { | |||
| 237 | + | if rawURL == "" { | |||
| 238 | + | return "" | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // Handle URLs without scheme | |||
| 242 | + | if rawURL[0] != 'h' && rawURL[0] != 'H' { | |||
| 243 | + | // Check if it looks like a hostname | |||
| 244 | + | if !containsScheme(rawURL) { | |||
| 245 | + | rawURL = "https://" + rawURL | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | parsed, err := url.Parse(rawURL) | |||
| 250 | + | if err != nil { | |||
| 251 | + | return "" | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | hostname := parsed.Hostname() | |||
| 255 | + | if hostname == "" || hostname == "localhost" { | |||
| 256 | + | return "" | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // Skip internal URLs | |||
| 260 | + | if hostname == "127.0.0.1" || hostname == "::1" { | |||
| 261 | + | return "" | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | return hostname | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // containsScheme checks if URL contains a scheme | |||
| 268 | + | func containsScheme(rawURL string) bool { | |||
| 269 | + | for i := 0; i < len(rawURL) && i < 10; i++ { | |||
| 270 | + | if rawURL[i] == ':' { | |||
| 271 | + | return true | |||
| 272 | + | } | |||
| 273 | + | if rawURL[i] == '/' || rawURL[i] == '?' || rawURL[i] == '#' { | |||
| 274 | + | return false | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | return false | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // Stats returns prefetch statistics | |||
| 281 | + | func (pm *PrefetchManager) Stats() map[string]interface{} { | |||
| 282 | + | pm.mu.RLock() | |||
| 283 | + | defer pm.mu.RUnlock() | |||
| 284 | + | ||||
| 285 | + | return map[string]interface{}{ | |||
| 286 | + | "enabled": pm.isEnabled(), | |||
| 287 | + | "prefetch_count": pm.prefetchCount, | |||
| 288 | + | "cached_domains": len(pm.recentPrefetches), | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // SetEnabled enables or disables prefetching | |||
| 293 | + | func (pm *PrefetchManager) SetEnabled(enabled bool) { | |||
| 294 | + | var val int32 | |||
| 295 | + | if enabled { | |||
| 296 | + | val = 1 | |||
| 297 | + | } | |||
| 298 | + | atomic.StoreInt32(&pm.enabled, val) | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | // ClearCache clears the prefetch throttle cache | |||
| 302 | + | func (pm *PrefetchManager) ClearCache() { | |||
| 303 | + | pm.mu.Lock() | |||
| 304 | + | defer pm.mu.Unlock() | |||
| 305 | + | pm.recentPrefetches = make(map[string]time.Time) | |||
| 306 | + | } | |||
A internal/app/browser/webview_pool.go +346 -0
| @@ -0,0 +1,346 @@ | |||
| 1 | + | // Package browser provides the main browser application components. | |
| 2 | + | package browser | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "fmt" | |
| 6 | + | "sync" | |
| 7 | + | "sync/atomic" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // PoolState represents the current state of the WebView pool | |
| 15 | + | type PoolState int32 | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | PoolStateIdle PoolState = iota | |
| 19 | + | PoolStateWarming | |
| 20 | + | PoolStateReady | |
| 21 | + | PoolStateStopped | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // WebViewPool manages pre-created WebViews for instant tab/pane creation. | |
| 25 | + | // Uses a "warm pool" strategy: pre-creates fresh WebViews rather than recycling, | |
| 26 | + | // since WebKitGTK doesn't support resetting WebView state. | |
| 27 | + | type WebViewPool struct { | |
| 28 | + | mu sync.RWMutex | |
| 29 | + | ||
| 30 | + | // Pool of ready-to-use WebViews | |
| 31 | + | available []*webkit.WebView | |
| 32 | + | ||
| 33 | + | // Configuration | |
| 34 | + | targetSize int // Target number of WebViews to maintain | |
| 35 | + | maxSize int // Maximum pool size | |
| 36 | + | warmupDelay time.Duration // Delay before initial warmup | |
| 37 | + | ||
| 38 | + | // State tracking | |
| 39 | + | state int32 // atomic: PoolState | |
| 40 | + | created int64 // atomic: total WebViews created | |
| 41 | + | acquired int64 // atomic: total WebViews acquired from pool | |
| 42 | + | misses int64 // atomic: times pool was empty when requested | |
| 43 | + | configGetter func() *webkit.Config | |
| 44 | + | ||
| 45 | + | // Shutdown | |
| 46 | + | stopCh chan struct{} | |
| 47 | + | wg sync.WaitGroup | |
| 48 | + | stopOnce sync.Once // Ensures Stop() is idempotent | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // PoolStats contains statistics about pool usage | |
| 52 | + | type PoolStats struct { | |
| 53 | + | Available int | |
| 54 | + | TargetSize int | |
| 55 | + | MaxSize int | |
| 56 | + | Created int64 | |
| 57 | + | Acquired int64 | |
| 58 | + | Misses int64 | |
| 59 | + | HitRate float64 | |
| 60 | + | State PoolState | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // NewWebViewPool creates a new WebView pool with the given configuration. | |
| 64 | + | // configGetter is called each time a new WebView needs to be created. | |
| 65 | + | func NewWebViewPool(targetSize, maxSize int, configGetter func() *webkit.Config) *WebViewPool { | |
| 66 | + | if targetSize < 1 { | |
| 67 | + | targetSize = 1 | |
| 68 | + | } | |
| 69 | + | if maxSize < targetSize { | |
| 70 | + | maxSize = targetSize | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | return &WebViewPool{ | |
| 74 | + | available: make([]*webkit.WebView, 0, maxSize), | |
| 75 | + | targetSize: targetSize, | |
| 76 | + | maxSize: maxSize, | |
| 77 | + | warmupDelay: 100 * time.Millisecond, // Wait for UI to render first | |
| 78 | + | configGetter: configGetter, | |
| 79 | + | stopCh: make(chan struct{}), | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // SetWarmupDelay sets the delay before initial pool warmup. | |
| 84 | + | // Must be called before Start(). | |
| 85 | + | func (p *WebViewPool) SetWarmupDelay(delay time.Duration) { | |
| 86 | + | p.warmupDelay = delay | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Start begins the pool warmup process in the background. | |
| 90 | + | // Should be called after the main window is visible. | |
| 91 | + | func (p *WebViewPool) Start() { | |
| 92 | + | if !atomic.CompareAndSwapInt32(&p.state, int32(PoolStateIdle), int32(PoolStateWarming)) { | |
| 93 | + | logging.Warn("[pool] Pool already started or stopped") | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | p.wg.Add(1) | |
| 98 | + | go p.warmupLoop() | |
| 99 | + | ||
| 100 | + | logging.Info(fmt.Sprintf("[pool] WebView pool started (target=%d, max=%d, delay=%v)", | |
| 101 | + | p.targetSize, p.maxSize, p.warmupDelay)) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // warmupLoop runs in background to maintain pool size | |
| 105 | + | func (p *WebViewPool) warmupLoop() { | |
| 106 | + | defer p.wg.Done() | |
| 107 | + | ||
| 108 | + | // Initial delay to let UI render first | |
| 109 | + | select { | |
| 110 | + | case <-time.After(p.warmupDelay): | |
| 111 | + | case <-p.stopCh: | |
| 112 | + | return | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Initial warmup | |
| 116 | + | p.warmup() | |
| 117 | + | atomic.StoreInt32(&p.state, int32(PoolStateReady)) | |
| 118 | + | ||
| 119 | + | // Periodic check to maintain pool size (every 500ms) | |
| 120 | + | ticker := time.NewTicker(500 * time.Millisecond) | |
| 121 | + | defer ticker.Stop() | |
| 122 | + | ||
| 123 | + | for { | |
| 124 | + | select { | |
| 125 | + | case <-ticker.C: | |
| 126 | + | p.warmup() | |
| 127 | + | case <-p.stopCh: | |
| 128 | + | return | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // warmup creates WebViews to reach target size | |
| 134 | + | func (p *WebViewPool) warmup() { | |
| 135 | + | p.mu.RLock() | |
| 136 | + | currentSize := len(p.available) | |
| 137 | + | p.mu.RUnlock() | |
| 138 | + | ||
| 139 | + | needed := p.targetSize - currentSize | |
| 140 | + | if needed <= 0 { | |
| 141 | + | return | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | logging.Debug(fmt.Sprintf("[pool] Warming up %d WebViews (current=%d, target=%d)", | |
| 145 | + | needed, currentSize, p.targetSize)) | |
| 146 | + | ||
| 147 | + | for i := 0; i < needed; i++ { | |
| 148 | + | select { | |
| 149 | + | case <-p.stopCh: | |
| 150 | + | return | |
| 151 | + | default: | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // Create WebView on GTK main thread | |
| 155 | + | var wv *webkit.WebView | |
| 156 | + | var err error | |
| 157 | + | ||
| 158 | + | done := make(chan struct{}) | |
| 159 | + | webkit.RunOnMainThread(func() { | |
| 160 | + | defer close(done) | |
| 161 | + | cfg := p.configGetter() | |
| 162 | + | if cfg == nil { | |
| 163 | + | logging.Error("[pool] Config getter returned nil") | |
| 164 | + | return | |
| 165 | + | } | |
| 166 | + | // Pool WebViews don't create their own window | |
| 167 | + | cfg.CreateWindow = false | |
| 168 | + | wv, err = webkit.NewWebView(cfg) | |
| 169 | + | }) | |
| 170 | + | <-done | |
| 171 | + | ||
| 172 | + | if err != nil { | |
| 173 | + | logging.Error(fmt.Sprintf("[pool] Failed to create WebView: %v", err)) | |
| 174 | + | continue | |
| 175 | + | } | |
| 176 | + | if wv == nil { | |
| 177 | + | logging.Error("[pool] Created WebView is nil") | |
| 178 | + | continue | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | p.mu.Lock() | |
| 182 | + | if len(p.available) < p.maxSize { | |
| 183 | + | p.available = append(p.available, wv) | |
| 184 | + | atomic.AddInt64(&p.created, 1) | |
| 185 | + | logging.Debug(fmt.Sprintf("[pool] Added WebView ID %d to pool (size=%d)", | |
| 186 | + | wv.ID(), len(p.available))) | |
| 187 | + | } else { | |
| 188 | + | // Pool full, destroy the extra WebView | |
| 189 | + | p.mu.Unlock() | |
| 190 | + | webkit.RunOnMainThread(func() { | |
| 191 | + | wv.Destroy() | |
| 192 | + | }) | |
| 193 | + | logging.Debug("[pool] Pool at max capacity, discarding extra WebView") | |
| 194 | + | continue | |
| 195 | + | } | |
| 196 | + | p.mu.Unlock() | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Acquire gets a WebView from the pool, or creates one if pool is empty. | |
| 201 | + | // The returned WebView is ready to use but needs to be added to a container. | |
| 202 | + | // Returns nil and error if creation fails. | |
| 203 | + | func (p *WebViewPool) Acquire() (*webkit.WebView, error) { | |
| 204 | + | state := PoolState(atomic.LoadInt32(&p.state)) | |
| 205 | + | if state == PoolStateStopped { | |
| 206 | + | return nil, fmt.Errorf("pool is stopped") | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | p.mu.Lock() | |
| 210 | + | if len(p.available) > 0 { | |
| 211 | + | // Take from pool (LIFO for better cache locality) | |
| 212 | + | wv := p.available[len(p.available)-1] | |
| 213 | + | p.available = p.available[:len(p.available)-1] | |
| 214 | + | remaining := len(p.available) // Capture before unlock to avoid race | |
| 215 | + | p.mu.Unlock() | |
| 216 | + | ||
| 217 | + | atomic.AddInt64(&p.acquired, 1) | |
| 218 | + | logging.Debug(fmt.Sprintf("[pool] Acquired WebView ID %d from pool (remaining=%d)", | |
| 219 | + | wv.ID(), remaining)) | |
| 220 | + | ||
| 221 | + | return wv, nil | |
| 222 | + | } | |
| 223 | + | p.mu.Unlock() | |
| 224 | + | ||
| 225 | + | // Pool empty, create on-demand (fallback) | |
| 226 | + | atomic.AddInt64(&p.misses, 1) | |
| 227 | + | logging.Debug("[pool] Pool empty, creating WebView on-demand") | |
| 228 | + | ||
| 229 | + | var wv *webkit.WebView | |
| 230 | + | var err error | |
| 231 | + | ||
| 232 | + | done := make(chan struct{}) | |
| 233 | + | webkit.RunOnMainThread(func() { | |
| 234 | + | defer close(done) | |
| 235 | + | cfg := p.configGetter() | |
| 236 | + | if cfg == nil { | |
| 237 | + | err = fmt.Errorf("config getter returned nil") | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | cfg.CreateWindow = false | |
| 241 | + | wv, err = webkit.NewWebView(cfg) | |
| 242 | + | }) | |
| 243 | + | <-done | |
| 244 | + | ||
| 245 | + | if err != nil { | |
| 246 | + | return nil, fmt.Errorf("failed to create WebView: %w", err) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | atomic.AddInt64(&p.created, 1) | |
| 250 | + | return wv, nil | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // TryAcquire attempts to get a WebView from the pool without blocking. | |
| 254 | + | // Returns nil if pool is empty (does not create on-demand). | |
| 255 | + | func (p *WebViewPool) TryAcquire() *webkit.WebView { | |
| 256 | + | state := PoolState(atomic.LoadInt32(&p.state)) | |
| 257 | + | if state == PoolStateStopped { | |
| 258 | + | return nil | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | p.mu.Lock() | |
| 262 | + | defer p.mu.Unlock() | |
| 263 | + | ||
| 264 | + | if len(p.available) > 0 { | |
| 265 | + | wv := p.available[len(p.available)-1] | |
| 266 | + | p.available = p.available[:len(p.available)-1] | |
| 267 | + | atomic.AddInt64(&p.acquired, 1) | |
| 268 | + | logging.Debug(fmt.Sprintf("[pool] TryAcquire: got WebView ID %d (remaining=%d)", | |
| 269 | + | wv.ID(), len(p.available))) | |
| 270 | + | return wv | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | atomic.AddInt64(&p.misses, 1) | |
| 274 | + | return nil | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // Stats returns current pool statistics | |
| 278 | + | func (p *WebViewPool) Stats() PoolStats { | |
| 279 | + | p.mu.RLock() | |
| 280 | + | available := len(p.available) | |
| 281 | + | p.mu.RUnlock() | |
| 282 | + | ||
| 283 | + | acquired := atomic.LoadInt64(&p.acquired) | |
| 284 | + | misses := atomic.LoadInt64(&p.misses) | |
| 285 | + | ||
| 286 | + | var hitRate float64 | |
| 287 | + | total := acquired + misses | |
| 288 | + | if total > 0 { | |
| 289 | + | hitRate = float64(acquired) / float64(total) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | return PoolStats{ | |
| 293 | + | Available: available, | |
| 294 | + | TargetSize: p.targetSize, | |
| 295 | + | MaxSize: p.maxSize, | |
| 296 | + | Created: atomic.LoadInt64(&p.created), | |
| 297 | + | Acquired: acquired, | |
| 298 | + | Misses: misses, | |
| 299 | + | HitRate: hitRate, | |
| 300 | + | State: PoolState(atomic.LoadInt32(&p.state)), | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // Stop shuts down the pool and destroys all pooled WebViews. | |
| 305 | + | // Safe to call multiple times. | |
| 306 | + | func (p *WebViewPool) Stop() { | |
| 307 | + | p.stopOnce.Do(func() { | |
| 308 | + | // Transition to stopped state from any running state | |
| 309 | + | for { | |
| 310 | + | oldState := atomic.LoadInt32(&p.state) | |
| 311 | + | if oldState == int32(PoolStateStopped) || oldState == int32(PoolStateIdle) { | |
| 312 | + | return | |
| 313 | + | } | |
| 314 | + | if atomic.CompareAndSwapInt32(&p.state, oldState, int32(PoolStateStopped)) { | |
| 315 | + | break | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | close(p.stopCh) | |
| 320 | + | p.wg.Wait() | |
| 321 | + | ||
| 322 | + | // Destroy all pooled WebViews | |
| 323 | + | p.mu.Lock() | |
| 324 | + | views := p.available | |
| 325 | + | p.available = nil | |
| 326 | + | p.mu.Unlock() | |
| 327 | + | ||
| 328 | + | if len(views) > 0 { | |
| 329 | + | logging.Info(fmt.Sprintf("[pool] Destroying %d pooled WebViews", len(views))) | |
| 330 | + | for _, wv := range views { | |
| 331 | + | if wv != nil && !wv.IsDestroyed() { | |
| 332 | + | wv.Destroy() | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | stats := p.Stats() | |
| 338 | + | logging.Info(fmt.Sprintf("[pool] Pool stopped (created=%d, acquired=%d, misses=%d, hitRate=%.2f%%)", | |
| 339 | + | stats.Created, stats.Acquired, stats.Misses, stats.HitRate*100)) | |
| 340 | + | }) | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // IsReady returns true if the pool has completed initial warmup | |
| 344 | + | func (p *WebViewPool) IsReady() bool { | |
| 345 | + | return PoolState(atomic.LoadInt32(&p.state)) == PoolStateReady | |
| 346 | + | } | |
| @@ -0,0 +1,346 @@ | |||||
| 1 | + | // Package browser provides the main browser application components. | |||
| 2 | + | package browser | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "fmt" | |||
| 6 | + | "sync" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/logging" | |||
| 11 | + | "github.com/bnema/dumber/pkg/webkit" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // PoolState represents the current state of the WebView pool | |||
| 15 | + | type PoolState int32 | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | PoolStateIdle PoolState = iota | |||
| 19 | + | PoolStateWarming | |||
| 20 | + | PoolStateReady | |||
| 21 | + | PoolStateStopped | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // WebViewPool manages pre-created WebViews for instant tab/pane creation. | |||
| 25 | + | // Uses a "warm pool" strategy: pre-creates fresh WebViews rather than recycling, | |||
| 26 | + | // since WebKitGTK doesn't support resetting WebView state. | |||
| 27 | + | type WebViewPool struct { | |||
| 28 | + | mu sync.RWMutex | |||
| 29 | + | ||||
| 30 | + | // Pool of ready-to-use WebViews | |||
| 31 | + | available []*webkit.WebView | |||
| 32 | + | ||||
| 33 | + | // Configuration | |||
| 34 | + | targetSize int // Target number of WebViews to maintain | |||
| 35 | + | maxSize int // Maximum pool size | |||
| 36 | + | warmupDelay time.Duration // Delay before initial warmup | |||
| 37 | + | ||||
| 38 | + | // State tracking | |||
| 39 | + | state int32 // atomic: PoolState | |||
| 40 | + | created int64 // atomic: total WebViews created | |||
| 41 | + | acquired int64 // atomic: total WebViews acquired from pool | |||
| 42 | + | misses int64 // atomic: times pool was empty when requested | |||
| 43 | + | configGetter func() *webkit.Config | |||
| 44 | + | ||||
| 45 | + | // Shutdown | |||
| 46 | + | stopCh chan struct{} | |||
| 47 | + | wg sync.WaitGroup | |||
| 48 | + | stopOnce sync.Once // Ensures Stop() is idempotent | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // PoolStats contains statistics about pool usage | |||
| 52 | + | type PoolStats struct { | |||
| 53 | + | Available int | |||
| 54 | + | TargetSize int | |||
| 55 | + | MaxSize int | |||
| 56 | + | Created int64 | |||
| 57 | + | Acquired int64 | |||
| 58 | + | Misses int64 | |||
| 59 | + | HitRate float64 | |||
| 60 | + | State PoolState | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // NewWebViewPool creates a new WebView pool with the given configuration. | |||
| 64 | + | // configGetter is called each time a new WebView needs to be created. | |||
| 65 | + | func NewWebViewPool(targetSize, maxSize int, configGetter func() *webkit.Config) *WebViewPool { | |||
| 66 | + | if targetSize < 1 { | |||
| 67 | + | targetSize = 1 | |||
| 68 | + | } | |||
| 69 | + | if maxSize < targetSize { | |||
| 70 | + | maxSize = targetSize | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | return &WebViewPool{ | |||
| 74 | + | available: make([]*webkit.WebView, 0, maxSize), | |||
| 75 | + | targetSize: targetSize, | |||
| 76 | + | maxSize: maxSize, | |||
| 77 | + | warmupDelay: 100 * time.Millisecond, // Wait for UI to render first | |||
| 78 | + | configGetter: configGetter, | |||
| 79 | + | stopCh: make(chan struct{}), | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // SetWarmupDelay sets the delay before initial pool warmup. | |||
| 84 | + | // Must be called before Start(). | |||
| 85 | + | func (p *WebViewPool) SetWarmupDelay(delay time.Duration) { | |||
| 86 | + | p.warmupDelay = delay | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // Start begins the pool warmup process in the background. | |||
| 90 | + | // Should be called after the main window is visible. | |||
| 91 | + | func (p *WebViewPool) Start() { | |||
| 92 | + | if !atomic.CompareAndSwapInt32(&p.state, int32(PoolStateIdle), int32(PoolStateWarming)) { | |||
| 93 | + | logging.Warn("[pool] Pool already started or stopped") | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | p.wg.Add(1) | |||
| 98 | + | go p.warmupLoop() | |||
| 99 | + | ||||
| 100 | + | logging.Info(fmt.Sprintf("[pool] WebView pool started (target=%d, max=%d, delay=%v)", | |||
| 101 | + | p.targetSize, p.maxSize, p.warmupDelay)) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // warmupLoop runs in background to maintain pool size | |||
| 105 | + | func (p *WebViewPool) warmupLoop() { | |||
| 106 | + | defer p.wg.Done() | |||
| 107 | + | ||||
| 108 | + | // Initial delay to let UI render first | |||
| 109 | + | select { | |||
| 110 | + | case <-time.After(p.warmupDelay): | |||
| 111 | + | case <-p.stopCh: | |||
| 112 | + | return | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // Initial warmup | |||
| 116 | + | p.warmup() | |||
| 117 | + | atomic.StoreInt32(&p.state, int32(PoolStateReady)) | |||
| 118 | + | ||||
| 119 | + | // Periodic check to maintain pool size (every 500ms) | |||
| 120 | + | ticker := time.NewTicker(500 * time.Millisecond) | |||
| 121 | + | defer ticker.Stop() | |||
| 122 | + | ||||
| 123 | + | for { | |||
| 124 | + | select { | |||
| 125 | + | case <-ticker.C: | |||
| 126 | + | p.warmup() | |||
| 127 | + | case <-p.stopCh: | |||
| 128 | + | return | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // warmup creates WebViews to reach target size | |||
| 134 | + | func (p *WebViewPool) warmup() { | |||
| 135 | + | p.mu.RLock() | |||
| 136 | + | currentSize := len(p.available) | |||
| 137 | + | p.mu.RUnlock() | |||
| 138 | + | ||||
| 139 | + | needed := p.targetSize - currentSize | |||
| 140 | + | if needed <= 0 { | |||
| 141 | + | return | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | logging.Debug(fmt.Sprintf("[pool] Warming up %d WebViews (current=%d, target=%d)", | |||
| 145 | + | needed, currentSize, p.targetSize)) | |||
| 146 | + | ||||
| 147 | + | for i := 0; i < needed; i++ { | |||
| 148 | + | select { | |||
| 149 | + | case <-p.stopCh: | |||
| 150 | + | return | |||
| 151 | + | default: | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // Create WebView on GTK main thread | |||
| 155 | + | var wv *webkit.WebView | |||
| 156 | + | var err error | |||
| 157 | + | ||||
| 158 | + | done := make(chan struct{}) | |||
| 159 | + | webkit.RunOnMainThread(func() { | |||
| 160 | + | defer close(done) | |||
| 161 | + | cfg := p.configGetter() | |||
| 162 | + | if cfg == nil { | |||
| 163 | + | logging.Error("[pool] Config getter returned nil") | |||
| 164 | + | return | |||
| 165 | + | } | |||
| 166 | + | // Pool WebViews don't create their own window | |||
| 167 | + | cfg.CreateWindow = false | |||
| 168 | + | wv, err = webkit.NewWebView(cfg) | |||
| 169 | + | }) | |||
| 170 | + | <-done | |||
| 171 | + | ||||
| 172 | + | if err != nil { | |||
| 173 | + | logging.Error(fmt.Sprintf("[pool] Failed to create WebView: %v", err)) | |||
| 174 | + | continue | |||
| 175 | + | } | |||
| 176 | + | if wv == nil { | |||
| 177 | + | logging.Error("[pool] Created WebView is nil") | |||
| 178 | + | continue | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | p.mu.Lock() | |||
| 182 | + | if len(p.available) < p.maxSize { | |||
| 183 | + | p.available = append(p.available, wv) | |||
| 184 | + | atomic.AddInt64(&p.created, 1) | |||
| 185 | + | logging.Debug(fmt.Sprintf("[pool] Added WebView ID %d to pool (size=%d)", | |||
| 186 | + | wv.ID(), len(p.available))) | |||
| 187 | + | } else { | |||
| 188 | + | // Pool full, destroy the extra WebView | |||
| 189 | + | p.mu.Unlock() | |||
| 190 | + | webkit.RunOnMainThread(func() { | |||
| 191 | + | wv.Destroy() | |||
| 192 | + | }) | |||
| 193 | + | logging.Debug("[pool] Pool at max capacity, discarding extra WebView") | |||
| 194 | + | continue | |||
| 195 | + | } | |||
| 196 | + | p.mu.Unlock() | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // Acquire gets a WebView from the pool, or creates one if pool is empty. | |||
| 201 | + | // The returned WebView is ready to use but needs to be added to a container. | |||
| 202 | + | // Returns nil and error if creation fails. | |||
| 203 | + | func (p *WebViewPool) Acquire() (*webkit.WebView, error) { | |||
| 204 | + | state := PoolState(atomic.LoadInt32(&p.state)) | |||
| 205 | + | if state == PoolStateStopped { | |||
| 206 | + | return nil, fmt.Errorf("pool is stopped") | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | p.mu.Lock() | |||
| 210 | + | if len(p.available) > 0 { | |||
| 211 | + | // Take from pool (LIFO for better cache locality) | |||
| 212 | + | wv := p.available[len(p.available)-1] | |||
| 213 | + | p.available = p.available[:len(p.available)-1] | |||
| 214 | + | remaining := len(p.available) // Capture before unlock to avoid race | |||
| 215 | + | p.mu.Unlock() | |||
| 216 | + | ||||
| 217 | + | atomic.AddInt64(&p.acquired, 1) | |||
| 218 | + | logging.Debug(fmt.Sprintf("[pool] Acquired WebView ID %d from pool (remaining=%d)", | |||
| 219 | + | wv.ID(), remaining)) | |||
| 220 | + | ||||
| 221 | + | return wv, nil | |||
| 222 | + | } | |||
| 223 | + | p.mu.Unlock() | |||
| 224 | + | ||||
| 225 | + | // Pool empty, create on-demand (fallback) | |||
| 226 | + | atomic.AddInt64(&p.misses, 1) | |||
| 227 | + | logging.Debug("[pool] Pool empty, creating WebView on-demand") | |||
| 228 | + | ||||
| 229 | + | var wv *webkit.WebView | |||
| 230 | + | var err error | |||
| 231 | + | ||||
| 232 | + | done := make(chan struct{}) | |||
| 233 | + | webkit.RunOnMainThread(func() { | |||
| 234 | + | defer close(done) | |||
| 235 | + | cfg := p.configGetter() | |||
| 236 | + | if cfg == nil { | |||
| 237 | + | err = fmt.Errorf("config getter returned nil") | |||
| 238 | + | return | |||
| 239 | + | } | |||
| 240 | + | cfg.CreateWindow = false | |||
| 241 | + | wv, err = webkit.NewWebView(cfg) | |||
| 242 | + | }) | |||
| 243 | + | <-done | |||
| 244 | + | ||||
| 245 | + | if err != nil { | |||
| 246 | + | return nil, fmt.Errorf("failed to create WebView: %w", err) | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | atomic.AddInt64(&p.created, 1) | |||
| 250 | + | return wv, nil | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | // TryAcquire attempts to get a WebView from the pool without blocking. | |||
| 254 | + | // Returns nil if pool is empty (does not create on-demand). | |||
| 255 | + | func (p *WebViewPool) TryAcquire() *webkit.WebView { | |||
| 256 | + | state := PoolState(atomic.LoadInt32(&p.state)) | |||
| 257 | + | if state == PoolStateStopped { | |||
| 258 | + | return nil | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | p.mu.Lock() | |||
| 262 | + | defer p.mu.Unlock() | |||
| 263 | + | ||||
| 264 | + | if len(p.available) > 0 { | |||
| 265 | + | wv := p.available[len(p.available)-1] | |||
| 266 | + | p.available = p.available[:len(p.available)-1] | |||
| 267 | + | atomic.AddInt64(&p.acquired, 1) | |||
| 268 | + | logging.Debug(fmt.Sprintf("[pool] TryAcquire: got WebView ID %d (remaining=%d)", | |||
| 269 | + | wv.ID(), len(p.available))) | |||
| 270 | + | return wv | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | atomic.AddInt64(&p.misses, 1) | |||
| 274 | + | return nil | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | // Stats returns current pool statistics | |||
| 278 | + | func (p *WebViewPool) Stats() PoolStats { | |||
| 279 | + | p.mu.RLock() | |||
| 280 | + | available := len(p.available) | |||
| 281 | + | p.mu.RUnlock() | |||
| 282 | + | ||||
| 283 | + | acquired := atomic.LoadInt64(&p.acquired) | |||
| 284 | + | misses := atomic.LoadInt64(&p.misses) | |||
| 285 | + | ||||
| 286 | + | var hitRate float64 | |||
| 287 | + | total := acquired + misses | |||
| 288 | + | if total > 0 { | |||
| 289 | + | hitRate = float64(acquired) / float64(total) | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | return PoolStats{ | |||
| 293 | + | Available: available, | |||
| 294 | + | TargetSize: p.targetSize, | |||
| 295 | + | MaxSize: p.maxSize, | |||
| 296 | + | Created: atomic.LoadInt64(&p.created), | |||
| 297 | + | Acquired: acquired, | |||
| 298 | + | Misses: misses, | |||
| 299 | + | HitRate: hitRate, | |||
| 300 | + | State: PoolState(atomic.LoadInt32(&p.state)), | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | // Stop shuts down the pool and destroys all pooled WebViews. | |||
| 305 | + | // Safe to call multiple times. | |||
| 306 | + | func (p *WebViewPool) Stop() { | |||
| 307 | + | p.stopOnce.Do(func() { | |||
| 308 | + | // Transition to stopped state from any running state | |||
| 309 | + | for { | |||
| 310 | + | oldState := atomic.LoadInt32(&p.state) | |||
| 311 | + | if oldState == int32(PoolStateStopped) || oldState == int32(PoolStateIdle) { | |||
| 312 | + | return | |||
| 313 | + | } | |||
| 314 | + | if atomic.CompareAndSwapInt32(&p.state, oldState, int32(PoolStateStopped)) { | |||
| 315 | + | break | |||
| 316 | + | } | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | close(p.stopCh) | |||
| 320 | + | p.wg.Wait() | |||
| 321 | + | ||||
| 322 | + | // Destroy all pooled WebViews | |||
| 323 | + | p.mu.Lock() | |||
| 324 | + | views := p.available | |||
| 325 | + | p.available = nil | |||
| 326 | + | p.mu.Unlock() | |||
| 327 | + | ||||
| 328 | + | if len(views) > 0 { | |||
| 329 | + | logging.Info(fmt.Sprintf("[pool] Destroying %d pooled WebViews", len(views))) | |||
| 330 | + | for _, wv := range views { | |||
| 331 | + | if wv != nil && !wv.IsDestroyed() { | |||
| 332 | + | wv.Destroy() | |||
| 333 | + | } | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | stats := p.Stats() | |||
| 338 | + | logging.Info(fmt.Sprintf("[pool] Pool stopped (created=%d, acquired=%d, misses=%d, hitRate=%.2f%%)", | |||
| 339 | + | stats.Created, stats.Acquired, stats.Misses, stats.HitRate*100)) | |||
| 340 | + | }) | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | // IsReady returns true if the pool has completed initial warmup | |||
| 344 | + | func (p *WebViewPool) IsReady() bool { | |||
| 345 | + | return PoolState(atomic.LoadInt32(&p.state)) == PoolStateReady | |||
| 346 | + | } | |||
M pkg/webkit/mainloop.go +7 -10
| @@ -44,21 +44,18 @@ func QuitMainLoop() { | |||
| 44 | 44 | } | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | - | // IsMainThread returns true if called from the GTK main thread. | |
| 47 | + | // IsMainThread returns true if GTK has been initialized. | |
| 48 | + | // Note: This doesn't truly detect the main thread, but is used as a guard | |
| 49 | + | // for whether GTK operations are safe. All GTK operations should use | |
| 50 | + | // RunOnMainThread or IdleAdd to be thread-safe. | |
| 48 | 51 | func IsMainThread() bool { | |
| 49 | - | // In gotk4, we're always on the main thread since we lock it | |
| 50 | 52 | return isInitialized | |
| 51 | 53 | } | |
| 52 | 54 | ||
| 53 | - | // RunOnMainThread executes a function on the GTK main thread. | |
| 54 | - | // If already on the main thread, executes immediately. | |
| 55 | - | // Otherwise, schedules the function via glib.IdleAdd. | |
| 55 | + | // RunOnMainThread schedules a function to run on the GTK main thread. | |
| 56 | + | // Always uses glib.IdleAdd to ensure thread safety. | |
| 57 | + | // The function will execute during the next main loop iteration. | |
| 56 | 58 | func RunOnMainThread(fn func()) { | |
| 57 | - | if IsMainThread() { | |
| 58 | - | fn() | |
| 59 | - | return | |
| 60 | - | } | |
| 61 | - | ||
| 62 | 59 | glib.IdleAdd(func() bool { | |
| 63 | 60 | fn() | |
| 64 | 61 | return false // Remove the idle handler after execution | |
| @@ -44,21 +44,18 @@ func QuitMainLoop() { | |||||
| 44 | } | 44 | } | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | - | // IsMainThread returns true if called from the GTK main thread. | 47 | + | // IsMainThread returns true if GTK has been initialized. |
| 48 | + | // Note: This doesn't truly detect the main thread, but is used as a guard | |||
| 49 | + | // for whether GTK operations are safe. All GTK operations should use | |||
| 50 | + | // RunOnMainThread or IdleAdd to be thread-safe. | |||
| 48 | func IsMainThread() bool { | 51 | func IsMainThread() bool { | ||
| 49 | - | // In gotk4, we're always on the main thread since we lock it | |||
| 50 | return isInitialized | 52 | return isInitialized | ||
| 51 | } | 53 | } | ||
| 52 | 54 | ||||
| 53 | - | // RunOnMainThread executes a function on the GTK main thread. | 55 | + | // RunOnMainThread schedules a function to run on the GTK main thread. |
| 54 | - | // If already on the main thread, executes immediately. | 56 | + | // Always uses glib.IdleAdd to ensure thread safety. |
| 55 | - | // Otherwise, schedules the function via glib.IdleAdd. | 57 | + | // The function will execute during the next main loop iteration. |
| 56 | func RunOnMainThread(fn func()) { | 58 | func RunOnMainThread(fn func()) { | ||
| 57 | - | if IsMainThread() { | |||
| 58 | - | fn() | |||
| 59 | - | return | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | glib.IdleAdd(func() bool { | 59 | glib.IdleAdd(func() bool { | ||
| 63 | fn() | 60 | fn() | ||
| 64 | return false // Remove the idle handler after execution | 61 | return false // Remove the idle handler after execution | ||