detached 3f8c6a5

Add experimental multi-threaded CEF loop mode

commit
3f8c6a56cb7511ab77418666353826027a1393ae
author
brice · · 2026-03-20 13:48 UTC
committer
brice · · 2026-03-28 04:36 UTC
parent
95bfd15
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +159 -44
M internal/infrastructure/cef/engine.go +11 -2
@@ -29,8 +29,9 @@ type Engine struct {
2929 factory *WebViewFactory
3030 pool *WebViewPool
3131
32- externalMessagePump bool
33- manualPumpInterval int64
32+ multiThreadedMessageLoop bool
33+ externalMessagePump bool
34+ manualPumpInterval int64
3435
3536 // nextWorkAtMs is the next deadline, in Unix milliseconds, when
3637 // CefDoMessageLoopWork should run. It is written from any Chromium thread
@@ -152,6 +153,7 @@ func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.Download
152153 func (e *Engine) OnToolkitReady(_ context.Context) error {
153154 log := logging.FromContext(e.ctx)
154155 log.Debug().
156+ Bool("multi_threaded_message_loop", e.multiThreadedMessageLoop).
155157 Bool("external_message_pump", e.externalMessagePump).
156158 Int64("manual_pump_interval_ms", e.manualPumpInterval).
157159 Msg("cef: OnToolkitReady called, starting message pump")
@@ -344,6 +346,10 @@ func (e *Engine) scheduleMessagePumpWork(delayMs int64) {
344346 }
345347
346348 func (e *Engine) startMessagePump() {
349+ if e.multiThreadedMessageLoop {
350+ logging.FromContext(e.ctx).Info().Msg("cef: multi-threaded message loop enabled, skipping external/manual pump")
351+ return
352+ }
347353 if e.externalMessagePump {
348354 e.startExternalMessagePump()
349355 return
@@ -458,6 +464,9 @@ func (e *Engine) startManualMessagePump() {
458464 }
459465
460466 func (e *Engine) stopMessagePump() {
467+ if e.multiThreadedMessageLoop {
468+ return
469+ }
461470 e.pumpClosing.Store(true)
462471 e.pumpEnabled.Store(false)
463472 e.nextWorkAtMs.Store(0)
@@ -29,8 +29,9 @@ type Engine struct {
29 factory *WebViewFactory29 factory *WebViewFactory
30 pool *WebViewPool30 pool *WebViewPool
31 31
32- externalMessagePump bool32+ multiThreadedMessageLoop bool
33- manualPumpInterval int6433+ externalMessagePump bool
34+ manualPumpInterval int64
34 35
35 // nextWorkAtMs is the next deadline, in Unix milliseconds, when36 // nextWorkAtMs is the next deadline, in Unix milliseconds, when
36 // CefDoMessageLoopWork should run. It is written from any Chromium thread37 // CefDoMessageLoopWork should run. It is written from any Chromium thread
@@ -152,6 +153,7 @@ func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.Download
152 func (e *Engine) OnToolkitReady(_ context.Context) error {153 func (e *Engine) OnToolkitReady(_ context.Context) error {
153 log := logging.FromContext(e.ctx)154 log := logging.FromContext(e.ctx)
154 log.Debug().155 log.Debug().
156+ Bool("multi_threaded_message_loop", e.multiThreadedMessageLoop).
155 Bool("external_message_pump", e.externalMessagePump).157 Bool("external_message_pump", e.externalMessagePump).
156 Int64("manual_pump_interval_ms", e.manualPumpInterval).158 Int64("manual_pump_interval_ms", e.manualPumpInterval).
157 Msg("cef: OnToolkitReady called, starting message pump")159 Msg("cef: OnToolkitReady called, starting message pump")
@@ -344,6 +346,10 @@ func (e *Engine) scheduleMessagePumpWork(delayMs int64) {
344 }346 }
345 347
346 func (e *Engine) startMessagePump() {348 func (e *Engine) startMessagePump() {
349+ if e.multiThreadedMessageLoop {
350+ logging.FromContext(e.ctx).Info().Msg("cef: multi-threaded message loop enabled, skipping external/manual pump")
351+ return
352+ }
347 if e.externalMessagePump {353 if e.externalMessagePump {
348 e.startExternalMessagePump()354 e.startExternalMessagePump()
349 return355 return
@@ -458,6 +464,9 @@ func (e *Engine) startManualMessagePump() {
458 }464 }
459 465
460 func (e *Engine) stopMessagePump() {466 func (e *Engine) stopMessagePump() {
467+ if e.multiThreadedMessageLoop {
468+ return
469+ }
461 e.pumpClosing.Store(true)470 e.pumpClosing.Store(true)
462 e.pumpEnabled.Store(false)471 e.pumpEnabled.Store(false)
463 e.nextWorkAtMs.Store(0)472 e.nextWorkAtMs.Store(0)
M internal/infrastructure/cef/engine_init.go +9 -3
@@ -18,7 +18,11 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
1818 logger := logging.FromContext(ctx)
1919 // 1. Initialize CEF.
2020 settings := purecef.DefaultSettings()
21+ settings.MultiThreadedMessageLoop = parseBoolEnv("DUMBER_CEF_MULTI_THREADED_MESSAGE_LOOP", false)
2122 settings.ExternalMessagePump = parseBoolEnv("DUMBER_CEF_EXTERNAL_PUMP", settings.ExternalMessagePump)
23+ if settings.MultiThreadedMessageLoop {
24+ settings.ExternalMessagePump = false
25+ }
2226 if cfg.CEFDir != "" {
2327 settings.CEFDir = cfg.CEFDir
2428 }
@@ -52,12 +56,14 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
5256
5357 // 2. Build the engine early so the App can reference it for the pump callback.
5458 eng := &Engine{
55- ctx: ctx,
56- externalMessagePump: settings.ExternalMessagePump,
57- manualPumpInterval: parseInt64Env("DUMBER_CEF_MANUAL_PUMP_MS", 10),
59+ ctx: ctx,
60+ multiThreadedMessageLoop: settings.MultiThreadedMessageLoop,
61+ externalMessagePump: settings.ExternalMessagePump,
62+ manualPumpInterval: parseInt64Env("DUMBER_CEF_MANUAL_PUMP_MS", 10),
5863 }
5964
6065 logger.Info().
66+ Bool("multi_threaded_message_loop", settings.MultiThreadedMessageLoop).
6167 Bool("external_message_pump", settings.ExternalMessagePump).
6268 Int64("manual_pump_interval_ms", eng.manualPumpInterval).
6369 Msg("cef: configured message pump mode")
@@ -18,7 +18,11 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
18 logger := logging.FromContext(ctx)18 logger := logging.FromContext(ctx)
19 // 1. Initialize CEF.19 // 1. Initialize CEF.
20 settings := purecef.DefaultSettings()20 settings := purecef.DefaultSettings()
21+ settings.MultiThreadedMessageLoop = parseBoolEnv("DUMBER_CEF_MULTI_THREADED_MESSAGE_LOOP", false)
21 settings.ExternalMessagePump = parseBoolEnv("DUMBER_CEF_EXTERNAL_PUMP", settings.ExternalMessagePump)22 settings.ExternalMessagePump = parseBoolEnv("DUMBER_CEF_EXTERNAL_PUMP", settings.ExternalMessagePump)
23+ if settings.MultiThreadedMessageLoop {
24+ settings.ExternalMessagePump = false
25+ }
22 if cfg.CEFDir != "" {26 if cfg.CEFDir != "" {
23 settings.CEFDir = cfg.CEFDir27 settings.CEFDir = cfg.CEFDir
24 }28 }
@@ -52,12 +56,14 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
52 56
53 // 2. Build the engine early so the App can reference it for the pump callback.57 // 2. Build the engine early so the App can reference it for the pump callback.
54 eng := &Engine{58 eng := &Engine{
55- ctx: ctx,59+ ctx: ctx,
56- externalMessagePump: settings.ExternalMessagePump,60+ multiThreadedMessageLoop: settings.MultiThreadedMessageLoop,
57- manualPumpInterval: parseInt64Env("DUMBER_CEF_MANUAL_PUMP_MS", 10),61+ externalMessagePump: settings.ExternalMessagePump,
62+ manualPumpInterval: parseInt64Env("DUMBER_CEF_MANUAL_PUMP_MS", 10),
58 }63 }
59 64
60 logger.Info().65 logger.Info().
66+ Bool("multi_threaded_message_loop", settings.MultiThreadedMessageLoop).
61 Bool("external_message_pump", settings.ExternalMessagePump).67 Bool("external_message_pump", settings.ExternalMessagePump).
62 Int64("manual_pump_interval_ms", eng.manualPumpInterval).68 Int64("manual_pump_interval_ms", eng.manualPumpInterval).
63 Msg("cef: configured message pump mode")69 Msg("cef: configured message pump mode")
M internal/infrastructure/cef/factory.go +5 -2
@@ -109,8 +109,11 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
109109
110110 // On subsequent resizes, notify CEF so it re-queries GetViewRect.
111111 pipeline.onResizeCB = func(_, _ int32) {
112- if wv.host != nil {
113- wv.host.WasResized()
112+ wv.mu.RLock()
113+ host := wv.host
114+ wv.mu.RUnlock()
115+ if host != nil {
116+ host.WasResized()
114117 }
115118 }
116119
@@ -109,8 +109,11 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
109 109
110 // On subsequent resizes, notify CEF so it re-queries GetViewRect.110 // On subsequent resizes, notify CEF so it re-queries GetViewRect.
111 pipeline.onResizeCB = func(_, _ int32) {111 pipeline.onResizeCB = func(_, _ int32) {
112- if wv.host != nil {112+ wv.mu.RLock()
113- wv.host.WasResized()113+ host := wv.host
114+ wv.mu.RUnlock()
115+ if host != nil {
116+ host.WasResized()
114 }117 }
115 }118 }
116 119
M internal/infrastructure/cef/webview.go +74 -21
@@ -9,6 +9,7 @@ import (
99 "unsafe"
1010
1111 purecef "github.com/bnema/purego-cef/cef"
12+ "github.com/jwijenbergh/puregotk/v4/glib"
1213
1314 "github.com/bnema/dumber/internal/application/port"
1415 )
@@ -92,12 +93,16 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
9293 if wv.destroyed.Load() {
9394 return errDestroyed
9495 }
95- if wv.browser == nil {
96+ wv.mu.Lock()
97+ browser := wv.browser
98+ if browser == nil {
9699 // Browser not yet created — queue the URI for OnAfterCreated.
97100 wv.pendingURI = uri
101+ wv.mu.Unlock()
98102 return nil
99103 }
100- wv.browser.GetMainFrame().LoadURL(uri)
104+ wv.mu.Unlock()
105+ browser.GetMainFrame().LoadURL(uri)
101106 return nil
102107 }
103108
@@ -106,11 +111,14 @@ func (wv *WebView) LoadHTML(_ context.Context, content, _ string) error {
106111 if wv.destroyed.Load() {
107112 return errDestroyed
108113 }
109- if wv.browser == nil {
114+ wv.mu.RLock()
115+ browser := wv.browser
116+ wv.mu.RUnlock()
117+ if browser == nil {
110118 return errNoBrowser
111119 }
112120 dataURL := "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(content))
113- wv.browser.GetMainFrame().LoadURL(dataURL)
121+ browser.GetMainFrame().LoadURL(dataURL)
114122 return nil
115123 }
116124
@@ -119,10 +127,13 @@ func (wv *WebView) Reload(_ context.Context) error {
119127 if wv.destroyed.Load() {
120128 return errDestroyed
121129 }
122- if wv.browser == nil {
130+ wv.mu.RLock()
131+ browser := wv.browser
132+ wv.mu.RUnlock()
133+ if browser == nil {
123134 return errNoBrowser
124135 }
125- wv.browser.Reload()
136+ browser.Reload()
126137 return nil
127138 }
128139
@@ -131,10 +142,13 @@ func (wv *WebView) ReloadBypassCache(_ context.Context) error {
131142 if wv.destroyed.Load() {
132143 return errDestroyed
133144 }
134- if wv.browser == nil {
145+ wv.mu.RLock()
146+ browser := wv.browser
147+ wv.mu.RUnlock()
148+ if browser == nil {
135149 return errNoBrowser
136150 }
137- wv.browser.ReloadIgnoreCache()
151+ browser.ReloadIgnoreCache()
138152 return nil
139153 }
140154
@@ -143,10 +157,13 @@ func (wv *WebView) Stop(_ context.Context) error {
143157 if wv.destroyed.Load() {
144158 return errDestroyed
145159 }
146- if wv.browser == nil {
160+ wv.mu.RLock()
161+ browser := wv.browser
162+ wv.mu.RUnlock()
163+ if browser == nil {
147164 return errNoBrowser
148165 }
149- wv.browser.StopLoad()
166+ browser.StopLoad()
150167 return nil
151168 }
152169
@@ -155,10 +172,13 @@ func (wv *WebView) GoBack(_ context.Context) error {
155172 if wv.destroyed.Load() {
156173 return errDestroyed
157174 }
158- if wv.browser == nil {
175+ wv.mu.RLock()
176+ browser := wv.browser
177+ wv.mu.RUnlock()
178+ if browser == nil {
159179 return errNoBrowser
160180 }
161- wv.browser.GoBack()
181+ browser.GoBack()
162182 return nil
163183 }
164184
@@ -167,10 +187,13 @@ func (wv *WebView) GoForward(_ context.Context) error {
167187 if wv.destroyed.Load() {
168188 return errDestroyed
169189 }
170- if wv.browser == nil {
190+ wv.mu.RLock()
191+ browser := wv.browser
192+ wv.mu.RUnlock()
193+ if browser == nil {
171194 return errNoBrowser
172195 }
173- wv.browser.GoForward()
196+ browser.GoForward()
174197 return nil
175198 }
176199
@@ -295,10 +318,16 @@ func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) {
295318
296319 // RunJavaScript executes a script in the main world. Fire-and-forget.
297320 func (wv *WebView) RunJavaScript(_ context.Context, script string) {
298- if wv.destroyed.Load() || wv.browser == nil {
321+ if wv.destroyed.Load() {
299322 return
300323 }
301- wv.browser.GetMainFrame().ExecuteJavaScript(script, "", 0)
324+ wv.mu.RLock()
325+ browser := wv.browser
326+ wv.mu.RUnlock()
327+ if browser == nil {
328+ return
329+ }
330+ browser.GetMainFrame().ExecuteJavaScript(script, "", 0)
302331 }
303332
304333 // SetBackgroundColor is a no-op in Phase 1.
@@ -321,8 +350,11 @@ func (wv *WebView) Destroy() {
321350 if !wv.destroyed.CompareAndSwap(false, true) {
322351 return
323352 }
324- if wv.host != nil {
325- wv.host.CloseBrowser(1)
353+ wv.mu.RLock()
354+ host := wv.host
355+ wv.mu.RUnlock()
356+ if host != nil {
357+ host.CloseBrowser(1)
326358 }
327359 if wv.pipeline != nil {
328360 wv.pipeline.destroy()
@@ -354,7 +386,9 @@ func (wv *WebView) updateURI(uri string) {
354386 cb := wv.callbacks
355387 wv.mu.RUnlock()
356388 if cb != nil && cb.OnURIChanged != nil {
357- cb.OnURIChanged(uri)
389+ wv.runOnGTK(func() {
390+ cb.OnURIChanged(uri)
391+ })
358392 }
359393 }
360394
@@ -367,7 +401,9 @@ func (wv *WebView) updateTitle(title string) {
367401 cb := wv.callbacks
368402 wv.mu.RUnlock()
369403 if cb != nil && cb.OnTitleChanged != nil {
370- cb.OnTitleChanged(title)
404+ wv.runOnGTK(func() {
405+ cb.OnTitleChanged(title)
406+ })
371407 }
372408 }
373409
@@ -380,7 +416,9 @@ func (wv *WebView) updateProgress(progress float64) {
380416 cb := wv.callbacks
381417 wv.mu.RUnlock()
382418 if cb != nil && cb.OnProgressChanged != nil {
383- cb.OnProgressChanged(progress)
419+ wv.runOnGTK(func() {
420+ cb.OnProgressChanged(progress)
421+ })
384422 }
385423 }
386424
@@ -392,5 +430,20 @@ func (wv *WebView) updateLoadState(loading, back, fwd bool) {
392430 wv.mu.Unlock()
393431 }
394432
433+func (wv *WebView) runOnGTK(fn func()) {
434+ if fn == nil {
435+ return
436+ }
437+ if wv.engine == nil || !wv.engine.multiThreadedMessageLoop {
438+ fn()
439+ return
440+ }
441+
442+ cb := glib.SourceOnceFunc(func(_ uintptr) {
443+ fn()
444+ })
445+ glib.IdleAddOnce(&cb, 0)
446+}
447+
395448 // suppress unused import for unsafe (used by NativeWidget via GoPointer).
396449 var _ = unsafe.Pointer(nil)
@@ -9,6 +9,7 @@ import (
9 "unsafe"9 "unsafe"
10 10
11 purecef "github.com/bnema/purego-cef/cef"11 purecef "github.com/bnema/purego-cef/cef"
12+ "github.com/jwijenbergh/puregotk/v4/glib"
12 13
13 "github.com/bnema/dumber/internal/application/port"14 "github.com/bnema/dumber/internal/application/port"
14 )15 )
@@ -92,12 +93,16 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
92 if wv.destroyed.Load() {93 if wv.destroyed.Load() {
93 return errDestroyed94 return errDestroyed
94 }95 }
95- if wv.browser == nil {96+ wv.mu.Lock()
97+ browser := wv.browser
98+ if browser == nil {
96 // Browser not yet created — queue the URI for OnAfterCreated.99 // Browser not yet created — queue the URI for OnAfterCreated.
97 wv.pendingURI = uri100 wv.pendingURI = uri
101+ wv.mu.Unlock()
98 return nil102 return nil
99 }103 }
100- wv.browser.GetMainFrame().LoadURL(uri)104+ wv.mu.Unlock()
105+ browser.GetMainFrame().LoadURL(uri)
101 return nil106 return nil
102 }107 }
103 108
@@ -106,11 +111,14 @@ func (wv *WebView) LoadHTML(_ context.Context, content, _ string) error {
106 if wv.destroyed.Load() {111 if wv.destroyed.Load() {
107 return errDestroyed112 return errDestroyed
108 }113 }
109- if wv.browser == nil {114+ wv.mu.RLock()
115+ browser := wv.browser
116+ wv.mu.RUnlock()
117+ if browser == nil {
110 return errNoBrowser118 return errNoBrowser
111 }119 }
112 dataURL := "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(content))120 dataURL := "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(content))
113- wv.browser.GetMainFrame().LoadURL(dataURL)121+ browser.GetMainFrame().LoadURL(dataURL)
114 return nil122 return nil
115 }123 }
116 124
@@ -119,10 +127,13 @@ func (wv *WebView) Reload(_ context.Context) error {
119 if wv.destroyed.Load() {127 if wv.destroyed.Load() {
120 return errDestroyed128 return errDestroyed
121 }129 }
122- if wv.browser == nil {130+ wv.mu.RLock()
131+ browser := wv.browser
132+ wv.mu.RUnlock()
133+ if browser == nil {
123 return errNoBrowser134 return errNoBrowser
124 }135 }
125- wv.browser.Reload()136+ browser.Reload()
126 return nil137 return nil
127 }138 }
128 139
@@ -131,10 +142,13 @@ func (wv *WebView) ReloadBypassCache(_ context.Context) error {
131 if wv.destroyed.Load() {142 if wv.destroyed.Load() {
132 return errDestroyed143 return errDestroyed
133 }144 }
134- if wv.browser == nil {145+ wv.mu.RLock()
146+ browser := wv.browser
147+ wv.mu.RUnlock()
148+ if browser == nil {
135 return errNoBrowser149 return errNoBrowser
136 }150 }
137- wv.browser.ReloadIgnoreCache()151+ browser.ReloadIgnoreCache()
138 return nil152 return nil
139 }153 }
140 154
@@ -143,10 +157,13 @@ func (wv *WebView) Stop(_ context.Context) error {
143 if wv.destroyed.Load() {157 if wv.destroyed.Load() {
144 return errDestroyed158 return errDestroyed
145 }159 }
146- if wv.browser == nil {160+ wv.mu.RLock()
161+ browser := wv.browser
162+ wv.mu.RUnlock()
163+ if browser == nil {
147 return errNoBrowser164 return errNoBrowser
148 }165 }
149- wv.browser.StopLoad()166+ browser.StopLoad()
150 return nil167 return nil
151 }168 }
152 169
@@ -155,10 +172,13 @@ func (wv *WebView) GoBack(_ context.Context) error {
155 if wv.destroyed.Load() {172 if wv.destroyed.Load() {
156 return errDestroyed173 return errDestroyed
157 }174 }
158- if wv.browser == nil {175+ wv.mu.RLock()
176+ browser := wv.browser
177+ wv.mu.RUnlock()
178+ if browser == nil {
159 return errNoBrowser179 return errNoBrowser
160 }180 }
161- wv.browser.GoBack()181+ browser.GoBack()
162 return nil182 return nil
163 }183 }
164 184
@@ -167,10 +187,13 @@ func (wv *WebView) GoForward(_ context.Context) error {
167 if wv.destroyed.Load() {187 if wv.destroyed.Load() {
168 return errDestroyed188 return errDestroyed
169 }189 }
170- if wv.browser == nil {190+ wv.mu.RLock()
191+ browser := wv.browser
192+ wv.mu.RUnlock()
193+ if browser == nil {
171 return errNoBrowser194 return errNoBrowser
172 }195 }
173- wv.browser.GoForward()196+ browser.GoForward()
174 return nil197 return nil
175 }198 }
176 199
@@ -295,10 +318,16 @@ func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) {
295 318
296 // RunJavaScript executes a script in the main world. Fire-and-forget.319 // RunJavaScript executes a script in the main world. Fire-and-forget.
297 func (wv *WebView) RunJavaScript(_ context.Context, script string) {320 func (wv *WebView) RunJavaScript(_ context.Context, script string) {
298- if wv.destroyed.Load() || wv.browser == nil {321+ if wv.destroyed.Load() {
299 return322 return
300 }323 }
301- wv.browser.GetMainFrame().ExecuteJavaScript(script, "", 0)324+ wv.mu.RLock()
325+ browser := wv.browser
326+ wv.mu.RUnlock()
327+ if browser == nil {
328+ return
329+ }
330+ browser.GetMainFrame().ExecuteJavaScript(script, "", 0)
302 }331 }
303 332
304 // SetBackgroundColor is a no-op in Phase 1.333 // SetBackgroundColor is a no-op in Phase 1.
@@ -321,8 +350,11 @@ func (wv *WebView) Destroy() {
321 if !wv.destroyed.CompareAndSwap(false, true) {350 if !wv.destroyed.CompareAndSwap(false, true) {
322 return351 return
323 }352 }
324- if wv.host != nil {353+ wv.mu.RLock()
325- wv.host.CloseBrowser(1)354+ host := wv.host
355+ wv.mu.RUnlock()
356+ if host != nil {
357+ host.CloseBrowser(1)
326 }358 }
327 if wv.pipeline != nil {359 if wv.pipeline != nil {
328 wv.pipeline.destroy()360 wv.pipeline.destroy()
@@ -354,7 +386,9 @@ func (wv *WebView) updateURI(uri string) {
354 cb := wv.callbacks386 cb := wv.callbacks
355 wv.mu.RUnlock()387 wv.mu.RUnlock()
356 if cb != nil && cb.OnURIChanged != nil {388 if cb != nil && cb.OnURIChanged != nil {
357- cb.OnURIChanged(uri)389+ wv.runOnGTK(func() {
390+ cb.OnURIChanged(uri)
391+ })
358 }392 }
359 }393 }
360 394
@@ -367,7 +401,9 @@ func (wv *WebView) updateTitle(title string) {
367 cb := wv.callbacks401 cb := wv.callbacks
368 wv.mu.RUnlock()402 wv.mu.RUnlock()
369 if cb != nil && cb.OnTitleChanged != nil {403 if cb != nil && cb.OnTitleChanged != nil {
370- cb.OnTitleChanged(title)404+ wv.runOnGTK(func() {
405+ cb.OnTitleChanged(title)
406+ })
371 }407 }
372 }408 }
373 409
@@ -380,7 +416,9 @@ func (wv *WebView) updateProgress(progress float64) {
380 cb := wv.callbacks416 cb := wv.callbacks
381 wv.mu.RUnlock()417 wv.mu.RUnlock()
382 if cb != nil && cb.OnProgressChanged != nil {418 if cb != nil && cb.OnProgressChanged != nil {
383- cb.OnProgressChanged(progress)419+ wv.runOnGTK(func() {
420+ cb.OnProgressChanged(progress)
421+ })
384 }422 }
385 }423 }
386 424
@@ -392,5 +430,20 @@ func (wv *WebView) updateLoadState(loading, back, fwd bool) {
392 wv.mu.Unlock()430 wv.mu.Unlock()
393 }431 }
394 432
433+func (wv *WebView) runOnGTK(fn func()) {
434+ if fn == nil {
435+ return
436+ }
437+ if wv.engine == nil || !wv.engine.multiThreadedMessageLoop {
438+ fn()
439+ return
440+ }
441+
442+ cb := glib.SourceOnceFunc(func(_ uintptr) {
443+ fn()
444+ })
445+ glib.IdleAddOnce(&cb, 0)
446+}
447+
395 // suppress unused import for unsafe (used by NativeWidget via GoPointer).448 // suppress unused import for unsafe (used by NativeWidget via GoPointer).
396 var _ = unsafe.Pointer(nil)449 var _ = unsafe.Pointer(nil)
M internal/infrastructure/cef/webview_handlers.go +60 -16
@@ -99,15 +99,31 @@ func (h *handlerSet) OnPopupShow(_ purecef.Browser, _ int32) {}
9999 func (h *handlerSet) OnPopupSize(_ purecef.Browser, _ *purecef.Rect) {}
100100
101101 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects
102-// to the render pipeline for GPU upload.
102+// to the render pipeline for GPU upload. With a multi-threaded CEF UI loop we
103+// must first copy the transient CEF buffer before hopping back to GTK.
103104 func (h *handlerSet) OnPaint(
104105 _ purecef.Browser, _ purecef.PaintElementType,
105106 dirtyRects []purecef.Rect, buffer unsafe.Pointer, width, height int32,
106107 ) {
108+ if buffer == nil || width <= 0 || height <= 0 {
109+ return
110+ }
107111 rects := make([]rect, len(dirtyRects))
108112 for i, dr := range dirtyRects {
109113 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}
110114 }
115+ if h.wv.engine != nil && h.wv.engine.multiThreadedMessageLoop {
116+ bufSize := int(width) * int(height) * 4
117+ if bufSize <= 0 {
118+ return
119+ }
120+ pixels := make([]byte, bufSize)
121+ copy(pixels, unsafe.Slice((*byte)(buffer), bufSize))
122+ h.wv.runOnGTK(func() {
123+ h.wv.pipeline.handlePaint(unsafe.Pointer(&pixels[0]), width, height, rects)
124+ })
125+ return
126+ }
111127 h.wv.pipeline.handlePaint(buffer, width, height, rects)
112128 }
113129
@@ -170,10 +186,14 @@ func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32)
170186 return
171187 }
172188 if entering && cb.OnEnterFullscreen != nil {
173- cb.OnEnterFullscreen()
189+ h.wv.runOnGTK(func() {
190+ cb.OnEnterFullscreen()
191+ })
174192 }
175193 if !entering && cb.OnLeaveFullscreen != nil {
176- cb.OnLeaveFullscreen()
194+ h.wv.runOnGTK(func() {
195+ cb.OnLeaveFullscreen()
196+ })
177197 }
178198 }
179199
@@ -185,7 +205,9 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
185205 cb := h.wv.callbacks
186206 h.wv.mu.RUnlock()
187207 if cb != nil && cb.OnLinkHover != nil {
188- cb.OnLinkHover(value)
208+ h.wv.runOnGTK(func() {
209+ cb.OnLinkHover(value)
210+ })
189211 }
190212 }
191213
@@ -202,7 +224,9 @@ func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64
202224
203225 func (h *handlerSet) OnCursorChange(_ purecef.Browser, _ uintptr, cursorType purecef.CursorType, _ *purecef.CursorInfo) int32 {
204226 name := cefCursorToGDKName(cursorType)
205- h.wv.pipeline.glArea.SetCursorFromName(&name)
227+ h.wv.runOnGTK(func() {
228+ h.wv.pipeline.glArea.SetCursorFromName(&name)
229+ })
206230 return 1 // handled
207231 }
208232
@@ -226,9 +250,13 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac
226250 h.wv.mu.RUnlock()
227251 if cb != nil && cb.OnLoadChanged != nil {
228252 if loading {
229- cb.OnLoadChanged(port.LoadStarted)
253+ h.wv.runOnGTK(func() {
254+ cb.OnLoadChanged(port.LoadStarted)
255+ })
230256 } else {
231- cb.OnLoadChanged(port.LoadFinished)
257+ h.wv.runOnGTK(func() {
258+ cb.OnLoadChanged(port.LoadFinished)
259+ })
232260 }
233261 }
234262 }
@@ -245,7 +273,9 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
245273 cb := h.wv.callbacks
246274 h.wv.mu.RUnlock()
247275 if cb != nil && cb.OnLoadChanged != nil {
248- cb.OnLoadChanged(port.LoadCommitted)
276+ h.wv.runOnGTK(func() {
277+ cb.OnLoadChanged(port.LoadCommitted)
278+ })
249279 }
250280 }
251281
@@ -268,10 +298,14 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
268298 h.wv.mu.RUnlock()
269299 if cb != nil {
270300 if cb.OnLoadChanged != nil {
271- cb.OnLoadChanged(port.LoadFinished)
301+ h.wv.runOnGTK(func() {
302+ cb.OnLoadChanged(port.LoadFinished)
303+ })
272304 }
273305 if cb.OnProgressChanged != nil {
274- cb.OnProgressChanged(1.0)
306+ h.wv.runOnGTK(func() {
307+ cb.OnProgressChanged(1.0)
308+ })
275309 }
276310 }
277311 }
@@ -316,13 +350,19 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
316350 if h.wv.engine != nil {
317351 h.wv.engine.recordBrowserAfterCreated(browser)
318352 }
353+ host := browser.GetHost()
354+
355+ h.wv.mu.Lock()
319356 h.wv.browser = browser
320- h.wv.host = browser.GetHost()
321- h.wv.input.setHost(h.wv.host)
357+ h.wv.host = host
358+ uri := h.wv.pendingURI
359+ h.wv.pendingURI = ""
360+ h.wv.mu.Unlock()
361+
362+ h.wv.input.setHost(host)
322363
323364 // Replay any navigation that was requested before the browser existed.
324- if uri := h.wv.pendingURI; uri != "" {
325- h.wv.pendingURI = ""
365+ if uri != "" {
326366 browser.GetMainFrame().LoadURL(uri)
327367 }
328368 }
@@ -338,7 +378,9 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
338378 cb := h.wv.callbacks
339379 h.wv.mu.RUnlock()
340380 if cb != nil && cb.OnClose != nil {
341- cb.OnClose()
381+ h.wv.runOnGTK(func() {
382+ cb.OnClose()
383+ })
342384 }
343385 }
344386
@@ -424,7 +466,9 @@ func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef
424466 uri := h.wv.uri
425467 h.wv.mu.RUnlock()
426468 if cb != nil && cb.OnWebProcessTerminated != nil {
427- cb.OnWebProcessTerminated(reason, label, uri)
469+ h.wv.runOnGTK(func() {
470+ cb.OnWebProcessTerminated(reason, label, uri)
471+ })
428472 }
429473 }
430474
@@ -99,15 +99,31 @@ func (h *handlerSet) OnPopupShow(_ purecef.Browser, _ int32) {}
99 func (h *handlerSet) OnPopupSize(_ purecef.Browser, _ *purecef.Rect) {}99 func (h *handlerSet) OnPopupSize(_ purecef.Browser, _ *purecef.Rect) {}
100 100
101 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects101 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects
102-// to the render pipeline for GPU upload.102+// to the render pipeline for GPU upload. With a multi-threaded CEF UI loop we
103+// must first copy the transient CEF buffer before hopping back to GTK.
103 func (h *handlerSet) OnPaint(104 func (h *handlerSet) OnPaint(
104 _ purecef.Browser, _ purecef.PaintElementType,105 _ purecef.Browser, _ purecef.PaintElementType,
105 dirtyRects []purecef.Rect, buffer unsafe.Pointer, width, height int32,106 dirtyRects []purecef.Rect, buffer unsafe.Pointer, width, height int32,
106 ) {107 ) {
108+ if buffer == nil || width <= 0 || height <= 0 {
109+ return
110+ }
107 rects := make([]rect, len(dirtyRects))111 rects := make([]rect, len(dirtyRects))
108 for i, dr := range dirtyRects {112 for i, dr := range dirtyRects {
109 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}113 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}
110 }114 }
115+ if h.wv.engine != nil && h.wv.engine.multiThreadedMessageLoop {
116+ bufSize := int(width) * int(height) * 4
117+ if bufSize <= 0 {
118+ return
119+ }
120+ pixels := make([]byte, bufSize)
121+ copy(pixels, unsafe.Slice((*byte)(buffer), bufSize))
122+ h.wv.runOnGTK(func() {
123+ h.wv.pipeline.handlePaint(unsafe.Pointer(&pixels[0]), width, height, rects)
124+ })
125+ return
126+ }
111 h.wv.pipeline.handlePaint(buffer, width, height, rects)127 h.wv.pipeline.handlePaint(buffer, width, height, rects)
112 }128 }
113 129
@@ -170,10 +186,14 @@ func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32)
170 return186 return
171 }187 }
172 if entering && cb.OnEnterFullscreen != nil {188 if entering && cb.OnEnterFullscreen != nil {
173- cb.OnEnterFullscreen()189+ h.wv.runOnGTK(func() {
190+ cb.OnEnterFullscreen()
191+ })
174 }192 }
175 if !entering && cb.OnLeaveFullscreen != nil {193 if !entering && cb.OnLeaveFullscreen != nil {
176- cb.OnLeaveFullscreen()194+ h.wv.runOnGTK(func() {
195+ cb.OnLeaveFullscreen()
196+ })
177 }197 }
178 }198 }
179 199
@@ -185,7 +205,9 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
185 cb := h.wv.callbacks205 cb := h.wv.callbacks
186 h.wv.mu.RUnlock()206 h.wv.mu.RUnlock()
187 if cb != nil && cb.OnLinkHover != nil {207 if cb != nil && cb.OnLinkHover != nil {
188- cb.OnLinkHover(value)208+ h.wv.runOnGTK(func() {
209+ cb.OnLinkHover(value)
210+ })
189 }211 }
190 }212 }
191 213
@@ -202,7 +224,9 @@ func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64
202 224
203 func (h *handlerSet) OnCursorChange(_ purecef.Browser, _ uintptr, cursorType purecef.CursorType, _ *purecef.CursorInfo) int32 {225 func (h *handlerSet) OnCursorChange(_ purecef.Browser, _ uintptr, cursorType purecef.CursorType, _ *purecef.CursorInfo) int32 {
204 name := cefCursorToGDKName(cursorType)226 name := cefCursorToGDKName(cursorType)
205- h.wv.pipeline.glArea.SetCursorFromName(&name)227+ h.wv.runOnGTK(func() {
228+ h.wv.pipeline.glArea.SetCursorFromName(&name)
229+ })
206 return 1 // handled230 return 1 // handled
207 }231 }
208 232
@@ -226,9 +250,13 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac
226 h.wv.mu.RUnlock()250 h.wv.mu.RUnlock()
227 if cb != nil && cb.OnLoadChanged != nil {251 if cb != nil && cb.OnLoadChanged != nil {
228 if loading {252 if loading {
229- cb.OnLoadChanged(port.LoadStarted)253+ h.wv.runOnGTK(func() {
254+ cb.OnLoadChanged(port.LoadStarted)
255+ })
230 } else {256 } else {
231- cb.OnLoadChanged(port.LoadFinished)257+ h.wv.runOnGTK(func() {
258+ cb.OnLoadChanged(port.LoadFinished)
259+ })
232 }260 }
233 }261 }
234 }262 }
@@ -245,7 +273,9 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
245 cb := h.wv.callbacks273 cb := h.wv.callbacks
246 h.wv.mu.RUnlock()274 h.wv.mu.RUnlock()
247 if cb != nil && cb.OnLoadChanged != nil {275 if cb != nil && cb.OnLoadChanged != nil {
248- cb.OnLoadChanged(port.LoadCommitted)276+ h.wv.runOnGTK(func() {
277+ cb.OnLoadChanged(port.LoadCommitted)
278+ })
249 }279 }
250 }280 }
251 281
@@ -268,10 +298,14 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
268 h.wv.mu.RUnlock()298 h.wv.mu.RUnlock()
269 if cb != nil {299 if cb != nil {
270 if cb.OnLoadChanged != nil {300 if cb.OnLoadChanged != nil {
271- cb.OnLoadChanged(port.LoadFinished)301+ h.wv.runOnGTK(func() {
302+ cb.OnLoadChanged(port.LoadFinished)
303+ })
272 }304 }
273 if cb.OnProgressChanged != nil {305 if cb.OnProgressChanged != nil {
274- cb.OnProgressChanged(1.0)306+ h.wv.runOnGTK(func() {
307+ cb.OnProgressChanged(1.0)
308+ })
275 }309 }
276 }310 }
277 }311 }
@@ -316,13 +350,19 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
316 if h.wv.engine != nil {350 if h.wv.engine != nil {
317 h.wv.engine.recordBrowserAfterCreated(browser)351 h.wv.engine.recordBrowserAfterCreated(browser)
318 }352 }
353+ host := browser.GetHost()
354+
355+ h.wv.mu.Lock()
319 h.wv.browser = browser356 h.wv.browser = browser
320- h.wv.host = browser.GetHost()357+ h.wv.host = host
321- h.wv.input.setHost(h.wv.host)358+ uri := h.wv.pendingURI
359+ h.wv.pendingURI = ""
360+ h.wv.mu.Unlock()
361+
362+ h.wv.input.setHost(host)
322 363
323 // Replay any navigation that was requested before the browser existed.364 // Replay any navigation that was requested before the browser existed.
324- if uri := h.wv.pendingURI; uri != "" {365+ if uri != "" {
325- h.wv.pendingURI = ""
326 browser.GetMainFrame().LoadURL(uri)366 browser.GetMainFrame().LoadURL(uri)
327 }367 }
328 }368 }
@@ -338,7 +378,9 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
338 cb := h.wv.callbacks378 cb := h.wv.callbacks
339 h.wv.mu.RUnlock()379 h.wv.mu.RUnlock()
340 if cb != nil && cb.OnClose != nil {380 if cb != nil && cb.OnClose != nil {
341- cb.OnClose()381+ h.wv.runOnGTK(func() {
382+ cb.OnClose()
383+ })
342 }384 }
343 }385 }
344 386
@@ -424,7 +466,9 @@ func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef
424 uri := h.wv.uri466 uri := h.wv.uri
425 h.wv.mu.RUnlock()467 h.wv.mu.RUnlock()
426 if cb != nil && cb.OnWebProcessTerminated != nil {468 if cb != nil && cb.OnWebProcessTerminated != nil {
427- cb.OnWebProcessTerminated(reason, label, uri)469+ h.wv.runOnGTK(func() {
470+ cb.OnWebProcessTerminated(reason, label, uri)
471+ })
428 }472 }
429 }473 }
430 474

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