detached a004a88

refactor(cef): route pump config through config system, remove dead external pump

Move CEF message pump settings (MultiThreadedMessageLoop, ManualPumpIntervalMs)
into CEFEngineConfig instead of reading env vars directly. Multi-threaded message
loop is now the default — no env vars needed to launch CEF.

Remove ~250 lines of dead external pump code (wakeup pipe, signal/drain, arm/disarm
timer, onPumpWake, performMessageLoopWork, diagnostics) since the external pump is
broken in purego-cef (OnScheduleMessagePumpWork never fires after cef_initialize).

- config/engine.go: add MultiThreadedMessageLoop (*bool, default true) and
  ManualPumpIntervalMs (int64, default 10) to CEFEngineConfig
- engine_init.go: read from config, hardcode ExternalMessagePump=false with
  bug comment, remove parseBoolEnv/parseInt64Env helpers
- engine.go: strip external pump fields/methods, simplify manual pump and
  stopMessagePump
- main.go: replace multiThreadedCEFLoopEnabled() with resolveEngineType(cfg)
- app.go: remove cefMultiThreadedLoopEnabled() and conditional color scheme skip
commit
a004a884cbf5c8381b1c0480f1e2d32b97a2abf3
author
brice · · 2026-03-20 14:46 UTC
committer
brice · · 2026-03-28 04:36 UTC
parent
ec11c42
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +61 -464
M cmd/dumber/main.go +10 -9
@@ -7,7 +7,6 @@ import (
77 "os"
88 "os/signal"
99 "runtime"
10- "strconv"
1110 "syscall"
1211
1312 "github.com/bnema/dumber/internal/application/port"
@@ -114,7 +113,7 @@ func runGUI() int {
114113 }
115114 timer.MarkDuration("parallel_phase", initResult.Duration)
116115
117- if multiThreadedCEFLoopEnabled() {
116+ if cfg.Engine.CEF.CEFMultiThreadedMessageLoop() && resolveEngineType(cfg) == "cef" {
118117 logging.FromContext(ctx).Info().Msg("pre-initializing libadwaita before CEF multi-threaded loop")
119118 ui.EnsureAdwaitaInitialized()
120119 if initResult.AdwaitaDetector != nil {
@@ -253,14 +252,16 @@ func initStartupContext(cfg *config.Config) context.Context {
253252 return ctx
254253 }
255254
256-func multiThreadedCEFLoopEnabled() bool {
257- value, ok := os.LookupEnv("DUMBER_CEF_MULTI_THREADED_MESSAGE_LOOP")
258- if !ok || value == "" {
259- return false
255+// resolveEngineType returns the effective engine type from config + env override.
256+func resolveEngineType(cfg *config.Config) string {
257+ engineType := cfg.Engine.Type
258+ if engineType == "" {
259+ engineType = "webkit"
260260 }
261-
262- enabled, err := strconv.ParseBool(value)
263- return err == nil && enabled
261+ if envEngine := os.Getenv("DUMBER_ENGINE"); envEngine != "" {
262+ engineType = envEngine
263+ }
264+ return engineType
264265 }
265266
266267 func initStackAndRepos(
@@ -7,7 +7,6 @@ import (
7 "os"7 "os"
8 "os/signal"8 "os/signal"
9 "runtime"9 "runtime"
10- "strconv"
11 "syscall"10 "syscall"
12 11
13 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
@@ -114,7 +113,7 @@ func runGUI() int {
114 }113 }
115 timer.MarkDuration("parallel_phase", initResult.Duration)114 timer.MarkDuration("parallel_phase", initResult.Duration)
116 115
117- if multiThreadedCEFLoopEnabled() {116+ if cfg.Engine.CEF.CEFMultiThreadedMessageLoop() && resolveEngineType(cfg) == "cef" {
118 logging.FromContext(ctx).Info().Msg("pre-initializing libadwaita before CEF multi-threaded loop")117 logging.FromContext(ctx).Info().Msg("pre-initializing libadwaita before CEF multi-threaded loop")
119 ui.EnsureAdwaitaInitialized()118 ui.EnsureAdwaitaInitialized()
120 if initResult.AdwaitaDetector != nil {119 if initResult.AdwaitaDetector != nil {
@@ -253,14 +252,16 @@ func initStartupContext(cfg *config.Config) context.Context {
253 return ctx252 return ctx
254 }253 }
255 254
256-func multiThreadedCEFLoopEnabled() bool {255+// resolveEngineType returns the effective engine type from config + env override.
257- value, ok := os.LookupEnv("DUMBER_CEF_MULTI_THREADED_MESSAGE_LOOP")256+func resolveEngineType(cfg *config.Config) string {
258- if !ok || value == "" {257+ engineType := cfg.Engine.Type
259- return false258+ if engineType == "" {
259+ engineType = "webkit"
260 }260 }
261-261+ if envEngine := os.Getenv("DUMBER_ENGINE"); envEngine != "" {
262- enabled, err := strconv.ParseBool(value)262+ engineType = envEngine
263- return err == nil && enabled263+ }
264+ return engineType
264 }265 }
265 266
266 func initStackAndRepos(267 func initStackAndRepos(
M internal/infrastructure/cef/engine.go +13 -401
@@ -2,7 +2,6 @@ package cef
22
33 import (
44 "context"
5- "os"
65 "sync/atomic"
76 "time"
87
@@ -16,11 +15,6 @@ import (
1615 // Compile-time interface check.
1716 var _ port.Engine = (*Engine)(nil)
1817
19-// cefFallbackPumpDelayMS mirrors the upstream external message pump fallback:
20-// even when no new callback arrives we still re-enter CefDoMessageLoopWork
21-// periodically so async browser creation and IPC continue to make progress.
22-const cefFallbackPumpDelayMS int64 = 1000 / 30
23-
2418 // Engine implements port.Engine for the CEF browser backend.
2519 // It manages the CEF lifecycle and provides access to all engine subsystems.
2620 type Engine struct {
@@ -30,44 +24,16 @@ type Engine struct {
3024 pool *WebViewPool
3125
3226 multiThreadedMessageLoop bool
33- externalMessagePump bool
3427 manualPumpInterval int64
3528
36- // nextWorkAtMs is the next deadline, in Unix milliseconds, when
37- // CefDoMessageLoopWork should run. It is written from any Chromium thread
38- // via OnScheduleMessagePumpWork and read on the GTK main thread.
39- // Value: 0 means "nothing scheduled right now".
40- nextWorkAtMs atomic.Int64
41-
42- // Pump lifecycle. Scheduling callbacks can originate from any Chromium
43- // thread, while source callbacks execute on the GTK main thread.
44- pumpEnabled atomic.Bool
45- pumpClosing atomic.Bool
46-
47- pumpSignalPending atomic.Bool
48- pumpSignalSource atomic.Uint64
49- pumpSignalRead *os.File
50- pumpSignalWrite *os.File
51-
52- pumpTimerDueAtMs atomic.Int64
53- pumpTimerSource atomic.Uint64
54-
55- // Reentrancy guard for DoMessageLoopWork.
56- pumpActive bool
57- pumpReentry bool
58-
59- // Diagnostic counters (temporary).
60- pumpWakeCount uint64
61- pumpWorkCount uint64
62- pumpWorkTotalNs uint64
63- pumpWorkMaxNs int64
64- pumpSlowCount uint64
65- scheduleCount atomic.Uint64 // incremented from any thread — read from main thread only
66-
67- scheduleImmediateCount atomic.Uint64
68- scheduleDelayedCount atomic.Uint64
69- scheduleLastDelayMs atomic.Int64
70- scheduleMaxDelayMs atomic.Int64
29+ // Pump lifecycle (manual pump mode only).
30+ pumpEnabled atomic.Bool
31+ pumpClosing atomic.Bool
32+ pumpTimerSource atomic.Uint64
33+
34+ // Diagnostic counters.
35+ pumpWorkCount uint64
36+ scheduleCount atomic.Uint64 // OnScheduleMessagePumpWork call count (currently always 0)
7137
7238 contextInitializedCount atomic.Uint64
7339 childLaunchRenderer atomic.Uint64
@@ -81,13 +47,6 @@ type Engine struct {
8147 browserCreateLastWidth atomic.Int32
8248 browserCreateLastHeight atomic.Int32
8349 lastStallLoggedCreateSeq uint64
84-
85- pumpDiagLastLogAt time.Time
86- pumpDiagLastWakeCount uint64
87- pumpDiagLastWorkCount uint64
88- pumpDiagLastSchedule uint64
89- pumpDiagLastWorkTotalNs uint64
90- pumpDiagLastSlowCount uint64
9150 }
9251
9352 // Factory returns the WebViewFactory for creating new WebView instances.
@@ -154,7 +113,6 @@ func (e *Engine) OnToolkitReady(_ context.Context) error {
154113 log := logging.FromContext(e.ctx)
155114 log.Debug().
156115 Bool("multi_threaded_message_loop", e.multiThreadedMessageLoop).
157- Bool("external_message_pump", e.externalMessagePump).
158116 Int64("manual_pump_interval_ms", e.manualPumpInterval).
159117 Msg("cef: OnToolkitReady called, starting message pump")
160118 e.startMessagePump()
@@ -268,14 +226,8 @@ func (e *Engine) maybeLogBrowserCreateStall() {
268226 Int32("last_create_result", e.browserCreateLastResult.Load()).
269227 Int32("last_create_width", e.browserCreateLastWidth.Load()).
270228 Int32("last_create_height", e.browserCreateLastHeight.Load()).
271- Uint64("pump_wakes", e.pumpWakeCount).
272229 Uint64("pump_work", e.pumpWorkCount).
273230 Uint64("schedule_calls", e.scheduleCount.Load()).
274- Uint64("schedule_immediate", e.scheduleImmediateCount.Load()).
275- Uint64("schedule_delayed", e.scheduleDelayedCount.Load()).
276- Int64("last_schedule_delay_ms", e.scheduleLastDelayMs.Load()).
277- Int64("max_schedule_delay_ms", e.scheduleMaxDelayMs.Load()).
278- Int64("next_work_in_ms", e.nextWorkAtMs.Load()-time.Now().UnixMilli()).
279231 Uint64("context_initialized", e.contextInitializedCount.Load()).
280232 Uint64("renderer_launches", e.childLaunchRenderer.Load()).
281233 Uint64("gpu_launches", e.childLaunchGPU.Load()).
@@ -284,125 +236,24 @@ func (e *Engine) maybeLogBrowserCreateStall() {
284236 }
285237
286238 // ---------------------------------------------------------------------------
287-// CEF message pump — thread-safe via atomic deadlines
288-//
289-// OnScheduleMessagePumpWork can be called from ANY Chromium thread (UI, IO,
290-// GPU compositor, etc.). It updates the next due time atomically and signals a
291-// persistent GLib fd watch via a wakeup pipe. This closely follows the Linux
292-// external-message-pump strategy used by upstream cefclient and avoids
293-// creating/destroying GLib sources from Chromium threads.
239+// CEF message pump
294240 // ---------------------------------------------------------------------------
295241
296242 // scheduleMessagePumpWork is called by OnScheduleMessagePumpWork from any
297-// thread. It updates the earliest due deadline atomically and signals the GTK
298-// main thread when it needs to re-evaluate timer state.
299-func (e *Engine) scheduleMessagePumpWork(delayMs int64) {
300- if !e.externalMessagePump {
301- return
302- }
303-
304- nowMs := time.Now().UnixMilli()
305- dueAtMs := nowMs
306- if delayMs > 0 {
307- dueAtMs += delayMs
308- }
309-
310- for {
311- current := e.nextWorkAtMs.Load()
312- if current != 0 && current <= dueAtMs {
313- dueAtMs = current
314- break
315- }
316- if e.nextWorkAtMs.CompareAndSwap(current, dueAtMs) {
317- break
318- }
319- }
320-
243+// thread. External message pump is disabled (purego-cef bug), so this is a
244+// no-op with a diagnostic counter.
245+func (e *Engine) scheduleMessagePumpWork(_ int64) {
321246 e.scheduleCount.Add(1)
322- e.scheduleLastDelayMs.Store(delayMs)
323- if delayMs <= 0 {
324- e.scheduleImmediateCount.Add(1)
325- } else {
326- e.scheduleDelayedCount.Add(1)
327- for {
328- current := e.scheduleMaxDelayMs.Load()
329- if delayMs <= current {
330- break
331- }
332- if e.scheduleMaxDelayMs.CompareAndSwap(current, delayMs) {
333- break
334- }
335- }
336- }
337-
338- if !e.pumpEnabled.Load() || e.pumpClosing.Load() {
339- return
340- }
341-
342- armedDueAtMs := e.pumpTimerDueAtMs.Load()
343- if dueAtMs <= nowMs || armedDueAtMs == 0 || dueAtMs < armedDueAtMs {
344- e.signalPump()
345- }
346247 }
347248
348249 func (e *Engine) startMessagePump() {
349250 if e.multiThreadedMessageLoop {
350- logging.FromContext(e.ctx).Info().Msg("cef: multi-threaded message loop enabled, skipping external/manual pump")
351- return
352- }
353- if e.externalMessagePump {
354- e.startExternalMessagePump()
251+ logging.FromContext(e.ctx).Info().Msg("cef: multi-threaded message loop enabled, skipping manual pump")
355252 return
356253 }
357254 e.startManualMessagePump()
358255 }
359256
360-func (e *Engine) startExternalMessagePump() {
361- if e.pumpEnabled.Load() {
362- return
363- }
364-
365- readFile, writeFile, err := os.Pipe()
366- if err != nil {
367- logging.FromContext(e.ctx).Error().Err(err).Msg("cef: failed to create wakeup pipe")
368- return
369- }
370-
371- cb := glib.UnixFDSourceFunc(func(_ int, _ glib.IOCondition, _ uintptr) bool {
372- e.pumpSignalPending.Store(false)
373- e.drainPumpSignal()
374- if e.pumpClosing.Load() || !e.pumpEnabled.Load() {
375- return glib.SOURCE_REMOVE
376- }
377- e.onPumpWake()
378- return glib.SOURCE_CONTINUE
379- })
380-
381- sourceID := glib.UnixFdAddFull(
382- glib.PRIORITY_DEFAULT_IDLE,
383- int(readFile.Fd()),
384- glib.GIoInValue|glib.GIoHupValue|glib.GIoErrValue,
385- &cb,
386- 0,
387- nil,
388- )
389-
390- if sourceID == 0 {
391- _ = readFile.Close()
392- _ = writeFile.Close()
393- logging.FromContext(e.ctx).Error().Msg("cef: failed to attach wakeup pipe source")
394- return
395- }
396-
397- e.pumpSignalRead = readFile
398- e.pumpSignalWrite = writeFile
399- e.pumpSignalSource.Store(uint64(sourceID))
400- e.pumpClosing.Store(false)
401- e.pumpEnabled.Store(true)
402- e.nextWorkAtMs.Store(time.Now().UnixMilli())
403- e.signalPump()
404-}
405-
406257 func (e *Engine) startManualMessagePump() {
407258 if e.pumpEnabled.Load() {
408259 return
@@ -418,31 +269,16 @@ func (e *Engine) startManualMessagePump() {
418269 return glib.SOURCE_REMOVE
419270 }
420271
421- nowMs := time.Now().UnixMilli()
422- e.pumpWakeCount++
423272 e.pumpWorkCount++
424273 e.maybeLogBrowserCreateStall()
425274 if e.pumpWorkCount <= 20 || e.pumpWorkCount%100 == 0 {
426275 logging.FromContext(e.ctx).Debug().
427- Uint64("wake", e.pumpWakeCount).
428276 Uint64("work", e.pumpWorkCount).
429277 Int64("interval_ms", intervalMs).
430- Int64("now_ms", nowMs).
431278 Msg("cef: manual pump doing work")
432279 }
433280
434- workStartedAt := time.Now()
435281 purecef.DoMessageLoopWork()
436- workDuration := time.Since(workStartedAt)
437- e.pumpWorkTotalNs += uint64(workDuration)
438- if workDuration.Nanoseconds() > e.pumpWorkMaxNs {
439- e.pumpWorkMaxNs = workDuration.Nanoseconds()
440- }
441- if workDuration >= 2*time.Millisecond {
442- e.pumpSlowCount++
443- }
444- e.maybeLogPumpDiagnostics()
445-
446282 return glib.SOURCE_CONTINUE
447283 })
448284
@@ -457,7 +293,6 @@ func (e *Engine) startManualMessagePump() {
457293 e.pumpClosing.Store(false)
458294 e.pumpEnabled.Store(true)
459295 e.pumpTimerSource.Store(uint64(sourceID))
460- e.pumpTimerDueAtMs.Store(time.Now().UnixMilli() + intervalMs)
461296 logging.FromContext(e.ctx).Info().
462297 Int64("interval_ms", intervalMs).
463298 Msg("cef: started manual CEF pump")
@@ -469,230 +304,7 @@ func (e *Engine) stopMessagePump() {
469304 }
470305 e.pumpClosing.Store(true)
471306 e.pumpEnabled.Store(false)
472- e.nextWorkAtMs.Store(0)
473- e.pumpTimerDueAtMs.Store(0)
474307 if sourceID := uint(e.pumpTimerSource.Swap(0)); sourceID != 0 {
475308 glib.SourceRemove(sourceID)
476309 }
477- if sourceID := uint(e.pumpSignalSource.Swap(0)); sourceID != 0 {
478- glib.SourceRemove(sourceID)
479- }
480- if e.pumpSignalRead != nil {
481- _ = e.pumpSignalRead.Close()
482- e.pumpSignalRead = nil
483- }
484- if e.pumpSignalWrite != nil {
485- _ = e.pumpSignalWrite.Close()
486- e.pumpSignalWrite = nil
487- }
488-}
489-
490-func (e *Engine) scheduleFallbackPumpWork(nowMs int64) {
491- if e.nextWorkAtMs.Load() != 0 {
492- return
493- }
494- e.nextWorkAtMs.CompareAndSwap(0, nowMs+cefFallbackPumpDelayMS)
495-}
496-
497-func (e *Engine) signalPump() {
498- if e.pumpClosing.Load() || !e.pumpEnabled.Load() {
499- return
500- }
501- if !e.pumpSignalPending.CompareAndSwap(false, true) {
502- return
503- }
504-
505- if e.pumpSignalWrite == nil {
506- e.pumpSignalPending.Store(false)
507- return
508- }
509-
510- var wakeByte [1]byte
511- wakeByte[0] = 1
512- if _, err := e.pumpSignalWrite.Write(wakeByte[:]); err != nil {
513- e.pumpSignalPending.Store(false)
514- }
515-}
516-
517-func (e *Engine) drainPumpSignal() {
518- if e.pumpSignalRead == nil {
519- return
520- }
521-
522- var buf [64]byte
523- _, _ = e.pumpSignalRead.Read(buf[:])
524-}
525-
526-func (e *Engine) armPumpTimer(dueAtMs, nowMs int64) {
527- if e.pumpClosing.Load() || !e.pumpEnabled.Load() {
528- return
529- }
530-
531- currentDueAtMs := e.pumpTimerDueAtMs.Load()
532- if currentDueAtMs != 0 && currentDueAtMs <= dueAtMs {
533- return
534- }
535-
536- if sourceID := uint(e.pumpTimerSource.Swap(0)); sourceID != 0 {
537- glib.SourceRemove(sourceID)
538- }
539-
540- delayMs := dueAtMs - nowMs
541- if delayMs <= 0 {
542- delayMs = 1
543- }
544- if delayMs > cefFallbackPumpDelayMS {
545- delayMs = cefFallbackPumpDelayMS
546- }
547-
548- cb := glib.SourceOnceFunc(func(_ uintptr) {
549- e.pumpTimerSource.Store(0)
550- e.pumpTimerDueAtMs.Store(0)
551- if e.pumpClosing.Load() || !e.pumpEnabled.Load() {
552- return
553- }
554- e.onPumpWake()
555- })
556-
557- sourceID := glib.TimeoutAddOnce(uint(delayMs), &cb, 0)
558- e.pumpTimerSource.Store(uint64(sourceID))
559- e.pumpTimerDueAtMs.Store(nowMs + delayMs)
560-}
561-
562-func (e *Engine) disarmPumpTimer() {
563- e.pumpTimerDueAtMs.Store(0)
564- if sourceID := uint(e.pumpTimerSource.Swap(0)); sourceID != 0 {
565- glib.SourceRemove(sourceID)
566- }
567-}
568-
569-// onPumpWake is called on the GTK main thread when the message pump should
570-// either process due CEF work immediately or arm a one-shot timer for the
571-// next deadline.
572-func (e *Engine) onPumpWake() {
573- e.pumpWakeCount++
574- e.maybeLogBrowserCreateStall()
575-
576- dueAtMs := e.nextWorkAtMs.Load()
577- if dueAtMs == 0 {
578- e.disarmPumpTimer()
579- return
580- }
581-
582- nowMs := time.Now().UnixMilli()
583- if nowMs < dueAtMs {
584- e.armPumpTimer(dueAtMs, nowMs)
585- return
586- }
587-
588- e.disarmPumpTimer()
589- e.nextWorkAtMs.Store(0)
590- e.pumpWorkCount++
591- if e.pumpWorkCount <= 20 || e.pumpWorkCount%100 == 0 {
592- logging.FromContext(e.ctx).Debug().
593- Uint64("wake", e.pumpWakeCount).
594- Uint64("work", e.pumpWorkCount).
595- Uint64("schedule_calls", e.scheduleCount.Load()).
596- Int64("due_at_ms", dueAtMs).
597- Int64("now_ms", nowMs).
598- Msg("cef: pump doing work")
599- }
600- workStartedAt := time.Now()
601- wasReentrant := e.performMessageLoopWork()
602- workDuration := time.Since(workStartedAt)
603- e.pumpWorkTotalNs += uint64(workDuration)
604- if workDuration.Nanoseconds() > e.pumpWorkMaxNs {
605- e.pumpWorkMaxNs = workDuration.Nanoseconds()
606- }
607- if workDuration >= 2*time.Millisecond {
608- e.pumpSlowCount++
609- }
610-
611- // Match the upstream external-pump reference behavior: if CEF did not
612- // request another deadline, keep a placeholder wakeup so async work such as
613- // browser creation can continue progressing.
614- nowMs = time.Now().UnixMilli()
615- if wasReentrant {
616- e.signalPump()
617- return
618- }
619- e.scheduleFallbackPumpWork(nowMs)
620-
621- nextDueAtMs := e.nextWorkAtMs.Load()
622- if nextDueAtMs == 0 {
623- return
624- }
625- if nextDueAtMs <= nowMs {
626- e.signalPump()
627- e.maybeLogPumpDiagnostics()
628- return
629- }
630- e.armPumpTimer(nextDueAtMs, nowMs)
631- e.maybeLogPumpDiagnostics()
632-}
633-
634-// performMessageLoopWork calls DoMessageLoopWork with reentrancy protection.
635-// It returns true when re-entrant work was detected and should be reposted.
636-func (e *Engine) performMessageLoopWork() bool {
637- if e.pumpActive {
638- e.pumpReentry = true
639- return false
640- }
641-
642- e.pumpActive = true
643- e.pumpReentry = false
644- purecef.DoMessageLoopWork()
645- e.pumpActive = false
646-
647- return e.pumpReentry
648-}
649-
650-func (e *Engine) maybeLogPumpDiagnostics() {
651- now := time.Now()
652- if !e.pumpDiagLastLogAt.IsZero() && now.Sub(e.pumpDiagLastLogAt) < 3*time.Second {
653- return
654- }
655-
656- wakeDelta := e.pumpWakeCount - e.pumpDiagLastWakeCount
657- workDelta := e.pumpWorkCount - e.pumpDiagLastWorkCount
658- scheduleTotal := e.scheduleCount.Load()
659- scheduleDelta := scheduleTotal - e.pumpDiagLastSchedule
660- workTotalNsDelta := e.pumpWorkTotalNs - e.pumpDiagLastWorkTotalNs
661- slowDelta := e.pumpSlowCount - e.pumpDiagLastSlowCount
662- if wakeDelta == 0 && workDelta == 0 && scheduleDelta == 0 {
663- return
664- }
665-
666- elapsed := now.Sub(e.pumpDiagLastLogAt)
667- if e.pumpDiagLastLogAt.IsZero() || elapsed <= 0 {
668- elapsed = time.Second
669- }
670- elapsedSec := elapsed.Seconds()
671-
672- avgWorkUs := float64(0)
673- if workDelta > 0 {
674- avgWorkUs = float64(workTotalNsDelta) / float64(workDelta) / 1_000
675- }
676-
677- logging.FromContext(e.ctx).Debug().
678- Float64("wake_hz", float64(wakeDelta)/elapsedSec).
679- Float64("work_hz", float64(workDelta)/elapsedSec).
680- Float64("schedule_hz", float64(scheduleDelta)/elapsedSec).
681- Uint64("wake_delta", wakeDelta).
682- Uint64("work_delta", workDelta).
683- Uint64("schedule_delta", scheduleDelta).
684- Float64("avg_do_work_us", avgWorkUs).
685- Float64("max_do_work_us", float64(e.pumpWorkMaxNs)/1_000).
686- Uint64("slow_do_work_delta", slowDelta).
687- Uint64("wake_total", e.pumpWakeCount).
688- Uint64("work_total", e.pumpWorkCount).
689- Uint64("schedule_total", scheduleTotal).
690- Msg("cef: pump activity")
691-
692- e.pumpDiagLastLogAt = now
693- e.pumpDiagLastWakeCount = e.pumpWakeCount
694- e.pumpDiagLastWorkCount = e.pumpWorkCount
695- e.pumpDiagLastSchedule = scheduleTotal
696- e.pumpDiagLastWorkTotalNs = e.pumpWorkTotalNs
697- e.pumpDiagLastSlowCount = e.pumpSlowCount
698310 }
@@ -2,7 +2,6 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5- "os"
6 "sync/atomic"5 "sync/atomic"
7 "time"6 "time"
8 7
@@ -16,11 +15,6 @@ import (
16 // Compile-time interface check.15 // Compile-time interface check.
17 var _ port.Engine = (*Engine)(nil)16 var _ port.Engine = (*Engine)(nil)
18 17
19-// cefFallbackPumpDelayMS mirrors the upstream external message pump fallback:
20-// even when no new callback arrives we still re-enter CefDoMessageLoopWork
21-// periodically so async browser creation and IPC continue to make progress.
22-const cefFallbackPumpDelayMS int64 = 1000 / 30
23-
24 // Engine implements port.Engine for the CEF browser backend.18 // Engine implements port.Engine for the CEF browser backend.
25 // It manages the CEF lifecycle and provides access to all engine subsystems.19 // It manages the CEF lifecycle and provides access to all engine subsystems.
26 type Engine struct {20 type Engine struct {
@@ -30,44 +24,16 @@ type Engine struct {
30 pool *WebViewPool24 pool *WebViewPool
31 25
32 multiThreadedMessageLoop bool26 multiThreadedMessageLoop bool
33- externalMessagePump bool
34 manualPumpInterval int6427 manualPumpInterval int64
35 28
36- // nextWorkAtMs is the next deadline, in Unix milliseconds, when29+ // Pump lifecycle (manual pump mode only).
37- // CefDoMessageLoopWork should run. It is written from any Chromium thread30+ pumpEnabled atomic.Bool
38- // via OnScheduleMessagePumpWork and read on the GTK main thread.31+ pumpClosing atomic.Bool
39- // Value: 0 means "nothing scheduled right now".32+ pumpTimerSource atomic.Uint64
40- nextWorkAtMs atomic.Int6433+
41-34+ // Diagnostic counters.
42- // Pump lifecycle. Scheduling callbacks can originate from any Chromium35+ pumpWorkCount uint64
43- // thread, while source callbacks execute on the GTK main thread.36+ scheduleCount atomic.Uint64 // OnScheduleMessagePumpWork call count (currently always 0)
44- pumpEnabled atomic.Bool
45- pumpClosing atomic.Bool
46-
47- pumpSignalPending atomic.Bool
48- pumpSignalSource atomic.Uint64
49- pumpSignalRead *os.File
50- pumpSignalWrite *os.File
51-
52- pumpTimerDueAtMs atomic.Int64
53- pumpTimerSource atomic.Uint64
54-
55- // Reentrancy guard for DoMessageLoopWork.
56- pumpActive bool
57- pumpReentry bool
58-
59- // Diagnostic counters (temporary).
60- pumpWakeCount uint64
61- pumpWorkCount uint64
62- pumpWorkTotalNs uint64
63- pumpWorkMaxNs int64
64- pumpSlowCount uint64
65- scheduleCount atomic.Uint64 // incremented from any thread — read from main thread only
66-
67- scheduleImmediateCount atomic.Uint64
68- scheduleDelayedCount atomic.Uint64
69- scheduleLastDelayMs atomic.Int64
70- scheduleMaxDelayMs atomic.Int64
71 37
72 contextInitializedCount atomic.Uint6438 contextInitializedCount atomic.Uint64
73 childLaunchRenderer atomic.Uint6439 childLaunchRenderer atomic.Uint64
@@ -81,13 +47,6 @@ type Engine struct {
81 browserCreateLastWidth atomic.Int3247 browserCreateLastWidth atomic.Int32
82 browserCreateLastHeight atomic.Int3248 browserCreateLastHeight atomic.Int32
83 lastStallLoggedCreateSeq uint6449 lastStallLoggedCreateSeq uint64
84-
85- pumpDiagLastLogAt time.Time
86- pumpDiagLastWakeCount uint64
87- pumpDiagLastWorkCount uint64
88- pumpDiagLastSchedule uint64
89- pumpDiagLastWorkTotalNs uint64
90- pumpDiagLastSlowCount uint64
91 }50 }
92 51
93 // Factory returns the WebViewFactory for creating new WebView instances.52 // Factory returns the WebViewFactory for creating new WebView instances.
@@ -154,7 +113,6 @@ func (e *Engine) OnToolkitReady(_ context.Context) error {
154 log := logging.FromContext(e.ctx)113 log := logging.FromContext(e.ctx)
155 log.Debug().114 log.Debug().
156 Bool("multi_threaded_message_loop", e.multiThreadedMessageLoop).115 Bool("multi_threaded_message_loop", e.multiThreadedMessageLoop).
157- Bool("external_message_pump", e.externalMessagePump).
158 Int64("manual_pump_interval_ms", e.manualPumpInterval).116 Int64("manual_pump_interval_ms", e.manualPumpInterval).
159 Msg("cef: OnToolkitReady called, starting message pump")117 Msg("cef: OnToolkitReady called, starting message pump")
160 e.startMessagePump()118 e.startMessagePump()
@@ -268,14 +226,8 @@ func (e *Engine) maybeLogBrowserCreateStall() {
268 Int32("last_create_result", e.browserCreateLastResult.Load()).226 Int32("last_create_result", e.browserCreateLastResult.Load()).
269 Int32("last_create_width", e.browserCreateLastWidth.Load()).227 Int32("last_create_width", e.browserCreateLastWidth.Load()).
270 Int32("last_create_height", e.browserCreateLastHeight.Load()).228 Int32("last_create_height", e.browserCreateLastHeight.Load()).
271- Uint64("pump_wakes", e.pumpWakeCount).
272 Uint64("pump_work", e.pumpWorkCount).229 Uint64("pump_work", e.pumpWorkCount).
273 Uint64("schedule_calls", e.scheduleCount.Load()).230 Uint64("schedule_calls", e.scheduleCount.Load()).
274- Uint64("schedule_immediate", e.scheduleImmediateCount.Load()).
275- Uint64("schedule_delayed", e.scheduleDelayedCount.Load()).
276- Int64("last_schedule_delay_ms", e.scheduleLastDelayMs.Load()).
277- Int64("max_schedule_delay_ms", e.scheduleMaxDelayMs.Load()).
278- Int64("next_work_in_ms", e.nextWorkAtMs.Load()-time.Now().UnixMilli()).
279 Uint64("context_initialized", e.contextInitializedCount.Load()).231 Uint64("context_initialized", e.contextInitializedCount.Load()).
280 Uint64("renderer_launches", e.childLaunchRenderer.Load()).232 Uint64("renderer_launches", e.childLaunchRenderer.Load()).
281 Uint64("gpu_launches", e.childLaunchGPU.Load()).233 Uint64("gpu_launches", e.childLaunchGPU.Load()).
@@ -284,125 +236,24 @@ func (e *Engine) maybeLogBrowserCreateStall() {
284 }236 }
285 237
286 // ---------------------------------------------------------------------------238 // ---------------------------------------------------------------------------
287-// CEF message pump — thread-safe via atomic deadlines239+// CEF message pump
288-//
289-// OnScheduleMessagePumpWork can be called from ANY Chromium thread (UI, IO,
290-// GPU compositor, etc.). It updates the next due time atomically and signals a
291-// persistent GLib fd watch via a wakeup pipe. This closely follows the Linux
292-// external-message-pump strategy used by upstream cefclient and avoids
293-// creating/destroying GLib sources from Chromium threads.
294 // ---------------------------------------------------------------------------240 // ---------------------------------------------------------------------------
295 241
296 // scheduleMessagePumpWork is called by OnScheduleMessagePumpWork from any242 // scheduleMessagePumpWork is called by OnScheduleMessagePumpWork from any
297-// thread. It updates the earliest due deadline atomically and signals the GTK243+// thread. External message pump is disabled (purego-cef bug), so this is a
298-// main thread when it needs to re-evaluate timer state.244+// no-op with a diagnostic counter.
299-func (e *Engine) scheduleMessagePumpWork(delayMs int64) {245+func (e *Engine) scheduleMessagePumpWork(_ int64) {
300- if !e.externalMessagePump {
301- return
302- }
303-
304- nowMs := time.Now().UnixMilli()
305- dueAtMs := nowMs
306- if delayMs > 0 {
307- dueAtMs += delayMs
308- }
309-
310- for {
311- current := e.nextWorkAtMs.Load()
312- if current != 0 && current <= dueAtMs {
313- dueAtMs = current
314- break
315- }
316- if e.nextWorkAtMs.CompareAndSwap(current, dueAtMs) {
317- break
318- }
319- }
320-
321 e.scheduleCount.Add(1)246 e.scheduleCount.Add(1)
322- e.scheduleLastDelayMs.Store(delayMs)
323- if delayMs <= 0 {
324- e.scheduleImmediateCount.Add(1)
325- } else {
326- e.scheduleDelayedCount.Add(1)
327- for {
328- current := e.scheduleMaxDelayMs.Load()
329- if delayMs <= current {
330- break
331- }
332- if e.scheduleMaxDelayMs.CompareAndSwap(current, delayMs) {
333- break
334- }
335- }
336- }
337-
338- if !e.pumpEnabled.Load() || e.pumpClosing.Load() {
339- return
340- }
341-
342- armedDueAtMs := e.pumpTimerDueAtMs.Load()
343- if dueAtMs <= nowMs || armedDueAtMs == 0 || dueAtMs < armedDueAtMs {
344- e.signalPump()
345- }
346 }247 }
347 248
348 func (e *Engine) startMessagePump() {249 func (e *Engine) startMessagePump() {
349 if e.multiThreadedMessageLoop {250 if e.multiThreadedMessageLoop {
350- logging.FromContext(e.ctx).Info().Msg("cef: multi-threaded message loop enabled, skipping external/manual pump")251+ logging.FromContext(e.ctx).Info().Msg("cef: multi-threaded message loop enabled, skipping manual pump")
351- return
352- }
353- if e.externalMessagePump {
354- e.startExternalMessagePump()
355 return252 return
356 }253 }
357 e.startManualMessagePump()254 e.startManualMessagePump()
358 }255 }
359 256
360-func (e *Engine) startExternalMessagePump() {
361- if e.pumpEnabled.Load() {
362- return
363- }
364-
365- readFile, writeFile, err := os.Pipe()
366- if err != nil {
367- logging.FromContext(e.ctx).Error().Err(err).Msg("cef: failed to create wakeup pipe")
368- return
369- }
370-
371- cb := glib.UnixFDSourceFunc(func(_ int, _ glib.IOCondition, _ uintptr) bool {
372- e.pumpSignalPending.Store(false)
373- e.drainPumpSignal()
374- if e.pumpClosing.Load() || !e.pumpEnabled.Load() {
375- return glib.SOURCE_REMOVE
376- }
377- e.onPumpWake()
378- return glib.SOURCE_CONTINUE
379- })
380-
381- sourceID := glib.UnixFdAddFull(
382- glib.PRIORITY_DEFAULT_IDLE,
383- int(readFile.Fd()),
384- glib.GIoInValue|glib.GIoHupValue|glib.GIoErrValue,
385- &cb,
386- 0,
387- nil,
388- )
389-
390- if sourceID == 0 {
391- _ = readFile.Close()
392- _ = writeFile.Close()
393- logging.FromContext(e.ctx).Error().Msg("cef: failed to attach wakeup pipe source")
394- return
395- }
396-
397- e.pumpSignalRead = readFile
398- e.pumpSignalWrite = writeFile
399- e.pumpSignalSource.Store(uint64(sourceID))
400- e.pumpClosing.Store(false)
401- e.pumpEnabled.Store(true)
402- e.nextWorkAtMs.Store(time.Now().UnixMilli())
403- e.signalPump()
404-}
405-
406 func (e *Engine) startManualMessagePump() {257 func (e *Engine) startManualMessagePump() {
407 if e.pumpEnabled.Load() {258 if e.pumpEnabled.Load() {
408 return259 return
@@ -418,31 +269,16 @@ func (e *Engine) startManualMessagePump() {
418 return glib.SOURCE_REMOVE269 return glib.SOURCE_REMOVE
419 }270 }
420 271
421- nowMs := time.Now().UnixMilli()
422- e.pumpWakeCount++
423 e.pumpWorkCount++272 e.pumpWorkCount++
424 e.maybeLogBrowserCreateStall()273 e.maybeLogBrowserCreateStall()
425 if e.pumpWorkCount <= 20 || e.pumpWorkCount%100 == 0 {274 if e.pumpWorkCount <= 20 || e.pumpWorkCount%100 == 0 {
426 logging.FromContext(e.ctx).Debug().275 logging.FromContext(e.ctx).Debug().
427- Uint64("wake", e.pumpWakeCount).
428 Uint64("work", e.pumpWorkCount).276 Uint64("work", e.pumpWorkCount).
429 Int64("interval_ms", intervalMs).277 Int64("interval_ms", intervalMs).
430- Int64("now_ms", nowMs).
431 Msg("cef: manual pump doing work")278 Msg("cef: manual pump doing work")
432 }279 }
433 280
434- workStartedAt := time.Now()
435 purecef.DoMessageLoopWork()281 purecef.DoMessageLoopWork()
436- workDuration := time.Since(workStartedAt)
437- e.pumpWorkTotalNs += uint64(workDuration)
438- if workDuration.Nanoseconds() > e.pumpWorkMaxNs {
439- e.pumpWorkMaxNs = workDuration.Nanoseconds()
440- }
441- if workDuration >= 2*time.Millisecond {
442- e.pumpSlowCount++
443- }
444- e.maybeLogPumpDiagnostics()
445-
446 return glib.SOURCE_CONTINUE282 return glib.SOURCE_CONTINUE
447 })283 })
448 284
@@ -457,7 +293,6 @@ func (e *Engine) startManualMessagePump() {
457 e.pumpClosing.Store(false)293 e.pumpClosing.Store(false)
458 e.pumpEnabled.Store(true)294 e.pumpEnabled.Store(true)
459 e.pumpTimerSource.Store(uint64(sourceID))295 e.pumpTimerSource.Store(uint64(sourceID))
460- e.pumpTimerDueAtMs.Store(time.Now().UnixMilli() + intervalMs)
461 logging.FromContext(e.ctx).Info().296 logging.FromContext(e.ctx).Info().
462 Int64("interval_ms", intervalMs).297 Int64("interval_ms", intervalMs).
463 Msg("cef: started manual CEF pump")298 Msg("cef: started manual CEF pump")
@@ -469,230 +304,7 @@ func (e *Engine) stopMessagePump() {
469 }304 }
470 e.pumpClosing.Store(true)305 e.pumpClosing.Store(true)
471 e.pumpEnabled.Store(false)306 e.pumpEnabled.Store(false)
472- e.nextWorkAtMs.Store(0)
473- e.pumpTimerDueAtMs.Store(0)
474 if sourceID := uint(e.pumpTimerSource.Swap(0)); sourceID != 0 {307 if sourceID := uint(e.pumpTimerSource.Swap(0)); sourceID != 0 {
475 glib.SourceRemove(sourceID)308 glib.SourceRemove(sourceID)
476 }309 }
477- if sourceID := uint(e.pumpSignalSource.Swap(0)); sourceID != 0 {
478- glib.SourceRemove(sourceID)
479- }
480- if e.pumpSignalRead != nil {
481- _ = e.pumpSignalRead.Close()
482- e.pumpSignalRead = nil
483- }
484- if e.pumpSignalWrite != nil {
485- _ = e.pumpSignalWrite.Close()
486- e.pumpSignalWrite = nil
487- }
488-}
489-
490-func (e *Engine) scheduleFallbackPumpWork(nowMs int64) {
491- if e.nextWorkAtMs.Load() != 0 {
492- return
493- }
494- e.nextWorkAtMs.CompareAndSwap(0, nowMs+cefFallbackPumpDelayMS)
495-}
496-
497-func (e *Engine) signalPump() {
498- if e.pumpClosing.Load() || !e.pumpEnabled.Load() {
499- return
500- }
501- if !e.pumpSignalPending.CompareAndSwap(false, true) {
502- return
503- }
504-
505- if e.pumpSignalWrite == nil {
506- e.pumpSignalPending.Store(false)
507- return
508- }
509-
510- var wakeByte [1]byte
511- wakeByte[0] = 1
512- if _, err := e.pumpSignalWrite.Write(wakeByte[:]); err != nil {
513- e.pumpSignalPending.Store(false)
514- }
515-}
516-
517-func (e *Engine) drainPumpSignal() {
518- if e.pumpSignalRead == nil {
519- return
520- }
521-
522- var buf [64]byte
523- _, _ = e.pumpSignalRead.Read(buf[:])
524-}
525-
526-func (e *Engine) armPumpTimer(dueAtMs, nowMs int64) {
527- if e.pumpClosing.Load() || !e.pumpEnabled.Load() {
528- return
529- }
530-
531- currentDueAtMs := e.pumpTimerDueAtMs.Load()
532- if currentDueAtMs != 0 && currentDueAtMs <= dueAtMs {
533- return
534- }
535-
536- if sourceID := uint(e.pumpTimerSource.Swap(0)); sourceID != 0 {
537- glib.SourceRemove(sourceID)
538- }
539-
540- delayMs := dueAtMs - nowMs
541- if delayMs <= 0 {
542- delayMs = 1
543- }
544- if delayMs > cefFallbackPumpDelayMS {
545- delayMs = cefFallbackPumpDelayMS
546- }
547-
548- cb := glib.SourceOnceFunc(func(_ uintptr) {
549- e.pumpTimerSource.Store(0)
550- e.pumpTimerDueAtMs.Store(0)
551- if e.pumpClosing.Load() || !e.pumpEnabled.Load() {
552- return
553- }
554- e.onPumpWake()
555- })
556-
557- sourceID := glib.TimeoutAddOnce(uint(delayMs), &cb, 0)
558- e.pumpTimerSource.Store(uint64(sourceID))
559- e.pumpTimerDueAtMs.Store(nowMs + delayMs)
560-}
561-
562-func (e *Engine) disarmPumpTimer() {
563- e.pumpTimerDueAtMs.Store(0)
564- if sourceID := uint(e.pumpTimerSource.Swap(0)); sourceID != 0 {
565- glib.SourceRemove(sourceID)
566- }
567-}
568-
569-// onPumpWake is called on the GTK main thread when the message pump should
570-// either process due CEF work immediately or arm a one-shot timer for the
571-// next deadline.
572-func (e *Engine) onPumpWake() {
573- e.pumpWakeCount++
574- e.maybeLogBrowserCreateStall()
575-
576- dueAtMs := e.nextWorkAtMs.Load()
577- if dueAtMs == 0 {
578- e.disarmPumpTimer()
579- return
580- }
581-
582- nowMs := time.Now().UnixMilli()
583- if nowMs < dueAtMs {
584- e.armPumpTimer(dueAtMs, nowMs)
585- return
586- }
587-
588- e.disarmPumpTimer()
589- e.nextWorkAtMs.Store(0)
590- e.pumpWorkCount++
591- if e.pumpWorkCount <= 20 || e.pumpWorkCount%100 == 0 {
592- logging.FromContext(e.ctx).Debug().
593- Uint64("wake", e.pumpWakeCount).
594- Uint64("work", e.pumpWorkCount).
595- Uint64("schedule_calls", e.scheduleCount.Load()).
596- Int64("due_at_ms", dueAtMs).
597- Int64("now_ms", nowMs).
598- Msg("cef: pump doing work")
599- }
600- workStartedAt := time.Now()
601- wasReentrant := e.performMessageLoopWork()
602- workDuration := time.Since(workStartedAt)
603- e.pumpWorkTotalNs += uint64(workDuration)
604- if workDuration.Nanoseconds() > e.pumpWorkMaxNs {
605- e.pumpWorkMaxNs = workDuration.Nanoseconds()
606- }
607- if workDuration >= 2*time.Millisecond {
608- e.pumpSlowCount++
609- }
610-
611- // Match the upstream external-pump reference behavior: if CEF did not
612- // request another deadline, keep a placeholder wakeup so async work such as
613- // browser creation can continue progressing.
614- nowMs = time.Now().UnixMilli()
615- if wasReentrant {
616- e.signalPump()
617- return
618- }
619- e.scheduleFallbackPumpWork(nowMs)
620-
621- nextDueAtMs := e.nextWorkAtMs.Load()
622- if nextDueAtMs == 0 {
623- return
624- }
625- if nextDueAtMs <= nowMs {
626- e.signalPump()
627- e.maybeLogPumpDiagnostics()
628- return
629- }
630- e.armPumpTimer(nextDueAtMs, nowMs)
631- e.maybeLogPumpDiagnostics()
632-}
633-
634-// performMessageLoopWork calls DoMessageLoopWork with reentrancy protection.
635-// It returns true when re-entrant work was detected and should be reposted.
636-func (e *Engine) performMessageLoopWork() bool {
637- if e.pumpActive {
638- e.pumpReentry = true
639- return false
640- }
641-
642- e.pumpActive = true
643- e.pumpReentry = false
644- purecef.DoMessageLoopWork()
645- e.pumpActive = false
646-
647- return e.pumpReentry
648-}
649-
650-func (e *Engine) maybeLogPumpDiagnostics() {
651- now := time.Now()
652- if !e.pumpDiagLastLogAt.IsZero() && now.Sub(e.pumpDiagLastLogAt) < 3*time.Second {
653- return
654- }
655-
656- wakeDelta := e.pumpWakeCount - e.pumpDiagLastWakeCount
657- workDelta := e.pumpWorkCount - e.pumpDiagLastWorkCount
658- scheduleTotal := e.scheduleCount.Load()
659- scheduleDelta := scheduleTotal - e.pumpDiagLastSchedule
660- workTotalNsDelta := e.pumpWorkTotalNs - e.pumpDiagLastWorkTotalNs
661- slowDelta := e.pumpSlowCount - e.pumpDiagLastSlowCount
662- if wakeDelta == 0 && workDelta == 0 && scheduleDelta == 0 {
663- return
664- }
665-
666- elapsed := now.Sub(e.pumpDiagLastLogAt)
667- if e.pumpDiagLastLogAt.IsZero() || elapsed <= 0 {
668- elapsed = time.Second
669- }
670- elapsedSec := elapsed.Seconds()
671-
672- avgWorkUs := float64(0)
673- if workDelta > 0 {
674- avgWorkUs = float64(workTotalNsDelta) / float64(workDelta) / 1_000
675- }
676-
677- logging.FromContext(e.ctx).Debug().
678- Float64("wake_hz", float64(wakeDelta)/elapsedSec).
679- Float64("work_hz", float64(workDelta)/elapsedSec).
680- Float64("schedule_hz", float64(scheduleDelta)/elapsedSec).
681- Uint64("wake_delta", wakeDelta).
682- Uint64("work_delta", workDelta).
683- Uint64("schedule_delta", scheduleDelta).
684- Float64("avg_do_work_us", avgWorkUs).
685- Float64("max_do_work_us", float64(e.pumpWorkMaxNs)/1_000).
686- Uint64("slow_do_work_delta", slowDelta).
687- Uint64("wake_total", e.pumpWakeCount).
688- Uint64("work_total", e.pumpWorkCount).
689- Uint64("schedule_total", scheduleTotal).
690- Msg("cef: pump activity")
691-
692- e.pumpDiagLastLogAt = now
693- e.pumpDiagLastWakeCount = e.pumpWakeCount
694- e.pumpDiagLastWorkCount = e.pumpWorkCount
695- e.pumpDiagLastSchedule = scheduleTotal
696- e.pumpDiagLastWorkTotalNs = e.pumpWorkTotalNs
697- e.pumpDiagLastSlowCount = e.pumpSlowCount
698 }310 }
M internal/infrastructure/cef/engine_init.go +15 -38
@@ -5,7 +5,6 @@ import (
55 "fmt"
66 "os"
77 "path/filepath"
8- "strconv"
98
109 purecef "github.com/bnema/purego-cef/cef"
1110
@@ -16,13 +15,19 @@ import (
1615 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
1716 func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) {
1817 logger := logging.FromContext(ctx)
18+
19+ // Resolve pump mode from config (with defaults).
20+ multiThreaded := cfg.CEFMultiThreadedMessageLoop()
21+ manualPumpMs := cfg.CEFManualPumpIntervalMs()
22+
1923 // 1. Initialize CEF.
2024 settings := purecef.DefaultSettings()
21- settings.MultiThreadedMessageLoop = parseBoolEnv("DUMBER_CEF_MULTI_THREADED_MESSAGE_LOOP", false)
22- settings.ExternalMessagePump = parseBoolEnv("DUMBER_CEF_EXTERNAL_PUMP", settings.ExternalMessagePump)
23- if settings.MultiThreadedMessageLoop {
24- settings.ExternalMessagePump = false
25- }
25+ settings.MultiThreadedMessageLoop = multiThreaded
26+ // External message pump is disabled: purego-cef has a bug where
27+ // OnScheduleMessagePumpWork is never called after cef_initialize
28+ // (double-wrapping of BrowserProcessHandler in NewApp).
29+ // When multi-threaded is false, we use a manual glib timer instead.
30+ settings.ExternalMessagePump = false
2631 if cfg.CEFDir != "" {
2732 settings.CEFDir = cfg.CEFDir
2833 }
@@ -57,15 +62,13 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
5762 // 2. Build the engine early so the App can reference it for the pump callback.
5863 eng := &Engine{
5964 ctx: ctx,
60- multiThreadedMessageLoop: settings.MultiThreadedMessageLoop,
61- externalMessagePump: settings.ExternalMessagePump,
62- manualPumpInterval: parseInt64Env("DUMBER_CEF_MANUAL_PUMP_MS", 10),
65+ multiThreadedMessageLoop: multiThreaded,
66+ manualPumpInterval: manualPumpMs,
6367 }
6468
6569 logger.Info().
66- Bool("multi_threaded_message_loop", settings.MultiThreadedMessageLoop).
67- Bool("external_message_pump", settings.ExternalMessagePump).
68- Int64("manual_pump_interval_ms", eng.manualPumpInterval).
70+ Bool("multi_threaded_message_loop", multiThreaded).
71+ Int64("manual_pump_interval_ms", manualPumpMs).
6972 Msg("cef: configured message pump mode")
7073
7174 // Create the CEF App with a BrowserProcessHandler that drives the
@@ -123,29 +126,3 @@ func findHelperBinary() string {
123126 }
124127 return ""
125128 }
126-
127-func parseBoolEnv(name string, fallback bool) bool {
128- value, ok := os.LookupEnv(name)
129- if !ok || value == "" {
130- return fallback
131- }
132-
133- parsed, err := strconv.ParseBool(value)
134- if err != nil {
135- return fallback
136- }
137- return parsed
138-}
139-
140-func parseInt64Env(name string, fallback int64) int64 {
141- value, ok := os.LookupEnv(name)
142- if !ok || value == "" {
143- return fallback
144- }
145-
146- parsed, err := strconv.ParseInt(value, 10, 64)
147- if err != nil || parsed <= 0 {
148- return fallback
149- }
150- return parsed
151-}
@@ -5,7 +5,6 @@ import (
5 "fmt"5 "fmt"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8- "strconv"
9 8
10 purecef "github.com/bnema/purego-cef/cef"9 purecef "github.com/bnema/purego-cef/cef"
11 10
@@ -16,13 +15,19 @@ import (
16 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.15 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
17 func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) {16 func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) {
18 logger := logging.FromContext(ctx)17 logger := logging.FromContext(ctx)
18+
19+ // Resolve pump mode from config (with defaults).
20+ multiThreaded := cfg.CEFMultiThreadedMessageLoop()
21+ manualPumpMs := cfg.CEFManualPumpIntervalMs()
22+
19 // 1. Initialize CEF.23 // 1. Initialize CEF.
20 settings := purecef.DefaultSettings()24 settings := purecef.DefaultSettings()
21- settings.MultiThreadedMessageLoop = parseBoolEnv("DUMBER_CEF_MULTI_THREADED_MESSAGE_LOOP", false)25+ settings.MultiThreadedMessageLoop = multiThreaded
22- settings.ExternalMessagePump = parseBoolEnv("DUMBER_CEF_EXTERNAL_PUMP", settings.ExternalMessagePump)26+ // External message pump is disabled: purego-cef has a bug where
23- if settings.MultiThreadedMessageLoop {27+ // OnScheduleMessagePumpWork is never called after cef_initialize
24- settings.ExternalMessagePump = false28+ // (double-wrapping of BrowserProcessHandler in NewApp).
25- }29+ // When multi-threaded is false, we use a manual glib timer instead.
30+ settings.ExternalMessagePump = false
26 if cfg.CEFDir != "" {31 if cfg.CEFDir != "" {
27 settings.CEFDir = cfg.CEFDir32 settings.CEFDir = cfg.CEFDir
28 }33 }
@@ -57,15 +62,13 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
57 // 2. Build the engine early so the App can reference it for the pump callback.62 // 2. Build the engine early so the App can reference it for the pump callback.
58 eng := &Engine{63 eng := &Engine{
59 ctx: ctx,64 ctx: ctx,
60- multiThreadedMessageLoop: settings.MultiThreadedMessageLoop,65+ multiThreadedMessageLoop: multiThreaded,
61- externalMessagePump: settings.ExternalMessagePump,66+ manualPumpInterval: manualPumpMs,
62- manualPumpInterval: parseInt64Env("DUMBER_CEF_MANUAL_PUMP_MS", 10),
63 }67 }
64 68
65 logger.Info().69 logger.Info().
66- Bool("multi_threaded_message_loop", settings.MultiThreadedMessageLoop).70+ Bool("multi_threaded_message_loop", multiThreaded).
67- Bool("external_message_pump", settings.ExternalMessagePump).71+ Int64("manual_pump_interval_ms", manualPumpMs).
68- Int64("manual_pump_interval_ms", eng.manualPumpInterval).
69 Msg("cef: configured message pump mode")72 Msg("cef: configured message pump mode")
70 73
71 // Create the CEF App with a BrowserProcessHandler that drives the74 // Create the CEF App with a BrowserProcessHandler that drives the
@@ -123,29 +126,3 @@ func findHelperBinary() string {
123 }126 }
124 return ""127 return ""
125 }128 }
126-
127-func parseBoolEnv(name string, fallback bool) bool {
128- value, ok := os.LookupEnv(name)
129- if !ok || value == "" {
130- return fallback
131- }
132-
133- parsed, err := strconv.ParseBool(value)
134- if err != nil {
135- return fallback
136- }
137- return parsed
138-}
139-
140-func parseInt64Env(name string, fallback int64) int64 {
141- value, ok := os.LookupEnv(name)
142- if !ok || value == "" {
143- return fallback
144- }
145-
146- parsed, err := strconv.ParseInt(value, 10, 64)
147- if err != nil || parsed <= 0 {
148- return fallback
149- }
150- return parsed
151-}
M internal/infrastructure/config/engine.go +22 -0
@@ -69,6 +69,28 @@ type CEFEngineConfig struct {
6969 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.
7070 // Default: 30. Higher values increase CPU usage.
7171 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`
72+ // MultiThreadedMessageLoop lets CEF run its own message loop thread.
73+ // Default: true. When false, the host drives the pump via a manual timer.
74+ MultiThreadedMessageLoop *bool `mapstructure:"multi_threaded_message_loop" toml:"multi_threaded_message_loop" yaml:"multi_threaded_message_loop"` //nolint:lll
75+ // ManualPumpIntervalMs is the polling interval (ms) for CefDoMessageLoopWork
76+ // when MultiThreadedMessageLoop is false. Default: 10.
77+ ManualPumpIntervalMs int64 `mapstructure:"manual_pump_interval_ms" toml:"manual_pump_interval_ms" yaml:"manual_pump_interval_ms"`
78+}
79+
80+// CEFMultiThreadedMessageLoop returns the effective value with default true.
81+func (c CEFEngineConfig) CEFMultiThreadedMessageLoop() bool {
82+ if c.MultiThreadedMessageLoop != nil {
83+ return *c.MultiThreadedMessageLoop
84+ }
85+ return true
86+}
87+
88+// CEFManualPumpIntervalMs returns the effective value with default 10.
89+func (c CEFEngineConfig) CEFManualPumpIntervalMs() int64 {
90+ if c.ManualPumpIntervalMs > 0 {
91+ return c.ManualPumpIntervalMs
92+ }
93+ return 10
7294 }
7395
7496 // PerformanceConfigFromEngine constructs a PerformanceConfig from the
@@ -69,6 +69,28 @@ type CEFEngineConfig struct {
69 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.69 // WindowlessFrameRate is the maximum frame rate for off-screen rendering.
70 // Default: 30. Higher values increase CPU usage.70 // Default: 30. Higher values increase CPU usage.
71 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`71 WindowlessFrameRate int32 `mapstructure:"windowless_frame_rate" toml:"windowless_frame_rate" yaml:"windowless_frame_rate"`
72+ // MultiThreadedMessageLoop lets CEF run its own message loop thread.
73+ // Default: true. When false, the host drives the pump via a manual timer.
74+ MultiThreadedMessageLoop *bool `mapstructure:"multi_threaded_message_loop" toml:"multi_threaded_message_loop" yaml:"multi_threaded_message_loop"` //nolint:lll
75+ // ManualPumpIntervalMs is the polling interval (ms) for CefDoMessageLoopWork
76+ // when MultiThreadedMessageLoop is false. Default: 10.
77+ ManualPumpIntervalMs int64 `mapstructure:"manual_pump_interval_ms" toml:"manual_pump_interval_ms" yaml:"manual_pump_interval_ms"`
78+}
79+
80+// CEFMultiThreadedMessageLoop returns the effective value with default true.
81+func (c CEFEngineConfig) CEFMultiThreadedMessageLoop() bool {
82+ if c.MultiThreadedMessageLoop != nil {
83+ return *c.MultiThreadedMessageLoop
84+ }
85+ return true
86+}
87+
88+// CEFManualPumpIntervalMs returns the effective value with default 10.
89+func (c CEFEngineConfig) CEFManualPumpIntervalMs() int64 {
90+ if c.ManualPumpIntervalMs > 0 {
91+ return c.ManualPumpIntervalMs
92+ }
93+ return 10
72 }94 }
73 95
74 // PerformanceConfigFromEngine constructs a PerformanceConfig from the96 // PerformanceConfigFromEngine constructs a PerformanceConfig from the
M internal/ui/app.go +1 -16
@@ -6,7 +6,6 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9- "strconv"
109 "strings"
1110 "sync"
1211 "unsafe"
@@ -268,11 +267,7 @@ func (a *App) onActivate(ctx context.Context) {
268267 log := logging.FromContext(ctx)
269268 log.Debug().Msg("GTK application activated")
270269
271- if cefMultiThreadedLoopEnabled() {
272- log.Warn().Msg("skipping GTK color scheme preference while testing CEF multi-threaded loop")
273- } else {
274- a.applyGTKColorSchemePreference(ctx)
275- }
270+ a.applyGTKColorSchemePreference(ctx)
276271 // Configure pool background color early (prevents white flash), but avoid
277272 // synchronous prewarming that delays the first navigation on cold start.
278273 a.setupPoolBackgroundColor(ctx)
@@ -475,16 +470,6 @@ func (a *App) installCrashReportNotifier(ctx context.Context) {
475470 }
476471 }
477472
478-func cefMultiThreadedLoopEnabled() bool {
479- value, ok := os.LookupEnv("DUMBER_CEF_MULTI_THREADED_MESSAGE_LOOP")
480- if !ok || value == "" {
481- return false
482- }
483-
484- enabled, err := strconv.ParseBool(value)
485- return err == nil && enabled
486-}
487-
488473 func (a *App) initFocusAndBorderOverlay() {
489474 // Initialize focus manager for geometric navigation.
490475 a.focusMgr = focus.NewManager(a.panesUC)
@@ -6,7 +6,6 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9- "strconv"
10 "strings"9 "strings"
11 "sync"10 "sync"
12 "unsafe"11 "unsafe"
@@ -268,11 +267,7 @@ func (a *App) onActivate(ctx context.Context) {
268 log := logging.FromContext(ctx)267 log := logging.FromContext(ctx)
269 log.Debug().Msg("GTK application activated")268 log.Debug().Msg("GTK application activated")
270 269
271- if cefMultiThreadedLoopEnabled() {270+ a.applyGTKColorSchemePreference(ctx)
272- log.Warn().Msg("skipping GTK color scheme preference while testing CEF multi-threaded loop")
273- } else {
274- a.applyGTKColorSchemePreference(ctx)
275- }
276 // Configure pool background color early (prevents white flash), but avoid271 // Configure pool background color early (prevents white flash), but avoid
277 // synchronous prewarming that delays the first navigation on cold start.272 // synchronous prewarming that delays the first navigation on cold start.
278 a.setupPoolBackgroundColor(ctx)273 a.setupPoolBackgroundColor(ctx)
@@ -475,16 +470,6 @@ func (a *App) installCrashReportNotifier(ctx context.Context) {
475 }470 }
476 }471 }
477 472
478-func cefMultiThreadedLoopEnabled() bool {
479- value, ok := os.LookupEnv("DUMBER_CEF_MULTI_THREADED_MESSAGE_LOOP")
480- if !ok || value == "" {
481- return false
482- }
483-
484- enabled, err := strconv.ParseBool(value)
485- return err == nil && enabled
486-}
487-
488 func (a *App) initFocusAndBorderOverlay() {473 func (a *App) initFocusAndBorderOverlay() {
489 // Initialize focus manager for geometric navigation.474 // Initialize focus manager for geometric navigation.
490 a.focusMgr = focus.NewManager(a.panesUC)475 a.focusMgr = focus.NewManager(a.panesUC)

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