v0.32.0 0617278

pool.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"errors"
6	"os"
7	"strings"
8	"sync"
9	"sync/atomic"
10	"time"
11
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/bnema/puregotk/v4/glib"
14	"github.com/bnema/puregotk/v4/webkit"
15)
16
17// ErrPoolClosed is returned when operations are attempted on a closed pool.
18var ErrPoolClosed = errors.New("webview pool is closed")
19
20// FilterApplier applies content filters to a UserContentManager.
21// This interface decouples the pool from the filtering package.
22type FilterApplier interface {
23	ApplyTo(ctx context.Context, ucm *webkit.UserContentManager)
24}
25
26// PoolConfig configures the WebView pool behavior.
27type PoolConfig struct {
28	// MinSize is the minimum number of warm WebViews to maintain.
29	MinSize int
30	// MaxSize is the maximum number of WebViews to keep in the pool.
31	MaxSize int
32	// PrewarmCount is the number of WebViews to pre-create on startup.
33	PrewarmCount int
34	// IdleTimeout is how long idle views stay in the pool before being destroyed.
35	IdleTimeout time.Duration
36	// ReusePolicy controls if/when released WebViews are returned to pool.
37	// Values: off, safe, aggressive.
38	ReusePolicy string
39	// TerminatePolicy controls whether Destroy() should terminate web process.
40	// Values: auto, always, never.
41	TerminatePolicy string
42}
43
44// DefaultPoolConfig returns sensible defaults for the pool.
45func DefaultPoolConfig() PoolConfig {
46	return PoolConfig{
47		MinSize:      2,
48		MaxSize:      8,
49		PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation
50		IdleTimeout:  5 * time.Minute,
51	}
52}
53
54const (
55	reusePolicyOff        = "off"
56	reusePolicySafe       = "safe"
57	reusePolicyAggressive = "aggressive"
58)
59
60// WebViewPool manages a pool of pre-created WebViews for fast tab creation.
61type WebViewPool struct {
62	wkCtx    *WebKitContext
63	settings *SettingsManager
64	config   PoolConfig
65
66	pool          chan *WebView
67	poolMu        sync.Mutex
68	ctxMenuMu     sync.RWMutex
69	injector      *ContentInjector
70	router        *MessageRouter
71	filterApplier FilterApplier        // Optional content filter applier
72	ctxMenu       *contextMenuPipeline // Optional context menu pipeline
73
74	// Background color for WebViews (eliminates white flash)
75	bg bgColor
76
77	closed atomic.Bool
78	wg     sync.WaitGroup
79
80	reusePolicy     string
81	terminatePolicy string
82
83	acquireCount      atomic.Uint64
84	acquirePoolHits   atomic.Uint64
85	acquirePoolMisses atomic.Uint64
86	releaseCount      atomic.Uint64
87	releaseReused     atomic.Uint64
88	releaseDestroyed  atomic.Uint64
89	releaseDropped    atomic.Uint64
90	releaseTotalNanos atomic.Int64
91	acquireTotalNanos atomic.Int64
92}
93
94// NewWebViewPool creates a new WebView pool.
95func NewWebViewPool(
96	ctx context.Context,
97	wkCtx *WebKitContext,
98	settings *SettingsManager,
99	cfg PoolConfig,
100	injector *ContentInjector,
101	router *MessageRouter,
102) *WebViewPool {
103	if cfg.MaxSize <= 0 {
104		cfg.MaxSize = 8
105	}
106	if cfg.MinSize <= 0 {
107		cfg.MinSize = 2
108	}
109	if cfg.MinSize > cfg.MaxSize {
110		cfg.MinSize = cfg.MaxSize
111	}
112	cfg.ReusePolicy = resolveReusePolicy(cfg.ReusePolicy)
113	cfg.TerminatePolicy = resolveTerminatePolicy(cfg.TerminatePolicy)
114	if ctx == nil {
115		ctx = context.Background()
116	}
117
118	p := &WebViewPool{
119		wkCtx:           wkCtx,
120		settings:        settings,
121		config:          cfg,
122		pool:            make(chan *WebView, cfg.MaxSize),
123		injector:        injector,
124		router:          router,
125		reusePolicy:     cfg.ReusePolicy,
126		terminatePolicy: cfg.TerminatePolicy,
127	}
128
129	log := logging.FromContext(ctx)
130	log.Debug().
131		Str("reuse_policy", p.reusePolicy).
132		Str("terminate_policy", p.terminatePolicy).
133		Msg("creating webview pool")
134
135	return p
136}
137
138// SetFilterApplier sets the content filter applier.
139// Filters will be applied to all newly created WebViews.
140func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
141	p.filterApplier = applier
142}
143
144// SetBackgroundColor sets the background color for newly created WebViews.
145// This color is shown before content is painted, eliminating white flash.
146func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
147	p.bg.set(r, g, b, a)
148}
149
150// SetContextMenuPipeline sets the context menu pipeline for newly created WebViews.
151func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) {
152	p.ctxMenuMu.Lock()
153	p.ctxMenu = pipeline
154	p.ctxMenuMu.Unlock()
155}
156
157// Acquire gets a WebView from the pool or creates a new one.
158func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
159	log := logging.FromContext(ctx)
160	start := time.Now()
161
162	if p.closed.Load() {
163		return nil, ErrPoolClosed
164	}
165
166	// Try to get from pool first (non-blocking)
167	select {
168	case wv := <-p.pool:
169		if wv != nil && !wv.IsDestroyed() {
170			if r, g, b, a := p.bg.get(); a > 0 {
171				wv.SetBackgroundColor(float64(r), float64(g), float64(b), float64(a))
172			}
173			wv.inner.AddCssClass("webview-themed")
174			// Keep pooled WebViews hidden until we explicitly reveal them.
175			wv.inner.SetVisible(false)
176			if shouldReapplySettingsOnAcquire(p.settings, wv) {
177				p.settings.ApplyToWebView(ctx, wv.inner)
178			}
179
180			// Ensure frontend is attached even if this WebView was pooled before injection was configured.
181			alreadyAttached := wv.FrontendAttached()
182			if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {
183				log.Warn().Err(err).Uint64("id", uint64(wv.ID())).Msg("failed to attach frontend to pooled webview")
184			}
185			// Reinjection is only for already-attached reuse after ResetForPoolReuse.
186			// First AttachFrontend already injected; avoid an immediate double inject.
187			if needsFrontendReinjectionOnAcquire(alreadyAttached) && p.injector != nil && wv.ucm != nil {
188				wv.ucm.RemoveAllScripts()
189				wv.ucm.RemoveAllStyleSheets()
190				p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
191			}
192			// Apply filters if available (may not have been applied during prewarm)
193			if p.filterApplier != nil {
194				p.filterApplier.ApplyTo(ctx, wv.ucm)
195			}
196			// Ensure context menu pipeline is set (may not have been available during prewarm)
197			p.ctxMenuMu.RLock()
198			ctxMenu := p.ctxMenu
199			p.ctxMenuMu.RUnlock()
200			if ctxMenu != nil {
201				wv.contextMenu = ctxMenu
202				if needsExplicitContextMenuSignalOnAcquire(wv, ctxMenu) {
203					wv.connectContextMenuSignal(ctxMenu)
204				}
205			}
206			wv.ReconnectSignals()
207			p.acquireCount.Add(1)
208			p.acquirePoolHits.Add(1)
209			p.acquireTotalNanos.Add(time.Since(start).Nanoseconds())
210			log.Debug().
211				Uint64("id", uint64(wv.ID())).
212				Dur("latency", time.Since(start)).
213				Int("pool_size", len(p.pool)).
214				Str("source", "pool").
215				Msg("acquired webview")
216			return wv, nil
217		}
218		// Fall through to create new if the pooled one was destroyed
219	default:
220		// Pool empty
221	}
222
223	// Pool empty or pooled view was destroyed, create new
224	log.Debug().Msg("pool empty, creating new webview")
225	wv, err := p.createWebView(ctx)
226	if err != nil {
227		return nil, err
228	}
229	p.acquireCount.Add(1)
230	p.acquirePoolMisses.Add(1)
231	p.acquireTotalNanos.Add(time.Since(start).Nanoseconds())
232
233	// Re-check after creation: if pool closed during createWebView, destroy
234	// the new WebView and return an error to avoid leaking resources.
235	if p.closed.Load() {
236		wv.DestroyWithPolicy(p.terminatePolicy)
237		return nil, ErrPoolClosed
238	}
239
240	log.Debug().
241		Uint64("id", uint64(wv.ID())).
242		Dur("latency", time.Since(start)).
243		Int("pool_size", len(p.pool)).
244		Str("source", "new").
245		Msg("acquired webview")
246	return wv, nil
247}
248
249func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuPipeline) bool {
250	return wv != nil && pipeline != nil && len(wv.signalIDs) > 0
251}
252
253// needsFrontendReinjectionOnAcquire is true when AttachFrontend already succeeded
254// earlier, so pool acquire must wipe and reinject after reuse. First-time attach
255// injects inside AttachFrontend and must not immediately reinject.
256func needsFrontendReinjectionOnAcquire(alreadyAttached bool) bool {
257	return alreadyAttached
258}
259
260func shouldReapplySettingsOnAcquire(settings *SettingsManager, wv *WebView) bool {
261	return settings != nil && wv != nil && wv.inner != nil
262}
263
264func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
265	log := logging.FromContext(ctx)
266
267	wv, err := NewWebView(ctx, p.wkCtx, p.settings, p.bg.toGdkRGBA())
268	if err != nil {
269		return nil, err
270	}
271
272	// Add CSS class for theme background styling (prevents white flash)
273	wv.inner.AddCssClass("webview-themed")
274
275	// Keep WebView hidden until content is painted (see content.go:onLoadCommitted)
276	wv.inner.SetVisible(false)
277
278	if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {
279		log.Warn().Err(err).Msg("failed to attach frontend to new webview")
280	}
281
282	// Apply content filters if configured
283	if p.filterApplier != nil {
284		p.filterApplier.ApplyTo(ctx, wv.ucm)
285	}
286
287	// Wire context menu if configured
288	p.ctxMenuMu.RLock()
289	ctxMenu := p.ctxMenu
290	p.ctxMenuMu.RUnlock()
291	if ctxMenu != nil {
292		wv.contextMenu = ctxMenu
293		wv.connectContextMenuSignal(ctxMenu)
294	}
295
296	return wv, nil
297}
298
299// Release returns a WebView to the pool when configured and safe.
300// Default behavior remains conservative (`reuse_policy=off`) and destroys on release.
301func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
302	log := logging.FromContext(ctx)
303	start := time.Now()
304
305	if wv == nil || wv.IsDestroyed() || p.closed.Load() {
306		if wv != nil && !wv.IsDestroyed() {
307			wv.DestroyWithPolicy(p.terminatePolicy)
308		}
309		p.releaseDropped.Add(1)
310		return
311	}
312
313	action := "destroyed"
314	if p.shouldReuseOnRelease(wv) {
315		wv.ResetForPoolReuse()
316		if p.tryPut(ctx, wv) {
317			action = "reused"
318			p.releaseReused.Add(1)
319		} else {
320			action = "destroyed_after_reuse_failed"
321			wv.DestroyWithPolicy(p.terminatePolicy)
322			p.releaseDestroyed.Add(1)
323		}
324	} else {
325		wv.DestroyWithPolicy(p.terminatePolicy)
326		p.releaseDestroyed.Add(1)
327	}
328	p.releaseCount.Add(1)
329	elapsed := time.Since(start)
330	p.releaseTotalNanos.Add(elapsed.Nanoseconds())
331	log.Debug().
332		Uint64("id", uint64(wv.ID())).
333		Str("action", action).
334		Str("reuse_policy", p.reusePolicy).
335		Str("terminate_policy", p.terminatePolicy).
336		Dur("latency", elapsed).
337		Int("pool_size", len(p.pool)).
338		Uint64("acquire_total", p.acquireCount.Load()).
339		Uint64("acquire_hits", p.acquirePoolHits.Load()).
340		Uint64("acquire_misses", p.acquirePoolMisses.Load()).
341		Uint64("release_total", p.releaseCount.Load()).
342		Uint64("release_reused", p.releaseReused.Load()).
343		Uint64("release_destroyed", p.releaseDestroyed.Load()).
344		Msg("released webview")
345}
346
347// PrewarmFirst creates exactly one WebView synchronously.
348// Call this during startup (before GTK activate) to ensure first Acquire() is instant.
349// This is the most impactful optimization for cold start since WebView creation
350// is the heaviest operation (spawns WebKit web process).
351// Returns error if WebView creation fails; caller should log and continue.
352func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
353	if p.closed.Load() {
354		return ErrPoolClosed
355	}
356	if len(p.pool) > 0 {
357		return nil // Already have at least one
358	}
359
360	log := logging.FromContext(ctx)
361	wv, err := p.createWebView(ctx)
362	if err != nil {
363		return err
364	}
365
366	if p.tryPut(ctx, wv) {
367		log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")
368		return nil
369	}
370	wv.DestroyWithPolicy(p.terminatePolicy) // Pool somehow full (shouldn't happen)
371	return nil
372}
373
374// Prewarm creates WebViews synchronously to populate the pool.
375// Must be called from the GTK main thread (after GTK application is initialized).
376func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
377	if count <= 0 {
378		count = p.config.PrewarmCount
379	}
380	if count <= 0 {
381		return
382	}
383	p.prewarmSync(ctx, count)
384}
385
386// PrewarmAsync schedules WebView creation on the GTK idle loop.
387// This avoids blocking startup (especially cold-start navigation) while still
388// warming up WebViews for subsequent tab creation.
389func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
390	log := logging.FromContext(ctx)
391
392	if count <= 0 {
393		count = p.config.PrewarmCount
394	}
395	if count <= 0 {
396		return
397	}
398	if p.closed.Load() {
399		return
400	}
401
402	log.Debug().Int("count", count).Int("pool_size", len(p.pool)).Msg("scheduling async webview pool prewarm")
403
404	p.wg.Add(1)
405	var doneOnce sync.Once
406	remaining := count
407
408	var schedule func()
409	schedule = func() {
410		// Stop early if the caller canceled the operation.
411		if err := ctx.Err(); err != nil {
412			log.Debug().Err(err).Msg("async webview pool prewarm canceled")
413			doneOnce.Do(p.wg.Done)
414			return
415		}
416
417		cb := glib.SourceFunc(func(_ uintptr) bool {
418			// Stop if the caller canceled the operation.
419			if err := ctx.Err(); err != nil {
420				log.Debug().Err(err).Msg("async webview pool prewarm canceled")
421				doneOnce.Do(p.wg.Done)
422				return false
423			}
424
425			// Stop if pool is closing/closed.
426			if p.closed.Load() {
427				doneOnce.Do(p.wg.Done)
428				return false
429			}
430
431			// Stop early if we reached capacity.
432			if len(p.pool) >= p.config.MaxSize {
433				doneOnce.Do(p.wg.Done)
434				return false
435			}
436
437			wv, err := p.createWebView(ctx)
438			if err != nil {
439				log.Warn().Err(err).Msg("failed to prewarm webview")
440			} else {
441				if p.tryPut(ctx, wv) {
442					log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
443				} else {
444					wv.DestroyWithPolicy(p.terminatePolicy)
445				}
446			}
447
448			remaining--
449			if remaining <= 0 {
450				log.Debug().Int("pool_size", len(p.pool)).Msg("async webview pool prewarm complete")
451				doneOnce.Do(p.wg.Done)
452				return false
453			}
454
455			schedule()
456			return false
457		})
458		// Use LOW priority so initial navigation and other high-priority work runs first
459		glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil)
460	}
461
462	schedule()
463}
464
465// prewarmSync creates WebViews synchronously.
466func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
467	log := logging.FromContext(ctx)
468
469	for i := 0; i < count && !p.closed.Load(); i++ {
470		// Check if pool is already at capacity
471		if len(p.pool) >= p.config.MaxSize {
472			break
473		}
474
475		wv, err := p.createWebView(ctx)
476		if err != nil {
477			log.Warn().Err(err).Msg("failed to prewarm webview")
478			continue
479		}
480
481		// Try to add to pool
482		if p.tryPut(ctx, wv) {
483			log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
484		} else {
485			// Pool full, destroy
486			wv.DestroyWithPolicy(p.terminatePolicy)
487		}
488
489		// Small delay between creations to avoid overwhelming the system
490		time.Sleep(50 * time.Millisecond)
491	}
492}
493
494// Size returns the current number of WebViews in the pool.
495func (p *WebViewPool) Size() int {
496	return len(p.pool)
497}
498
499// RefreshScripts clears and re-injects scripts to all pooled WebViews.
500// This is needed after adw.Init() to update dark mode preference that was
501// injected with the wrong value during early prewarming.
502func (p *WebViewPool) RefreshScripts(ctx context.Context) {
503	if p.closed.Load() || p.injector == nil {
504		return
505	}
506
507	log := logging.FromContext(ctx)
508
509	// Drain and refresh each WebView, then put it back
510	count := len(p.pool)
511	if count == 0 {
512		return
513	}
514
515	refreshed := 0
516refreshLoop:
517	for range count {
518		select {
519		case wv := <-p.pool:
520			if wv == nil || wv.IsDestroyed() {
521				continue
522			}
523
524			// Clear existing scripts and re-inject with updated values
525			if wv.ucm != nil {
526				wv.ucm.RemoveAllScripts()
527				wv.ucm.RemoveAllStyleSheets()
528				p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
529			}
530
531			// Put back in pool
532			if p.tryPut(ctx, wv) {
533				refreshed++
534			} else {
535				wv.DestroyWithPolicy(p.terminatePolicy)
536			}
537		default:
538			// Pool was modified during iteration, stop
539			break refreshLoop
540		}
541	}
542
543	log.Debug().
544		Int("refreshed", refreshed).
545		Bool("prefers_dark", p.injector.PrefersDark()).
546		Msg("refreshed scripts in pooled webviews")
547}
548
549// Close shuts down the pool and destroys all pooled WebViews.
550func (p *WebViewPool) Close(ctx context.Context) {
551	if p.closed.Swap(true) {
552		return // Already closed
553	}
554
555	log := logging.FromContext(ctx)
556
557	// Wait for any background operations
558	p.wg.Wait()
559
560	// Drain and destroy all pooled views
561	p.poolMu.Lock()
562	close(p.pool)
563	p.poolMu.Unlock()
564	for wv := range p.pool {
565		if wv != nil && !wv.IsDestroyed() {
566			wv.Destroy()
567		}
568	}
569
570	log.Debug().Msg("webview pool closed")
571}
572
573func (p *WebViewPool) shouldReuseOnRelease(wv *WebView) bool {
574	if p.reusePolicy == reusePolicyOff || wv == nil || wv.IsDestroyed() || wv.IsRelated() {
575		return false
576	}
577	if len(p.pool) >= p.config.MaxSize {
578		return false
579	}
580	switch p.reusePolicy {
581	case reusePolicySafe:
582		// Safe mode: only reuse views that haven't navigated, avoiding
583		// back/forward/page context leaking across tabs.
584		return !wv.HasNavigationActivity()
585	case reusePolicyAggressive:
586		// Aggressive mode: reuse regardless of navigation activity.
587		// The view is reset via ResetForPoolReuse before being returned to the pool.
588		return true
589	default:
590		return false
591	}
592}
593
594func (p *WebViewPool) tryPut(ctx context.Context, wv *WebView) bool {
595	log := logging.FromContext(ctx)
596	p.poolMu.Lock()
597	defer p.poolMu.Unlock()
598	if p.closed.Load() {
599		return false
600	}
601	select {
602	case p.pool <- wv:
603		log.Debug().Uint64("id", uint64(wv.ID())).Int("pool_size", len(p.pool)).Msg("returned webview to pool")
604		return true
605	default:
606		return false
607	}
608}
609
610func resolveReusePolicy(raw string) string {
611	switch strings.ToLower(strings.TrimSpace(defaultIfEmpty(raw, os.Getenv("DUMBER_WEBVIEW_REUSE_POLICY")))) {
612	case reusePolicySafe:
613		return reusePolicySafe
614	case reusePolicyAggressive:
615		return reusePolicyAggressive
616	case reusePolicyOff, "":
617		return reusePolicyOff
618	default:
619		return reusePolicyOff
620	}
621}
622
623func defaultIfEmpty(value, fallback string) string {
624	if strings.TrimSpace(value) != "" {
625		return value
626	}
627	return fallback
628}

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close