Merge pull request #40 from bnema/feat/defer-webview-prewarm
perf(startup): defer webview pool prewarm
3 files · +105 -8
M CHANGELOG.md +1 -0
| @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### Fixed | |
| 8 | + | - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency. | |
| 8 | 9 | - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag). | |
| 9 | 10 | ||
| 10 | 11 | ## [0.21.0] - 2025-12-26 | |
| @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. | |||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | ### Fixed | 7 | ### Fixed | ||
| 8 | + | - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency. | |||
| 8 | - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag). | 9 | - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag). | ||
| 9 | 10 | ||||
| 10 | ## [0.21.0] - 2025-12-26 | 11 | ## [0.21.0] - 2025-12-26 | ||
M internal/infrastructure/webkit/pool.go +80 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 10 | "github.com/bnema/puregotk-webkit/webkit" | |
| 11 | + | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | // FilterApplier applies content filters to a UserContentManager. | |
| @@ -207,6 +208,85 @@ func (p *WebViewPool) Prewarm(ctx context.Context, count int) { | |||
| 207 | 208 | p.prewarmSync(ctx, count) | |
| 208 | 209 | } | |
| 209 | 210 | ||
| 211 | + | // PrewarmAsync schedules WebView creation on the GTK idle loop. | |
| 212 | + | // This avoids blocking startup (especially cold-start navigation) while still | |
| 213 | + | // warming up WebViews for subsequent tab creation. | |
| 214 | + | func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) { | |
| 215 | + | log := logging.FromContext(ctx) | |
| 216 | + | ||
| 217 | + | if count <= 0 { | |
| 218 | + | count = p.config.PrewarmCount | |
| 219 | + | } | |
| 220 | + | if count <= 0 { | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | if p.closed.Load() { | |
| 224 | + | return | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | log.Debug().Int("count", count).Int("pool_size", len(p.pool)).Msg("scheduling async webview pool prewarm") | |
| 228 | + | ||
| 229 | + | p.wg.Add(1) | |
| 230 | + | var doneOnce sync.Once | |
| 231 | + | remaining := count | |
| 232 | + | ||
| 233 | + | var schedule func() | |
| 234 | + | schedule = func() { | |
| 235 | + | // Stop early if the caller canceled the operation. | |
| 236 | + | if err := ctx.Err(); err != nil { | |
| 237 | + | log.Debug().Err(err).Msg("async webview pool prewarm canceled") | |
| 238 | + | doneOnce.Do(p.wg.Done) | |
| 239 | + | return | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 243 | + | // Stop if the caller canceled the operation. | |
| 244 | + | if err := ctx.Err(); err != nil { | |
| 245 | + | log.Debug().Err(err).Msg("async webview pool prewarm canceled") | |
| 246 | + | doneOnce.Do(p.wg.Done) | |
| 247 | + | return false | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // Stop if pool is closing/closed. | |
| 251 | + | if p.closed.Load() { | |
| 252 | + | doneOnce.Do(p.wg.Done) | |
| 253 | + | return false | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // Stop early if we reached capacity. | |
| 257 | + | if len(p.pool) >= p.config.MaxSize { | |
| 258 | + | doneOnce.Do(p.wg.Done) | |
| 259 | + | return false | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | wv, err := p.createWebView(ctx) | |
| 263 | + | if err != nil { | |
| 264 | + | log.Warn().Err(err).Msg("failed to prewarm webview") | |
| 265 | + | } else { | |
| 266 | + | select { | |
| 267 | + | case p.pool <- wv: | |
| 268 | + | log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool") | |
| 269 | + | default: | |
| 270 | + | wv.Destroy() | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | remaining-- | |
| 275 | + | if remaining <= 0 { | |
| 276 | + | log.Debug().Int("pool_size", len(p.pool)).Msg("async webview pool prewarm complete") | |
| 277 | + | doneOnce.Do(p.wg.Done) | |
| 278 | + | return false | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | schedule() | |
| 282 | + | return false | |
| 283 | + | }) | |
| 284 | + | glib.IdleAdd(&cb, 0) | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | schedule() | |
| 288 | + | } | |
| 289 | + | ||
| 210 | 290 | // prewarmSync creates WebViews synchronously. | |
| 211 | 291 | func (p *WebViewPool) prewarmSync(ctx context.Context, count int) { | |
| 212 | 292 | log := logging.FromContext(ctx) | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | "github.com/bnema/dumber/internal/logging" | 9 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | "github.com/bnema/puregotk-webkit/webkit" | 10 | "github.com/bnema/puregotk-webkit/webkit" | ||
| 11 | + | "github.com/jwijenbergh/puregotk/v4/glib" | |||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 13 | // FilterApplier applies content filters to a UserContentManager. | 14 | // FilterApplier applies content filters to a UserContentManager. | ||
| @@ -207,6 +208,85 @@ func (p *WebViewPool) Prewarm(ctx context.Context, count int) { | |||||
| 207 | p.prewarmSync(ctx, count) | 208 | p.prewarmSync(ctx, count) | ||
| 208 | } | 209 | } | ||
| 209 | 210 | ||||
| 211 | + | // PrewarmAsync schedules WebView creation on the GTK idle loop. | |||
| 212 | + | // This avoids blocking startup (especially cold-start navigation) while still | |||
| 213 | + | // warming up WebViews for subsequent tab creation. | |||
| 214 | + | func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) { | |||
| 215 | + | log := logging.FromContext(ctx) | |||
| 216 | + | ||||
| 217 | + | if count <= 0 { | |||
| 218 | + | count = p.config.PrewarmCount | |||
| 219 | + | } | |||
| 220 | + | if count <= 0 { | |||
| 221 | + | return | |||
| 222 | + | } | |||
| 223 | + | if p.closed.Load() { | |||
| 224 | + | return | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | log.Debug().Int("count", count).Int("pool_size", len(p.pool)).Msg("scheduling async webview pool prewarm") | |||
| 228 | + | ||||
| 229 | + | p.wg.Add(1) | |||
| 230 | + | var doneOnce sync.Once | |||
| 231 | + | remaining := count | |||
| 232 | + | ||||
| 233 | + | var schedule func() | |||
| 234 | + | schedule = func() { | |||
| 235 | + | // Stop early if the caller canceled the operation. | |||
| 236 | + | if err := ctx.Err(); err != nil { | |||
| 237 | + | log.Debug().Err(err).Msg("async webview pool prewarm canceled") | |||
| 238 | + | doneOnce.Do(p.wg.Done) | |||
| 239 | + | return | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 243 | + | // Stop if the caller canceled the operation. | |||
| 244 | + | if err := ctx.Err(); err != nil { | |||
| 245 | + | log.Debug().Err(err).Msg("async webview pool prewarm canceled") | |||
| 246 | + | doneOnce.Do(p.wg.Done) | |||
| 247 | + | return false | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | // Stop if pool is closing/closed. | |||
| 251 | + | if p.closed.Load() { | |||
| 252 | + | doneOnce.Do(p.wg.Done) | |||
| 253 | + | return false | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // Stop early if we reached capacity. | |||
| 257 | + | if len(p.pool) >= p.config.MaxSize { | |||
| 258 | + | doneOnce.Do(p.wg.Done) | |||
| 259 | + | return false | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | wv, err := p.createWebView(ctx) | |||
| 263 | + | if err != nil { | |||
| 264 | + | log.Warn().Err(err).Msg("failed to prewarm webview") | |||
| 265 | + | } else { | |||
| 266 | + | select { | |||
| 267 | + | case p.pool <- wv: | |||
| 268 | + | log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool") | |||
| 269 | + | default: | |||
| 270 | + | wv.Destroy() | |||
| 271 | + | } | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | remaining-- | |||
| 275 | + | if remaining <= 0 { | |||
| 276 | + | log.Debug().Int("pool_size", len(p.pool)).Msg("async webview pool prewarm complete") | |||
| 277 | + | doneOnce.Do(p.wg.Done) | |||
| 278 | + | return false | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | schedule() | |||
| 282 | + | return false | |||
| 283 | + | }) | |||
| 284 | + | glib.IdleAdd(&cb, 0) | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | schedule() | |||
| 288 | + | } | |||
| 289 | + | ||||
| 210 | // prewarmSync creates WebViews synchronously. | 290 | // prewarmSync creates WebViews synchronously. | ||
| 211 | func (p *WebViewPool) prewarmSync(ctx context.Context, count int) { | 291 | func (p *WebViewPool) prewarmSync(ctx context.Context, count int) { | ||
| 212 | log := logging.FromContext(ctx) | 292 | log := logging.FromContext(ctx) | ||
M internal/ui/app.go +24 -8
| @@ -179,7 +179,9 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 179 | 179 | log.Debug().Msg("GTK application activated") | |
| 180 | 180 | ||
| 181 | 181 | a.applyGTKColorSchemePreference(ctx) | |
| 182 | - | a.prewarmWebViewPool(ctx) | |
| 182 | + | // Configure pool background color early (prevents white flash), but avoid | |
| 183 | + | // synchronous prewarming that delays the first navigation on cold start. | |
| 184 | + | a.setupPoolBackgroundColor(ctx) | |
| 183 | 185 | ||
| 184 | 186 | if err := a.createMainWindow(ctx); err != nil { | |
| 185 | 187 | log.Error().Err(err).Msg("failed to create main window") | |
| @@ -198,6 +200,8 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 198 | 200 | a.initSnapshotService(ctx) | |
| 199 | 201 | a.initUpdateCoordinator(ctx) | |
| 200 | 202 | a.createInitialTab(ctx) | |
| 203 | + | // Prewarm the remaining WebViews after the initial tab starts loading. | |
| 204 | + | a.prewarmWebViewPoolAsync(ctx) | |
| 201 | 205 | a.finalizeActivation(ctx) | |
| 202 | 206 | } | |
| 203 | 207 | ||
| @@ -223,19 +227,31 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) { | |||
| 223 | 227 | Msg("set gtk-application-prefer-dark-theme") | |
| 224 | 228 | } | |
| 225 | 229 | ||
| 226 | - | func (a *App) prewarmWebViewPool(ctx context.Context) { | |
| 230 | + | func (a *App) setupPoolBackgroundColor(ctx context.Context) { | |
| 231 | + | log := logging.FromContext(ctx) | |
| 232 | + | ||
| 227 | 233 | // Set theme background color on pool to eliminate white flash. | |
| 228 | - | // Must be done before prewarming so WebViews get the correct color. | |
| 229 | - | if a.pool != nil && a.deps != nil && a.deps.Theme != nil { | |
| 230 | - | r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA() | |
| 231 | - | a.pool.SetBackgroundColor(r, g, b, alpha) | |
| 234 | + | // Must be done before any WebView creation so WebViews get the correct color. | |
| 235 | + | if a.pool == nil { | |
| 236 | + | log.Debug().Msg("webview pool not available; skipping background color setup") | |
| 237 | + | return | |
| 232 | 238 | } | |
| 239 | + | if a.deps == nil || a.deps.Theme == nil { | |
| 240 | + | log.Debug().Msg("theme not available; skipping webview pool background color setup") | |
| 241 | + | return | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA() | |
| 245 | + | a.pool.SetBackgroundColor(r, g, b, alpha) | |
| 246 | + | log.Debug().Msg("configured webview pool background color") | |
| 247 | + | } | |
| 233 | 248 | ||
| 234 | - | // Prewarm WebView pool now that GTK is initialized. | |
| 249 | + | func (a *App) prewarmWebViewPoolAsync(ctx context.Context) { | |
| 250 | + | // Prewarm WebView pool after startup so cold-start navigation is not blocked. | |
| 235 | 251 | if a.pool == nil { | |
| 236 | 252 | return | |
| 237 | 253 | } | |
| 238 | - | a.pool.Prewarm(ctx, 0) | |
| 254 | + | a.pool.PrewarmAsync(ctx, 0) | |
| 239 | 255 | } | |
| 240 | 256 | ||
| 241 | 257 | func (a *App) createMainWindow(ctx context.Context) error { | |
| @@ -179,7 +179,9 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 179 | log.Debug().Msg("GTK application activated") | 179 | log.Debug().Msg("GTK application activated") | ||
| 180 | 180 | ||||
| 181 | a.applyGTKColorSchemePreference(ctx) | 181 | a.applyGTKColorSchemePreference(ctx) | ||
| 182 | - | a.prewarmWebViewPool(ctx) | 182 | + | // Configure pool background color early (prevents white flash), but avoid |
| 183 | + | // synchronous prewarming that delays the first navigation on cold start. | |||
| 184 | + | a.setupPoolBackgroundColor(ctx) | |||
| 183 | 185 | ||||
| 184 | if err := a.createMainWindow(ctx); err != nil { | 186 | if err := a.createMainWindow(ctx); err != nil { | ||
| 185 | log.Error().Err(err).Msg("failed to create main window") | 187 | log.Error().Err(err).Msg("failed to create main window") | ||
| @@ -198,6 +200,8 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 198 | a.initSnapshotService(ctx) | 200 | a.initSnapshotService(ctx) | ||
| 199 | a.initUpdateCoordinator(ctx) | 201 | a.initUpdateCoordinator(ctx) | ||
| 200 | a.createInitialTab(ctx) | 202 | a.createInitialTab(ctx) | ||
| 203 | + | // Prewarm the remaining WebViews after the initial tab starts loading. | |||
| 204 | + | a.prewarmWebViewPoolAsync(ctx) | |||
| 201 | a.finalizeActivation(ctx) | 205 | a.finalizeActivation(ctx) | ||
| 202 | } | 206 | } | ||
| 203 | 207 | ||||
| @@ -223,19 +227,31 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) { | |||||
| 223 | Msg("set gtk-application-prefer-dark-theme") | 227 | Msg("set gtk-application-prefer-dark-theme") | ||
| 224 | } | 228 | } | ||
| 225 | 229 | ||||
| 226 | - | func (a *App) prewarmWebViewPool(ctx context.Context) { | 230 | + | func (a *App) setupPoolBackgroundColor(ctx context.Context) { |
| 231 | + | log := logging.FromContext(ctx) | |||
| 232 | + | ||||
| 227 | // Set theme background color on pool to eliminate white flash. | 233 | // Set theme background color on pool to eliminate white flash. | ||
| 228 | - | // Must be done before prewarming so WebViews get the correct color. | 234 | + | // Must be done before any WebView creation so WebViews get the correct color. |
| 229 | - | if a.pool != nil && a.deps != nil && a.deps.Theme != nil { | 235 | + | if a.pool == nil { |
| 230 | - | r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA() | 236 | + | log.Debug().Msg("webview pool not available; skipping background color setup") |
| 231 | - | a.pool.SetBackgroundColor(r, g, b, alpha) | 237 | + | return |
| 232 | } | 238 | } | ||
| 239 | + | if a.deps == nil || a.deps.Theme == nil { | |||
| 240 | + | log.Debug().Msg("theme not available; skipping webview pool background color setup") | |||
| 241 | + | return | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA() | |||
| 245 | + | a.pool.SetBackgroundColor(r, g, b, alpha) | |||
| 246 | + | log.Debug().Msg("configured webview pool background color") | |||
| 247 | + | } | |||
| 233 | 248 | ||||
| 234 | - | // Prewarm WebView pool now that GTK is initialized. | 249 | + | func (a *App) prewarmWebViewPoolAsync(ctx context.Context) { |
| 250 | + | // Prewarm WebView pool after startup so cold-start navigation is not blocked. | |||
| 235 | if a.pool == nil { | 251 | if a.pool == nil { | ||
| 236 | return | 252 | return | ||
| 237 | } | 253 | } | ||
| 238 | - | a.pool.Prewarm(ctx, 0) | 254 | + | a.pool.PrewarmAsync(ctx, 0) |
| 239 | } | 255 | } | ||
| 240 | 256 | ||||
| 241 | func (a *App) createMainWindow(ctx context.Context) error { | 257 | func (a *App) createMainWindow(ctx context.Context) error { | ||