perf(startup): defer webview pool prewarm
Avoid blocking cold-start navigation by moving WebView pool warming to GTK idle after the initial tab begins loading.
- commit
- 0c81a2242ade45b0ff89e51bd7c2f265f415a397
- author
- brice · · 2025-12-27 07:47 UTC
- parent
- 00c4c81
- signature
- unsigned
3 files · +72 -5
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 +60 -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,65 @@ 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 | + | if count <= 0 { | |
| 216 | + | count = p.config.PrewarmCount | |
| 217 | + | } | |
| 218 | + | if count <= 0 || p.closed.Load() { | |
| 219 | + | return | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | p.wg.Add(1) | |
| 223 | + | var doneOnce sync.Once | |
| 224 | + | remaining := count | |
| 225 | + | ||
| 226 | + | var schedule func() | |
| 227 | + | schedule = func() { | |
| 228 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 229 | + | log := logging.FromContext(ctx) | |
| 230 | + | ||
| 231 | + | // Stop if pool is closing/closed. | |
| 232 | + | if p.closed.Load() { | |
| 233 | + | doneOnce.Do(p.wg.Done) | |
| 234 | + | return false | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // Stop early if we reached capacity. | |
| 238 | + | if len(p.pool) >= p.config.MaxSize { | |
| 239 | + | doneOnce.Do(p.wg.Done) | |
| 240 | + | return false | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | wv, err := p.createWebView(ctx) | |
| 244 | + | if err != nil { | |
| 245 | + | log.Warn().Err(err).Msg("failed to prewarm webview") | |
| 246 | + | } else { | |
| 247 | + | select { | |
| 248 | + | case p.pool <- wv: | |
| 249 | + | log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool") | |
| 250 | + | default: | |
| 251 | + | wv.Destroy() | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | remaining-- | |
| 256 | + | if remaining <= 0 { | |
| 257 | + | doneOnce.Do(p.wg.Done) | |
| 258 | + | return false | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | schedule() | |
| 262 | + | return false | |
| 263 | + | }) | |
| 264 | + | glib.IdleAdd(&cb, 0) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | schedule() | |
| 268 | + | } | |
| 269 | + | ||
| 210 | 270 | // prewarmSync creates WebViews synchronously. | |
| 211 | 271 | func (p *WebViewPool) prewarmSync(ctx context.Context, count int) { | |
| 212 | 272 | 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,65 @@ 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 | + | if count <= 0 { | |||
| 216 | + | count = p.config.PrewarmCount | |||
| 217 | + | } | |||
| 218 | + | if count <= 0 || p.closed.Load() { | |||
| 219 | + | return | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | p.wg.Add(1) | |||
| 223 | + | var doneOnce sync.Once | |||
| 224 | + | remaining := count | |||
| 225 | + | ||||
| 226 | + | var schedule func() | |||
| 227 | + | schedule = func() { | |||
| 228 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 229 | + | log := logging.FromContext(ctx) | |||
| 230 | + | ||||
| 231 | + | // Stop if pool is closing/closed. | |||
| 232 | + | if p.closed.Load() { | |||
| 233 | + | doneOnce.Do(p.wg.Done) | |||
| 234 | + | return false | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // Stop early if we reached capacity. | |||
| 238 | + | if len(p.pool) >= p.config.MaxSize { | |||
| 239 | + | doneOnce.Do(p.wg.Done) | |||
| 240 | + | return false | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | wv, err := p.createWebView(ctx) | |||
| 244 | + | if err != nil { | |||
| 245 | + | log.Warn().Err(err).Msg("failed to prewarm webview") | |||
| 246 | + | } else { | |||
| 247 | + | select { | |||
| 248 | + | case p.pool <- wv: | |||
| 249 | + | log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool") | |||
| 250 | + | default: | |||
| 251 | + | wv.Destroy() | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | remaining-- | |||
| 256 | + | if remaining <= 0 { | |||
| 257 | + | doneOnce.Do(p.wg.Done) | |||
| 258 | + | return false | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | schedule() | |||
| 262 | + | return false | |||
| 263 | + | }) | |||
| 264 | + | glib.IdleAdd(&cb, 0) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | schedule() | |||
| 268 | + | } | |||
| 269 | + | ||||
| 210 | // prewarmSync creates WebViews synchronously. | 270 | // prewarmSync creates WebViews synchronously. | ||
| 211 | func (p *WebViewPool) prewarmSync(ctx context.Context, count int) { | 271 | func (p *WebViewPool) prewarmSync(ctx context.Context, count int) { | ||
| 212 | log := logging.FromContext(ctx) | 272 | log := logging.FromContext(ctx) | ||
M internal/ui/app.go +11 -5
| @@ -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,21 @@ 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) { | |
| 227 | 231 | // Set theme background color on pool to eliminate white flash. | |
| 228 | - | // Must be done before prewarming so WebViews get the correct color. | |
| 232 | + | // Must be done before any WebView creation so WebViews get the correct color. | |
| 229 | 233 | if a.pool != nil && a.deps != nil && a.deps.Theme != nil { | |
| 230 | 234 | r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA() | |
| 231 | 235 | a.pool.SetBackgroundColor(r, g, b, alpha) | |
| 232 | 236 | } | |
| 237 | + | } | |
| 233 | 238 | ||
| 234 | - | // Prewarm WebView pool now that GTK is initialized. | |
| 239 | + | func (a *App) prewarmWebViewPoolAsync(ctx context.Context) { | |
| 240 | + | // Prewarm WebView pool after startup so cold-start navigation is not blocked. | |
| 235 | 241 | if a.pool == nil { | |
| 236 | 242 | return | |
| 237 | 243 | } | |
| 238 | - | a.pool.Prewarm(ctx, 0) | |
| 244 | + | a.pool.PrewarmAsync(ctx, 0) | |
| 239 | 245 | } | |
| 240 | 246 | ||
| 241 | 247 | 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,21 @@ 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) { |
| 227 | // Set theme background color on pool to eliminate white flash. | 231 | // Set theme background color on pool to eliminate white flash. | ||
| 228 | - | // Must be done before prewarming so WebViews get the correct color. | 232 | + | // 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 { | 233 | if a.pool != nil && a.deps != nil && a.deps.Theme != nil { | ||
| 230 | r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA() | 234 | r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA() | ||
| 231 | a.pool.SetBackgroundColor(r, g, b, alpha) | 235 | a.pool.SetBackgroundColor(r, g, b, alpha) | ||
| 232 | } | 236 | } | ||
| 237 | + | } | |||
| 233 | 238 | ||||
| 234 | - | // Prewarm WebView pool now that GTK is initialized. | 239 | + | func (a *App) prewarmWebViewPoolAsync(ctx context.Context) { |
| 240 | + | // Prewarm WebView pool after startup so cold-start navigation is not blocked. | |||
| 235 | if a.pool == nil { | 241 | if a.pool == nil { | ||
| 236 | return | 242 | return | ||
| 237 | } | 243 | } | ||
| 238 | - | a.pool.Prewarm(ctx, 0) | 244 | + | a.pool.PrewarmAsync(ctx, 0) |
| 239 | } | 245 | } | ||
| 240 | 246 | ||||
| 241 | func (a *App) createMainWindow(ctx context.Context) error { | 247 | func (a *App) createMainWindow(ctx context.Context) error { | ||