detached 56e9f5a

feat(cef): fix GPU subprocess, message pump, and browser lifecycle

Resolve multiple interrelated issues preventing the CEF engine from
rendering content:

- Detect CEF subprocesses via --type= arg instead of env var, so
  renderer/utility subprocesses do not fall through into full app init
- Add dedicated cef-helper binary for CEF subprocesses to avoid Go
  runtime state corruption in forked/execd processes
- Use --in-process-gpu (Go runtime FDs break GPU subprocess) and
  --no-zygote (Go runtime does not survive fork) flags
- Temporarily inject Chromium flags into os.Args for cef_initialize,
  then restore them so GTK does not receive unknown flags
- Move CEF message pump from immediate glib.IdleAdd to
  glib.TimeoutAdd(33ms) started in OnToolkitReady, with reentrancy
  protection matching CEF reference implementation
- Defer browser creation until GtkGLArea has non-zero size, wire
  GTK resize to BrowserHost.WasResized()
- Queue pending URI when LoadURI is called before browser exists,
  replay in OnAfterCreated
- Add fallback 1x1 rect in GetViewRect and nil-staging guard in
  handlePaint to prevent panics during early CEF callbacks
- Cap consecutive renderer crash notifications to prevent infinite
  crash/redirect loops
- Map CEF OnLoadStart to port.LoadCommitted (not LoadStarted) since
  CEF fires this after navigation commit, fixing the reveal/spinner
  lifecycle
commit
56e9f5a22a5124812726f17898f7f8421703153b
author
brice · · 2026-03-19 22:11 UTC
committer
brice · · 2026-03-28 04:36 UTC
parent
c04ed5c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +426 -34
M Makefile +1 -0
@@ -45,6 +45,7 @@ build-quick: ## Build without frontend (faster for backend development)
4545 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."
4646 @mkdir -p $(DIST_DIR)
4747 CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
48+ CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper
4849 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
4950
5051 # Development targets
@@ -45,6 +45,7 @@ build-quick: ## Build without frontend (faster for backend development)
45 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."45 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."
46 @mkdir -p $(DIST_DIR)46 @mkdir -p $(DIST_DIR)
47 CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)47 CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
48+ CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper
48 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"49 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
49 50
50 # Development targets51 # Development targets
A cmd/cef-helper/main.go +32 -0
@@ -0,0 +1,32 @@
1+// cef-helper is a minimal subprocess binary for CEF.
2+//
3+// CEF spawns renderer, GPU, and utility subprocesses by re-executing a binary
4+// with --type=renderer/gpu-process/utility. In Go, the main dumber binary has
5+// heavy initialization (config, DB, GTK) that corrupts process state before
6+// CEF gets control. This dedicated helper binary calls cef_execute_process as
7+// the very first thing and exits, avoiding that problem.
8+//
9+// Usage: set Settings.BrowserSubprocessPath to the path of this binary.
10+package main
11+
12+import (
13+ "os"
14+ "runtime"
15+
16+ cef "github.com/bnema/purego-cef/cef"
17+)
18+
19+func main() {
20+ // Pin to the main OS thread — CEF requires it.
21+ runtime.LockOSThread()
22+
23+ // cef_execute_process will handle the subprocess lifecycle and call
24+ // os.Exit internally if this is a valid CEF subprocess invocation.
25+ // If it returns (code < 0), this is not a subprocess — shouldn't
26+ // happen in normal operation since CEF only launches this binary
27+ // with --type= flags.
28+ cef.MaybeExitSubprocess()
29+
30+ // If we get here, something went wrong.
31+ os.Exit(1)
32+}
@@ -0,0 +1,32 @@
1+// cef-helper is a minimal subprocess binary for CEF.
2+//
3+// CEF spawns renderer, GPU, and utility subprocesses by re-executing a binary
4+// with --type=renderer/gpu-process/utility. In Go, the main dumber binary has
5+// heavy initialization (config, DB, GTK) that corrupts process state before
6+// CEF gets control. This dedicated helper binary calls cef_execute_process as
7+// the very first thing and exits, avoiding that problem.
8+//
9+// Usage: set Settings.BrowserSubprocessPath to the path of this binary.
10+package main
11+
12+import (
13+ "os"
14+ "runtime"
15+
16+ cef "github.com/bnema/purego-cef/cef"
17+)
18+
19+func main() {
20+ // Pin to the main OS thread — CEF requires it.
21+ runtime.LockOSThread()
22+
23+ // cef_execute_process will handle the subprocess lifecycle and call
24+ // os.Exit internally if this is a valid CEF subprocess invocation.
25+ // If it returns (code < 0), this is not a subprocess — shouldn't
26+ // happen in normal operation since CEF only launches this binary
27+ // with --type= flags.
28+ cef.MaybeExitSubprocess()
29+
30+ // If we get here, something went wrong.
31+ os.Exit(1)
32+}
M cmd/dumber/main.go +21 -3
@@ -55,12 +55,19 @@ var restoreSessionID string
5555 func main() {
5656 enableCrashForensics()
5757
58+ // Debug: dump args at entry to identify subprocess leakage.
59+ fmt.Fprintf(os.Stderr, "cef-debug: main() entry, pid=%d args=%v\n", os.Getpid(), os.Args)
60+
5861 // CEF subprocess handling: when CEF re-launches the binary with
5962 // --type=renderer/gpu/etc, we must call ExecuteProcess before anything
60- // else (Cobra, config, arg stripping). This only runs when the engine
61- // is set to "cef" and exits immediately for subprocess invocations.
62- if os.Getenv("DUMBER_ENGINE") == "cef" {
63+ // else (Cobra, config, arg stripping). We detect subprocesses by the
64+ // presence of --type= in os.Args rather than checking the engine env
65+ // var, because subprocesses may not inherit the environment and the
66+ // engine can also be selected via config.
67+ if isCEFSubprocess() {
68+ fmt.Fprintf(os.Stderr, "cef: detected subprocess, args=%v\n", os.Args)
6369 cef.MaybeExitSubprocess()
70+ fmt.Fprintf(os.Stderr, "cef: MaybeExitSubprocess returned (browser process)\n")
6471 }
6572
6673 // Run GUI mode for browse command
@@ -507,6 +514,17 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
507514 }
508515 }
509516
517+// isCEFSubprocess returns true if os.Args contains a --type= flag, indicating
518+// this process was spawned by CEF as a renderer, GPU, or utility subprocess.
519+func isCEFSubprocess() bool {
520+ for _, arg := range os.Args {
521+ if len(arg) > 7 && arg[:7] == "--type=" {
522+ return true
523+ }
524+ }
525+ return false
526+}
527+
510528 func buildUIDependencies(
511529 ctx context.Context,
512530 cfg *config.Config,
@@ -55,12 +55,19 @@ var restoreSessionID string
55 func main() {55 func main() {
56 enableCrashForensics()56 enableCrashForensics()
57 57
58+ // Debug: dump args at entry to identify subprocess leakage.
59+ fmt.Fprintf(os.Stderr, "cef-debug: main() entry, pid=%d args=%v\n", os.Getpid(), os.Args)
60+
58 // CEF subprocess handling: when CEF re-launches the binary with61 // CEF subprocess handling: when CEF re-launches the binary with
59 // --type=renderer/gpu/etc, we must call ExecuteProcess before anything62 // --type=renderer/gpu/etc, we must call ExecuteProcess before anything
60- // else (Cobra, config, arg stripping). This only runs when the engine63+ // else (Cobra, config, arg stripping). We detect subprocesses by the
61- // is set to "cef" and exits immediately for subprocess invocations.64+ // presence of --type= in os.Args rather than checking the engine env
62- if os.Getenv("DUMBER_ENGINE") == "cef" {65+ // var, because subprocesses may not inherit the environment and the
66+ // engine can also be selected via config.
67+ if isCEFSubprocess() {
68+ fmt.Fprintf(os.Stderr, "cef: detected subprocess, args=%v\n", os.Args)
63 cef.MaybeExitSubprocess()69 cef.MaybeExitSubprocess()
70+ fmt.Fprintf(os.Stderr, "cef: MaybeExitSubprocess returned (browser process)\n")
64 }71 }
65 72
66 // Run GUI mode for browse command73 // Run GUI mode for browse command
@@ -507,6 +514,17 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
507 }514 }
508 }515 }
509 516
517+// isCEFSubprocess returns true if os.Args contains a --type= flag, indicating
518+// this process was spawned by CEF as a renderer, GPU, or utility subprocess.
519+func isCEFSubprocess() bool {
520+ for _, arg := range os.Args {
521+ if len(arg) > 7 && arg[:7] == "--type=" {
522+ return true
523+ }
524+ }
525+ return false
526+}
527+
510 func buildUIDependencies(528 func buildUIDependencies(
511 ctx context.Context,529 ctx context.Context,
512 cfg *config.Config,530 cfg *config.Config,
M internal/infrastructure/cef/engine.go +100 -1
@@ -4,6 +4,7 @@ import (
44 "context"
55
66 purecef "github.com/bnema/purego-cef/cef"
7+ "github.com/jwijenbergh/puregotk/v4/glib"
78 "github.com/rs/zerolog"
89
910 "github.com/bnema/dumber/internal/application/port"
@@ -12,6 +13,11 @@ import (
1213 // Compile-time interface check.
1314 var _ port.Engine = (*Engine)(nil)
1415
16+// cefPumpIntervalMS matches CEF's own kMaxTimerDelay (1000/30 ≈ 33ms) used in
17+// the reference external message pump implementation. This is a conservative
18+// fallback; Phase 2 will use OnScheduleMessagePumpWork for precise scheduling.
19+const cefPumpIntervalMS uint = 33
20+
1521 // Engine implements port.Engine for the CEF browser backend.
1622 // It manages the CEF lifecycle and provides access to all engine subsystems.
1723 type Engine struct {
@@ -20,6 +26,19 @@ type Engine struct {
2026 factory *WebViewFactory
2127 pool *WebViewPool
2228 logger zerolog.Logger
29+
30+ // Message pump state. The pump is a repeating glib.TimeoutAdd source
31+ // started from OnToolkitReady (not before GTK init).
32+ pumpCB glib.SourceFunc
33+ pumpSourceID uint
34+ pumpStarted bool
35+ pumpClosing bool
36+
37+ // Reentrancy guard: DoMessageLoopWork can trigger callbacks that
38+ // re-enter the pump. The reference implementation detects this and
39+ // schedules an immediate follow-up instead of recursing.
40+ pumpActive bool
41+ pumpReentry bool
2342 }
2443
2544 // Factory returns the WebViewFactory for creating new WebView instances.
@@ -59,6 +78,7 @@ func (e *Engine) InternalSchemePath() string {
5978
6079 // Close releases all resources held by the engine.
6180 func (e *Engine) Close() error {
81+ e.stopMessagePump()
6282 e.pool.Close()
6383 purecef.Shutdown()
6484 return nil
@@ -79,8 +99,12 @@ func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.Download
7999 return nil
80100 }
81101
82-// OnToolkitReady is called after the UI toolkit has initialized (Phase 1 stub).
102+// OnToolkitReady is called after the UI toolkit has initialized.
103+// This is the earliest safe point to start the CEF message pump — GTK and
104+// libadwaita are fully initialized, and the GLib main context is running.
83105 func (e *Engine) OnToolkitReady(_ context.Context) error {
106+ e.logger.Debug().Msg("cef: OnToolkitReady called, starting message pump")
107+ e.startMessagePump()
84108 return nil
85109 }
86110
@@ -98,3 +122,78 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate)
98122 func (e *Engine) SetHandlerContext(ctx context.Context) {
99123 e.ctx = ctx
100124 }
125+
126+// ---------------------------------------------------------------------------
127+// CEF message pump (fixed-interval fallback, Phase 1)
128+// ---------------------------------------------------------------------------
129+
130+func (e *Engine) startMessagePump() {
131+ if e.pumpStarted || e.pumpClosing {
132+ return
133+ }
134+
135+ e.pumpCB = glib.SourceFunc(func(_ uintptr) bool {
136+ if e.pumpClosing {
137+ e.pumpSourceID = 0
138+ return false // remove the source
139+ }
140+ e.performMessageLoopWork()
141+ return !e.pumpClosing
142+ })
143+
144+ e.pumpSourceID = glib.TimeoutAdd(cefPumpIntervalMS, &e.pumpCB, 0)
145+ if e.pumpSourceID == 0 {
146+ e.logger.Error().Msg("cef: failed to install message pump timer")
147+ return
148+ }
149+
150+ e.pumpStarted = true
151+ e.logger.Debug().
152+ Uint("interval_ms", cefPumpIntervalMS).
153+ Msg("cef: started message pump")
154+}
155+
156+func (e *Engine) stopMessagePump() {
157+ e.pumpClosing = true
158+ if e.pumpSourceID != 0 {
159+ glib.SourceRemove(e.pumpSourceID)
160+ e.pumpSourceID = 0
161+ }
162+}
163+
164+// performMessageLoopWork calls DoMessageLoopWork with reentrancy protection.
165+// Matches the pattern from CEF's main_message_loop_external_pump.cc:
166+// if we're already inside DoMessageLoopWork, just flag reentrancy and return.
167+var pumpCallCount uint64
168+
169+func (e *Engine) performMessageLoopWork() {
170+ if e.pumpActive {
171+ e.logger.Warn().Msg("cef: pump reentrancy detected, deferring")
172+ e.pumpReentry = true
173+ return
174+ }
175+
176+ pumpCallCount++
177+ if pumpCallCount <= 5 {
178+ e.logger.Debug().Uint64("call", pumpCallCount).Msg("cef: DoMessageLoopWork enter")
179+ }
180+
181+ e.pumpActive = true
182+ e.pumpReentry = false
183+ purecef.DoMessageLoopWork()
184+ e.pumpActive = false
185+
186+ if pumpCallCount <= 5 {
187+ e.logger.Debug().Uint64("call", pumpCallCount).Bool("reentry", e.pumpReentry).Msg("cef: DoMessageLoopWork exit")
188+ }
189+
190+ // If CEF re-entered during the call, do one more pass immediately.
191+ // Cap at one extra pass to avoid unbounded recursion.
192+ if e.pumpReentry {
193+ e.logger.Debug().Msg("cef: pump reentry follow-up pass")
194+ e.pumpReentry = false
195+ e.pumpActive = true
196+ purecef.DoMessageLoopWork()
197+ e.pumpActive = false
198+ }
199+}
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 5
6 purecef "github.com/bnema/purego-cef/cef"6 purecef "github.com/bnema/purego-cef/cef"
7+ "github.com/jwijenbergh/puregotk/v4/glib"
7 "github.com/rs/zerolog"8 "github.com/rs/zerolog"
8 9
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
@@ -12,6 +13,11 @@ import (
12 // Compile-time interface check.13 // Compile-time interface check.
13 var _ port.Engine = (*Engine)(nil)14 var _ port.Engine = (*Engine)(nil)
14 15
16+// cefPumpIntervalMS matches CEF's own kMaxTimerDelay (1000/30 ≈ 33ms) used in
17+// the reference external message pump implementation. This is a conservative
18+// fallback; Phase 2 will use OnScheduleMessagePumpWork for precise scheduling.
19+const cefPumpIntervalMS uint = 33
20+
15 // Engine implements port.Engine for the CEF browser backend.21 // Engine implements port.Engine for the CEF browser backend.
16 // It manages the CEF lifecycle and provides access to all engine subsystems.22 // It manages the CEF lifecycle and provides access to all engine subsystems.
17 type Engine struct {23 type Engine struct {
@@ -20,6 +26,19 @@ type Engine struct {
20 factory *WebViewFactory26 factory *WebViewFactory
21 pool *WebViewPool27 pool *WebViewPool
22 logger zerolog.Logger28 logger zerolog.Logger
29+
30+ // Message pump state. The pump is a repeating glib.TimeoutAdd source
31+ // started from OnToolkitReady (not before GTK init).
32+ pumpCB glib.SourceFunc
33+ pumpSourceID uint
34+ pumpStarted bool
35+ pumpClosing bool
36+
37+ // Reentrancy guard: DoMessageLoopWork can trigger callbacks that
38+ // re-enter the pump. The reference implementation detects this and
39+ // schedules an immediate follow-up instead of recursing.
40+ pumpActive bool
41+ pumpReentry bool
23 }42 }
24 43
25 // Factory returns the WebViewFactory for creating new WebView instances.44 // Factory returns the WebViewFactory for creating new WebView instances.
@@ -59,6 +78,7 @@ func (e *Engine) InternalSchemePath() string {
59 78
60 // Close releases all resources held by the engine.79 // Close releases all resources held by the engine.
61 func (e *Engine) Close() error {80 func (e *Engine) Close() error {
81+ e.stopMessagePump()
62 e.pool.Close()82 e.pool.Close()
63 purecef.Shutdown()83 purecef.Shutdown()
64 return nil84 return nil
@@ -79,8 +99,12 @@ func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.Download
79 return nil99 return nil
80 }100 }
81 101
82-// OnToolkitReady is called after the UI toolkit has initialized (Phase 1 stub).102+// OnToolkitReady is called after the UI toolkit has initialized.
103+// This is the earliest safe point to start the CEF message pump — GTK and
104+// libadwaita are fully initialized, and the GLib main context is running.
83 func (e *Engine) OnToolkitReady(_ context.Context) error {105 func (e *Engine) OnToolkitReady(_ context.Context) error {
106+ e.logger.Debug().Msg("cef: OnToolkitReady called, starting message pump")
107+ e.startMessagePump()
84 return nil108 return nil
85 }109 }
86 110
@@ -98,3 +122,78 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate)
98 func (e *Engine) SetHandlerContext(ctx context.Context) {122 func (e *Engine) SetHandlerContext(ctx context.Context) {
99 e.ctx = ctx123 e.ctx = ctx
100 }124 }
125+
126+// ---------------------------------------------------------------------------
127+// CEF message pump (fixed-interval fallback, Phase 1)
128+// ---------------------------------------------------------------------------
129+
130+func (e *Engine) startMessagePump() {
131+ if e.pumpStarted || e.pumpClosing {
132+ return
133+ }
134+
135+ e.pumpCB = glib.SourceFunc(func(_ uintptr) bool {
136+ if e.pumpClosing {
137+ e.pumpSourceID = 0
138+ return false // remove the source
139+ }
140+ e.performMessageLoopWork()
141+ return !e.pumpClosing
142+ })
143+
144+ e.pumpSourceID = glib.TimeoutAdd(cefPumpIntervalMS, &e.pumpCB, 0)
145+ if e.pumpSourceID == 0 {
146+ e.logger.Error().Msg("cef: failed to install message pump timer")
147+ return
148+ }
149+
150+ e.pumpStarted = true
151+ e.logger.Debug().
152+ Uint("interval_ms", cefPumpIntervalMS).
153+ Msg("cef: started message pump")
154+}
155+
156+func (e *Engine) stopMessagePump() {
157+ e.pumpClosing = true
158+ if e.pumpSourceID != 0 {
159+ glib.SourceRemove(e.pumpSourceID)
160+ e.pumpSourceID = 0
161+ }
162+}
163+
164+// performMessageLoopWork calls DoMessageLoopWork with reentrancy protection.
165+// Matches the pattern from CEF's main_message_loop_external_pump.cc:
166+// if we're already inside DoMessageLoopWork, just flag reentrancy and return.
167+var pumpCallCount uint64
168+
169+func (e *Engine) performMessageLoopWork() {
170+ if e.pumpActive {
171+ e.logger.Warn().Msg("cef: pump reentrancy detected, deferring")
172+ e.pumpReentry = true
173+ return
174+ }
175+
176+ pumpCallCount++
177+ if pumpCallCount <= 5 {
178+ e.logger.Debug().Uint64("call", pumpCallCount).Msg("cef: DoMessageLoopWork enter")
179+ }
180+
181+ e.pumpActive = true
182+ e.pumpReentry = false
183+ purecef.DoMessageLoopWork()
184+ e.pumpActive = false
185+
186+ if pumpCallCount <= 5 {
187+ e.logger.Debug().Uint64("call", pumpCallCount).Bool("reentry", e.pumpReentry).Msg("cef: DoMessageLoopWork exit")
188+ }
189+
190+ // If CEF re-entered during the call, do one more pass immediately.
191+ // Cap at one extra pass to avoid unbounded recursion.
192+ if e.pumpReentry {
193+ e.logger.Debug().Msg("cef: pump reentry follow-up pass")
194+ e.pumpReentry = false
195+ e.pumpActive = true
196+ purecef.DoMessageLoopWork()
197+ e.pumpActive = false
198+ }
199+}
M internal/infrastructure/cef/engine_init.go +65 -13
@@ -3,9 +3,10 @@ package cef
33 import (
44 "context"
55 "fmt"
6+ "os"
7+ "path/filepath"
68
79 purecef "github.com/bnema/purego-cef/cef"
8- "github.com/jwijenbergh/puregotk/v4/glib"
910 "github.com/rs/zerolog"
1011
1112 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -25,35 +26,86 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, logger zerolog.L
2526 // NOTE: MaybeExitSubprocess is called in main() before Cobra, so that
2627 // CEF subprocess args (--type=renderer) are handled before arg stripping.
2728
29+ // Use a dedicated helper binary for CEF subprocesses (renderer, GPU,
30+ // utility). The main dumber binary has heavy Go initialization that
31+ // corrupts process state before CEF gets control. The helper binary
32+ // calls cef_execute_process immediately and exits cleanly.
33+ helperPath := findHelperBinary()
34+ if helperPath != "" {
35+ settings.BrowserSubprocessPath = helperPath
36+ logger.Debug().Str("path", helperPath).Msg("cef: using subprocess helper")
37+ } else {
38+ logger.Warn().Msg("cef: subprocess helper not found, falling back to main binary")
39+ }
40+
41+ // Chromium flags injected temporarily into os.Args for cef_initialize,
42+ // then restored so GTK doesn't choke on unknown flags.
43+ //
44+ // --in-process-gpu: GPU subprocess fails (error_code=1002) even with the
45+ // helper because the Go runtime creates FDs the GPU process doesn't expect.
46+ // --no-zygote: Disable the zygote process which uses fork(). Go's runtime
47+ // does not survive fork cleanly, causing renderer crashes. Without zygote,
48+ // CEF launches each subprocess as a fresh exec of the helper binary.
49+ savedArgs := os.Args
50+ os.Args = appendIfMissing(os.Args, "--in-process-gpu")
51+ os.Args = appendIfMissing(os.Args, "--no-zygote")
52+
53+ logger.Debug().Msg("cef: calling Init")
2854 if err := purecef.Init(settings); err != nil {
55+ os.Args = savedArgs
2956 return nil, fmt.Errorf("cef.Init: %w", err)
3057 }
58+ os.Args = savedArgs
59+ logger.Debug().Msg("cef: Init returned OK")
3160
32- // 2. Pump CEF messages via the glib idle loop so CEF work executes on the
33- // GTK main thread alongside normal UI processing.
34- cb := glib.SourceFunc(func(_ uintptr) bool {
35- purecef.DoMessageLoopWork()
36- return true // keep firing
37- })
38- glib.IdleAdd(&cb, 0)
39-
40- // 3. Load GL.
61+ // 2. Load GL.
4162 gl, err := newGLLoader()
4263 if err != nil {
4364 purecef.Shutdown()
4465 return nil, fmt.Errorf("GL loader: %w", err)
4566 }
4667
47- // 4. Create factory + pool.
68+ // 3. Create factory + pool.
4869 scale := int32(1) // TODO: detect from GDK
4970 factory := newWebViewFactory(gl, scale)
5071 pool := newWebViewPool(factory)
5172
52- return &Engine{
73+ // 4. Build the engine. The CEF message pump is started later from
74+ // OnToolkitReady, after GTK/libadwaita have fully initialized.
75+ // Starting it here (before the GTK main loop) causes a SIGSEGV with
76+ // --in-process-gpu because Chromium GPU bootstrap runs too early.
77+ eng := &Engine{
5378 ctx: ctx,
5479 gl: gl,
5580 factory: factory,
5681 pool: pool,
5782 logger: logger,
58- }, nil
83+ }
84+
85+ return eng, nil
86+}
87+
88+// appendIfMissing appends flag to args if it's not already present.
89+func appendIfMissing(args []string, flag string) []string {
90+ for _, a := range args {
91+ if a == flag {
92+ return args
93+ }
94+ }
95+ return append(args, flag)
96+}
97+
98+// findHelperBinary looks for the cef-helper binary next to the running
99+// executable. Returns empty string if not found.
100+func findHelperBinary() string {
101+ exe, err := os.Executable()
102+ if err != nil {
103+ return ""
104+ }
105+ dir := filepath.Dir(exe)
106+ helper := filepath.Join(dir, "cef-helper")
107+ if _, err := os.Stat(helper); err == nil {
108+ return helper
109+ }
110+ return ""
59111 }
@@ -3,9 +3,10 @@ package cef
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "os"
7+ "path/filepath"
6 8
7 purecef "github.com/bnema/purego-cef/cef"9 purecef "github.com/bnema/purego-cef/cef"
8- "github.com/jwijenbergh/puregotk/v4/glib"
9 "github.com/rs/zerolog"10 "github.com/rs/zerolog"
10 11
11 "github.com/bnema/dumber/internal/infrastructure/config"12 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -25,35 +26,86 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, logger zerolog.L
25 // NOTE: MaybeExitSubprocess is called in main() before Cobra, so that26 // NOTE: MaybeExitSubprocess is called in main() before Cobra, so that
26 // CEF subprocess args (--type=renderer) are handled before arg stripping.27 // CEF subprocess args (--type=renderer) are handled before arg stripping.
27 28
29+ // Use a dedicated helper binary for CEF subprocesses (renderer, GPU,
30+ // utility). The main dumber binary has heavy Go initialization that
31+ // corrupts process state before CEF gets control. The helper binary
32+ // calls cef_execute_process immediately and exits cleanly.
33+ helperPath := findHelperBinary()
34+ if helperPath != "" {
35+ settings.BrowserSubprocessPath = helperPath
36+ logger.Debug().Str("path", helperPath).Msg("cef: using subprocess helper")
37+ } else {
38+ logger.Warn().Msg("cef: subprocess helper not found, falling back to main binary")
39+ }
40+
41+ // Chromium flags injected temporarily into os.Args for cef_initialize,
42+ // then restored so GTK doesn't choke on unknown flags.
43+ //
44+ // --in-process-gpu: GPU subprocess fails (error_code=1002) even with the
45+ // helper because the Go runtime creates FDs the GPU process doesn't expect.
46+ // --no-zygote: Disable the zygote process which uses fork(). Go's runtime
47+ // does not survive fork cleanly, causing renderer crashes. Without zygote,
48+ // CEF launches each subprocess as a fresh exec of the helper binary.
49+ savedArgs := os.Args
50+ os.Args = appendIfMissing(os.Args, "--in-process-gpu")
51+ os.Args = appendIfMissing(os.Args, "--no-zygote")
52+
53+ logger.Debug().Msg("cef: calling Init")
28 if err := purecef.Init(settings); err != nil {54 if err := purecef.Init(settings); err != nil {
55+ os.Args = savedArgs
29 return nil, fmt.Errorf("cef.Init: %w", err)56 return nil, fmt.Errorf("cef.Init: %w", err)
30 }57 }
58+ os.Args = savedArgs
59+ logger.Debug().Msg("cef: Init returned OK")
31 60
32- // 2. Pump CEF messages via the glib idle loop so CEF work executes on the61+ // 2. Load GL.
33- // GTK main thread alongside normal UI processing.
34- cb := glib.SourceFunc(func(_ uintptr) bool {
35- purecef.DoMessageLoopWork()
36- return true // keep firing
37- })
38- glib.IdleAdd(&cb, 0)
39-
40- // 3. Load GL.
41 gl, err := newGLLoader()62 gl, err := newGLLoader()
42 if err != nil {63 if err != nil {
43 purecef.Shutdown()64 purecef.Shutdown()
44 return nil, fmt.Errorf("GL loader: %w", err)65 return nil, fmt.Errorf("GL loader: %w", err)
45 }66 }
46 67
47- // 4. Create factory + pool.68+ // 3. Create factory + pool.
48 scale := int32(1) // TODO: detect from GDK69 scale := int32(1) // TODO: detect from GDK
49 factory := newWebViewFactory(gl, scale)70 factory := newWebViewFactory(gl, scale)
50 pool := newWebViewPool(factory)71 pool := newWebViewPool(factory)
51 72
52- return &Engine{73+ // 4. Build the engine. The CEF message pump is started later from
74+ // OnToolkitReady, after GTK/libadwaita have fully initialized.
75+ // Starting it here (before the GTK main loop) causes a SIGSEGV with
76+ // --in-process-gpu because Chromium GPU bootstrap runs too early.
77+ eng := &Engine{
53 ctx: ctx,78 ctx: ctx,
54 gl: gl,79 gl: gl,
55 factory: factory,80 factory: factory,
56 pool: pool,81 pool: pool,
57 logger: logger,82 logger: logger,
58- }, nil83+ }
84+
85+ return eng, nil
86+}
87+
88+// appendIfMissing appends flag to args if it's not already present.
89+func appendIfMissing(args []string, flag string) []string {
90+ for _, a := range args {
91+ if a == flag {
92+ return args
93+ }
94+ }
95+ return append(args, flag)
96+}
97+
98+// findHelperBinary looks for the cef-helper binary next to the running
99+// executable. Returns empty string if not found.
100+func findHelperBinary() string {
101+ exe, err := os.Executable()
102+ if err != nil {
103+ return ""
104+ }
105+ dir := filepath.Dir(exe)
106+ helper := filepath.Join(dir, "cef-helper")
107+ if _, err := os.Stat(helper); err == nil {
108+ return helper
109+ }
110+ return ""
59 }111 }
M internal/infrastructure/cef/factory.go +30 -10
@@ -66,16 +66,36 @@ func (f *WebViewFactory) Create(_ context.Context) (port.WebView, error) {
6666 settings := purecef.DefaultBrowserSettings()
6767 settings.WindowlessFrameRate = 60
6868
69- // Create the browser asynchronously. The browser will be nil until
70- // OnAfterCreated fires in the LifeSpanHandler.
71- purecef.BrowserHostCreateBrowser(
72- &windowInfo,
73- client,
74- "about:blank",
75- &settings,
76- nil, // extraInfo
77- nil, // requestContext
78- )
69+ // Defer browser creation until the GL area has a non-zero size.
70+ // CEF requires GetViewRect to return a non-empty rect, but the GL area
71+ // is not yet realized at this point.
72+ wv.pendingCreate = &pendingBrowserCreate{
73+ windowInfo: &windowInfo,
74+ client: client,
75+ settings: &settings,
76+ }
77+
78+ // When the GL area gets its first non-zero size, create the browser.
79+ pipeline.onFirstResize = func(_, _ int32) {
80+ if pc := wv.pendingCreate; pc != nil {
81+ wv.pendingCreate = nil
82+ purecef.BrowserHostCreateBrowser(
83+ pc.windowInfo,
84+ pc.client,
85+ "about:blank",
86+ pc.settings,
87+ nil, // extraInfo
88+ nil, // requestContext
89+ )
90+ }
91+ }
92+
93+ // On subsequent resizes, notify CEF so it re-queries GetViewRect.
94+ pipeline.onResizeCB = func(_, _ int32) {
95+ if wv.host != nil {
96+ wv.host.WasResized()
97+ }
98+ }
7999
80100 return wv, nil
81101 }
@@ -66,16 +66,36 @@ func (f *WebViewFactory) Create(_ context.Context) (port.WebView, error) {
66 settings := purecef.DefaultBrowserSettings()66 settings := purecef.DefaultBrowserSettings()
67 settings.WindowlessFrameRate = 6067 settings.WindowlessFrameRate = 60
68 68
69- // Create the browser asynchronously. The browser will be nil until69+ // Defer browser creation until the GL area has a non-zero size.
70- // OnAfterCreated fires in the LifeSpanHandler.70+ // CEF requires GetViewRect to return a non-empty rect, but the GL area
71- purecef.BrowserHostCreateBrowser(71+ // is not yet realized at this point.
72- &windowInfo,72+ wv.pendingCreate = &pendingBrowserCreate{
73- client,73+ windowInfo: &windowInfo,
74- "about:blank",74+ client: client,
75- &settings,75+ settings: &settings,
76- nil, // extraInfo76+ }
77- nil, // requestContext77+
78- )78+ // When the GL area gets its first non-zero size, create the browser.
79+ pipeline.onFirstResize = func(_, _ int32) {
80+ if pc := wv.pendingCreate; pc != nil {
81+ wv.pendingCreate = nil
82+ purecef.BrowserHostCreateBrowser(
83+ pc.windowInfo,
84+ pc.client,
85+ "about:blank",
86+ pc.settings,
87+ nil, // extraInfo
88+ nil, // requestContext
89+ )
90+ }
91+ }
92+
93+ // On subsequent resizes, notify CEF so it re-queries GetViewRect.
94+ pipeline.onResizeCB = func(_, _ int32) {
95+ if wv.host != nil {
96+ wv.host.WasResized()
97+ }
98+ }
79 99
80 return wv, nil100 return wv, nil
81 }101 }
M internal/infrastructure/cef/render_pipeline.go +33 -3
@@ -1,6 +1,8 @@
11 package cef
22
33 import (
4+ "fmt"
5+ "os"
46 "sync"
57 "unsafe"
68
@@ -45,6 +47,12 @@ type renderPipeline struct {
4547
4648 // GL initialized flag.
4749 glReady bool
50+
51+ // onFirstResize is called once when the first non-zero resize occurs.
52+ onFirstResize func(width, height int32)
53+
54+ // onResizeCB is called on every non-zero resize after the first.
55+ onResizeCB func(width, height int32)
4856 }
4957
5058 // Shader sources for the fullscreen textured quad.
@@ -121,8 +129,8 @@ func (rp *renderPipeline) handlePaint(buffer unsafe.Pointer, width, height int32
121129 bufSize := int(width) * int(height) * 4
122130 srcSlice := unsafe.Slice((*byte)(buffer), bufSize)
123131
124- // Detect size change.
125- if width != rp.width || height != rp.height {
132+ // Detect size change, or first paint (staging not yet allocated).
133+ if width != rp.width || height != rp.height || rp.staging == nil {
126134 rp.width = width
127135 rp.height = height
128136 rp.staging = make([]byte, bufSize)
@@ -151,6 +159,8 @@ func (rp *renderPipeline) handlePaint(buffer unsafe.Pointer, width, height int32
151159 }
152160
153161 // onGLRender is the GTK "render" signal handler. GL context is current.
162+var glRenderCount uint64
163+
154164 func (rp *renderPipeline) onGLRender() bool {
155165 rp.mu.Lock()
156166
@@ -158,6 +168,12 @@ func (rp *renderPipeline) onGLRender() bool {
158168 sizeChanged := rp.sizeChanged
159169 rp.needsUpload = false
160170
171+ glRenderCount++
172+ if glRenderCount <= 5 {
173+ fmt.Fprintf(os.Stderr, "cef-debug: onGLRender call=%d needsUpload=%v sizeChanged=%v glReady=%v\n",
174+ glRenderCount, needsUpload, sizeChanged, rp.glReady)
175+ }
176+
161177 // Snapshot dirty rects for upload.
162178 var dirtyRects []rect
163179 if needsUpload {
@@ -370,7 +386,6 @@ func compileShader(gl *glLoader, shaderType uint32, source string) uint32 {
370386 // we multiply by scale to get device pixels.
371387 func (rp *renderPipeline) onResize(width, height int32) {
372388 rp.mu.Lock()
373- defer rp.mu.Unlock()
374389
375390 scaled := func(v int32) int32 {
376391 return v * rp.scale
@@ -379,6 +394,21 @@ func (rp *renderPipeline) onResize(width, height int32) {
379394 rp.width = scaled(width)
380395 rp.height = scaled(height)
381396 rp.sizeChanged = true
397+
398+ firstCB := rp.onFirstResize
399+ if firstCB != nil {
400+ rp.onFirstResize = nil
401+ }
402+ resizeCB := rp.onResizeCB
403+
404+ w, h := rp.width, rp.height
405+ rp.mu.Unlock()
406+
407+ if firstCB != nil {
408+ firstCB(w, h)
409+ } else if resizeCB != nil {
410+ resizeCB(w, h)
411+ }
382412 }
383413
384414 // destroy releases all GL resources.
@@ -1,6 +1,8 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "fmt"
5+ "os"
4 "sync"6 "sync"
5 "unsafe"7 "unsafe"
6 8
@@ -45,6 +47,12 @@ type renderPipeline struct {
45 47
46 // GL initialized flag.48 // GL initialized flag.
47 glReady bool49 glReady bool
50+
51+ // onFirstResize is called once when the first non-zero resize occurs.
52+ onFirstResize func(width, height int32)
53+
54+ // onResizeCB is called on every non-zero resize after the first.
55+ onResizeCB func(width, height int32)
48 }56 }
49 57
50 // Shader sources for the fullscreen textured quad.58 // Shader sources for the fullscreen textured quad.
@@ -121,8 +129,8 @@ func (rp *renderPipeline) handlePaint(buffer unsafe.Pointer, width, height int32
121 bufSize := int(width) * int(height) * 4129 bufSize := int(width) * int(height) * 4
122 srcSlice := unsafe.Slice((*byte)(buffer), bufSize)130 srcSlice := unsafe.Slice((*byte)(buffer), bufSize)
123 131
124- // Detect size change.132+ // Detect size change, or first paint (staging not yet allocated).
125- if width != rp.width || height != rp.height {133+ if width != rp.width || height != rp.height || rp.staging == nil {
126 rp.width = width134 rp.width = width
127 rp.height = height135 rp.height = height
128 rp.staging = make([]byte, bufSize)136 rp.staging = make([]byte, bufSize)
@@ -151,6 +159,8 @@ func (rp *renderPipeline) handlePaint(buffer unsafe.Pointer, width, height int32
151 }159 }
152 160
153 // onGLRender is the GTK "render" signal handler. GL context is current.161 // onGLRender is the GTK "render" signal handler. GL context is current.
162+var glRenderCount uint64
163+
154 func (rp *renderPipeline) onGLRender() bool {164 func (rp *renderPipeline) onGLRender() bool {
155 rp.mu.Lock()165 rp.mu.Lock()
156 166
@@ -158,6 +168,12 @@ func (rp *renderPipeline) onGLRender() bool {
158 sizeChanged := rp.sizeChanged168 sizeChanged := rp.sizeChanged
159 rp.needsUpload = false169 rp.needsUpload = false
160 170
171+ glRenderCount++
172+ if glRenderCount <= 5 {
173+ fmt.Fprintf(os.Stderr, "cef-debug: onGLRender call=%d needsUpload=%v sizeChanged=%v glReady=%v\n",
174+ glRenderCount, needsUpload, sizeChanged, rp.glReady)
175+ }
176+
161 // Snapshot dirty rects for upload.177 // Snapshot dirty rects for upload.
162 var dirtyRects []rect178 var dirtyRects []rect
163 if needsUpload {179 if needsUpload {
@@ -370,7 +386,6 @@ func compileShader(gl *glLoader, shaderType uint32, source string) uint32 {
370 // we multiply by scale to get device pixels.386 // we multiply by scale to get device pixels.
371 func (rp *renderPipeline) onResize(width, height int32) {387 func (rp *renderPipeline) onResize(width, height int32) {
372 rp.mu.Lock()388 rp.mu.Lock()
373- defer rp.mu.Unlock()
374 389
375 scaled := func(v int32) int32 {390 scaled := func(v int32) int32 {
376 return v * rp.scale391 return v * rp.scale
@@ -379,6 +394,21 @@ func (rp *renderPipeline) onResize(width, height int32) {
379 rp.width = scaled(width)394 rp.width = scaled(width)
380 rp.height = scaled(height)395 rp.height = scaled(height)
381 rp.sizeChanged = true396 rp.sizeChanged = true
397+
398+ firstCB := rp.onFirstResize
399+ if firstCB != nil {
400+ rp.onFirstResize = nil
401+ }
402+ resizeCB := rp.onResizeCB
403+
404+ w, h := rp.width, rp.height
405+ rp.mu.Unlock()
406+
407+ if firstCB != nil {
408+ firstCB(w, h)
409+ } else if resizeCB != nil {
410+ resizeCB(w, h)
411+ }
382 }412 }
383 413
384 // destroy releases all GL resources.414 // destroy releases all GL resources.
M internal/infrastructure/cef/webview.go +21 -1
@@ -36,6 +36,16 @@ type WebView struct {
3636 input *inputBridge
3737 handlers *handlerSet
3838
39+ // pendingCreate holds browser creation params until the GL area is realized.
40+ pendingCreate *pendingBrowserCreate
41+
42+ // pendingURI is set when LoadURI is called before the browser exists.
43+ pendingURI string
44+
45+ // crashCount tracks consecutive renderer crashes to prevent infinite
46+ // crash → redirect → crash loops.
47+ crashCount int32
48+
3949 // Callbacks set by use case layer.
4050 mu sync.RWMutex
4151 callbacks *port.WebViewCallbacks
@@ -54,6 +64,14 @@ type WebView struct {
5464 generation atomic.Uint64
5565 }
5666
67+// pendingBrowserCreate holds the parameters needed to create a CEF browser,
68+// deferred until the GL area has a non-zero size.
69+type pendingBrowserCreate struct {
70+ windowInfo *purecef.WindowInfo
71+ client purecef.Client
72+ settings *purecef.BrowserSettings
73+}
74+
5775 // ---------------------------------------------------------------------------
5876 // Identity
5977 // ---------------------------------------------------------------------------
@@ -73,7 +91,9 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
7391 return errDestroyed
7492 }
7593 if wv.browser == nil {
76- return errNoBrowser
94+ // Browser not yet created — queue the URI for OnAfterCreated.
95+ wv.pendingURI = uri
96+ return nil
7797 }
7898 wv.browser.GetMainFrame().LoadURL(uri)
7999 return nil
@@ -36,6 +36,16 @@ type WebView struct {
36 input *inputBridge36 input *inputBridge
37 handlers *handlerSet37 handlers *handlerSet
38 38
39+ // pendingCreate holds browser creation params until the GL area is realized.
40+ pendingCreate *pendingBrowserCreate
41+
42+ // pendingURI is set when LoadURI is called before the browser exists.
43+ pendingURI string
44+
45+ // crashCount tracks consecutive renderer crashes to prevent infinite
46+ // crash → redirect → crash loops.
47+ crashCount int32
48+
39 // Callbacks set by use case layer.49 // Callbacks set by use case layer.
40 mu sync.RWMutex50 mu sync.RWMutex
41 callbacks *port.WebViewCallbacks51 callbacks *port.WebViewCallbacks
@@ -54,6 +64,14 @@ type WebView struct {
54 generation atomic.Uint6464 generation atomic.Uint64
55 }65 }
56 66
67+// pendingBrowserCreate holds the parameters needed to create a CEF browser,
68+// deferred until the GL area has a non-zero size.
69+type pendingBrowserCreate struct {
70+ windowInfo *purecef.WindowInfo
71+ client purecef.Client
72+ settings *purecef.BrowserSettings
73+}
74+
57 // ---------------------------------------------------------------------------75 // ---------------------------------------------------------------------------
58 // Identity76 // Identity
59 // ---------------------------------------------------------------------------77 // ---------------------------------------------------------------------------
@@ -73,7 +91,9 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
73 return errDestroyed91 return errDestroyed
74 }92 }
75 if wv.browser == nil {93 if wv.browser == nil {
76- return errNoBrowser94+ // Browser not yet created — queue the URI for OnAfterCreated.
95+ wv.pendingURI = uri
96+ return nil
77 }97 }
78 wv.browser.GetMainFrame().LoadURL(uri)98 wv.browser.GetMainFrame().LoadURL(uri)
79 return nil99 return nil
M internal/infrastructure/cef/webview_handlers.go +52 -3
@@ -1,6 +1,8 @@
11 package cef
22
33 import (
4+ "fmt"
5+ "os"
46 "unsafe"
57
68 purecef "github.com/bnema/purego-cef/cef"
@@ -63,6 +65,8 @@ func (h *handlerSet) GetRootScreenRect(_ purecef.Browser, _ *purecef.Rect) int32
6365 // GetViewRect fills the rect struct with the pipeline dimensions.
6466 // The rect pointer points to a cef_rect_t: {HostLayout padding, X, Y, Width, Height} all int32.
6567 // The HostLayout field occupies 0 bytes on most platforms but we use the Rect type alias to be safe.
68+var getViewRectCount uint64
69+
6670 func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
6771 if rect == nil {
6872 return
@@ -72,6 +76,20 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
7276 ht := h.wv.pipeline.height
7377 h.wv.pipeline.mu.Unlock()
7478
79+ // CEF requires a non-empty rect. Return a 1x1 fallback if the GL area
80+ // has not been realized yet.
81+ if w <= 0 {
82+ w = 1
83+ }
84+ if ht <= 0 {
85+ ht = 1
86+ }
87+
88+ getViewRectCount++
89+ if getViewRectCount <= 5 {
90+ fmt.Fprintf(os.Stderr, "cef-debug: GetViewRect call=%d w=%d h=%d\n", getViewRectCount, w, ht)
91+ }
92+
7593 rect.X = 0
7694 rect.Y = 0
7795 rect.Width = w
@@ -90,10 +108,17 @@ func (h *handlerSet) OnPopupSize(_ purecef.Browser, _ *purecef.Rect) {}
90108
91109 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects
92110 // to the render pipeline for GPU upload.
111+var onPaintCount uint64
112+
93113 func (h *handlerSet) OnPaint(
94114 _ purecef.Browser, _ purecef.PaintElementType,
95115 dirtyRects []purecef.Rect, buffer unsafe.Pointer, width, height int32,
96116 ) {
117+ onPaintCount++
118+ if onPaintCount <= 3 {
119+ fmt.Fprintf(os.Stderr, "cef-debug: OnPaint call=%d w=%d h=%d dirtyRects=%d buffer_nil=%v\n",
120+ onPaintCount, width, height, len(dirtyRects), buffer == nil)
121+ }
97122 rects := make([]rect, len(dirtyRects))
98123 for i, dr := range dirtyRects {
99124 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}
@@ -215,24 +240,31 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac
215240 }
216241 }
217242
218-// OnLoadStart fires LoadStarted for main frame navigations.
243+// OnLoadStart fires LoadCommitted for main frame navigations.
244+// In CEF this callback runs after navigation commit, so it is the closest
245+// equivalent to WebKit's LoadCommitted event.
219246 func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purecef.TransitionType) {
220247 if frame == nil || !frame.IsMain() {
221248 return
222249 }
250+ h.wv.updateURI(frame.GetURL())
223251 h.wv.mu.RLock()
224252 cb := h.wv.callbacks
225253 h.wv.mu.RUnlock()
226254 if cb != nil && cb.OnLoadChanged != nil {
227- cb.OnLoadChanged(port.LoadStarted)
255+ cb.OnLoadChanged(port.LoadCommitted)
228256 }
229257 }
230258
231259 // OnLoadEnd fires LoadFinished and sets progress to 1.0 for the main frame.
232-func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, _ int32) {
260+func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatusCode int32) {
261+ fmt.Fprintf(os.Stderr, "cef-debug: OnLoadEnd frame_nil=%v is_main=%v http_status=%d\n",
262+ frame == nil, frame != nil && frame.IsMain(), httpStatusCode)
233263 if frame == nil || !frame.IsMain() {
234264 return
235265 }
266+ // Successful load — reset the consecutive crash counter.
267+ h.wv.crashCount = 0
236268 h.wv.mu.RLock()
237269 cb := h.wv.callbacks
238270 h.wv.mu.RUnlock()
@@ -274,9 +306,16 @@ func (h *handlerSet) OnBeforeDevToolsPopup(
274306
275307 // OnAfterCreated stores the browser and host references and enables input.
276308 func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
309+ fmt.Fprintf(os.Stderr, "cef-debug: OnAfterCreated browser_nil=%v\n", browser == nil)
277310 h.wv.browser = browser
278311 h.wv.host = browser.GetHost()
279312 h.wv.input.setHost(h.wv.host)
313+
314+ // Replay any navigation that was requested before the browser existed.
315+ if uri := h.wv.pendingURI; uri != "" {
316+ h.wv.pendingURI = ""
317+ browser.GetMainFrame().LoadURL(uri)
318+ }
280319 }
281320
282321 // DoClose returns false to allow the default close behavior.
@@ -339,8 +378,18 @@ func (h *handlerSet) OnRenderProcessUnresponsive(_ purecef.Browser, _ purecef.Un
339378
340379 func (h *handlerSet) OnRenderProcessResponsive(_ purecef.Browser) {}
341380
381+// maxConsecutiveCrashes caps how many times OnRenderProcessTerminated will
382+// forward the event before suppressing further notifications. This prevents
383+// infinite crash → redirect → crash loops.
384+const maxConsecutiveCrashes = 3
385+
342386 // OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.
343387 func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {
388+ h.wv.crashCount++
389+ if h.wv.crashCount > maxConsecutiveCrashes {
390+ return // suppress to break the loop
391+ }
392+
344393 var reason port.WebProcessTerminationReason
345394 var label string
346395 switch status {
@@ -1,6 +1,8 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "fmt"
5+ "os"
4 "unsafe"6 "unsafe"
5 7
6 purecef "github.com/bnema/purego-cef/cef"8 purecef "github.com/bnema/purego-cef/cef"
@@ -63,6 +65,8 @@ func (h *handlerSet) GetRootScreenRect(_ purecef.Browser, _ *purecef.Rect) int32
63 // GetViewRect fills the rect struct with the pipeline dimensions.65 // GetViewRect fills the rect struct with the pipeline dimensions.
64 // The rect pointer points to a cef_rect_t: {HostLayout padding, X, Y, Width, Height} all int32.66 // The rect pointer points to a cef_rect_t: {HostLayout padding, X, Y, Width, Height} all int32.
65 // The HostLayout field occupies 0 bytes on most platforms but we use the Rect type alias to be safe.67 // The HostLayout field occupies 0 bytes on most platforms but we use the Rect type alias to be safe.
68+var getViewRectCount uint64
69+
66 func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {70 func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
67 if rect == nil {71 if rect == nil {
68 return72 return
@@ -72,6 +76,20 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
72 ht := h.wv.pipeline.height76 ht := h.wv.pipeline.height
73 h.wv.pipeline.mu.Unlock()77 h.wv.pipeline.mu.Unlock()
74 78
79+ // CEF requires a non-empty rect. Return a 1x1 fallback if the GL area
80+ // has not been realized yet.
81+ if w <= 0 {
82+ w = 1
83+ }
84+ if ht <= 0 {
85+ ht = 1
86+ }
87+
88+ getViewRectCount++
89+ if getViewRectCount <= 5 {
90+ fmt.Fprintf(os.Stderr, "cef-debug: GetViewRect call=%d w=%d h=%d\n", getViewRectCount, w, ht)
91+ }
92+
75 rect.X = 093 rect.X = 0
76 rect.Y = 094 rect.Y = 0
77 rect.Width = w95 rect.Width = w
@@ -90,10 +108,17 @@ func (h *handlerSet) OnPopupSize(_ purecef.Browser, _ *purecef.Rect) {}
90 108
91 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects109 // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects
92 // to the render pipeline for GPU upload.110 // to the render pipeline for GPU upload.
111+var onPaintCount uint64
112+
93 func (h *handlerSet) OnPaint(113 func (h *handlerSet) OnPaint(
94 _ purecef.Browser, _ purecef.PaintElementType,114 _ purecef.Browser, _ purecef.PaintElementType,
95 dirtyRects []purecef.Rect, buffer unsafe.Pointer, width, height int32,115 dirtyRects []purecef.Rect, buffer unsafe.Pointer, width, height int32,
96 ) {116 ) {
117+ onPaintCount++
118+ if onPaintCount <= 3 {
119+ fmt.Fprintf(os.Stderr, "cef-debug: OnPaint call=%d w=%d h=%d dirtyRects=%d buffer_nil=%v\n",
120+ onPaintCount, width, height, len(dirtyRects), buffer == nil)
121+ }
97 rects := make([]rect, len(dirtyRects))122 rects := make([]rect, len(dirtyRects))
98 for i, dr := range dirtyRects {123 for i, dr := range dirtyRects {
99 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}124 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}
@@ -215,24 +240,31 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac
215 }240 }
216 }241 }
217 242
218-// OnLoadStart fires LoadStarted for main frame navigations.243+// OnLoadStart fires LoadCommitted for main frame navigations.
244+// In CEF this callback runs after navigation commit, so it is the closest
245+// equivalent to WebKit's LoadCommitted event.
219 func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purecef.TransitionType) {246 func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purecef.TransitionType) {
220 if frame == nil || !frame.IsMain() {247 if frame == nil || !frame.IsMain() {
221 return248 return
222 }249 }
250+ h.wv.updateURI(frame.GetURL())
223 h.wv.mu.RLock()251 h.wv.mu.RLock()
224 cb := h.wv.callbacks252 cb := h.wv.callbacks
225 h.wv.mu.RUnlock()253 h.wv.mu.RUnlock()
226 if cb != nil && cb.OnLoadChanged != nil {254 if cb != nil && cb.OnLoadChanged != nil {
227- cb.OnLoadChanged(port.LoadStarted)255+ cb.OnLoadChanged(port.LoadCommitted)
228 }256 }
229 }257 }
230 258
231 // OnLoadEnd fires LoadFinished and sets progress to 1.0 for the main frame.259 // OnLoadEnd fires LoadFinished and sets progress to 1.0 for the main frame.
232-func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, _ int32) {260+func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatusCode int32) {
261+ fmt.Fprintf(os.Stderr, "cef-debug: OnLoadEnd frame_nil=%v is_main=%v http_status=%d\n",
262+ frame == nil, frame != nil && frame.IsMain(), httpStatusCode)
233 if frame == nil || !frame.IsMain() {263 if frame == nil || !frame.IsMain() {
234 return264 return
235 }265 }
266+ // Successful load — reset the consecutive crash counter.
267+ h.wv.crashCount = 0
236 h.wv.mu.RLock()268 h.wv.mu.RLock()
237 cb := h.wv.callbacks269 cb := h.wv.callbacks
238 h.wv.mu.RUnlock()270 h.wv.mu.RUnlock()
@@ -274,9 +306,16 @@ func (h *handlerSet) OnBeforeDevToolsPopup(
274 306
275 // OnAfterCreated stores the browser and host references and enables input.307 // OnAfterCreated stores the browser and host references and enables input.
276 func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {308 func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
309+ fmt.Fprintf(os.Stderr, "cef-debug: OnAfterCreated browser_nil=%v\n", browser == nil)
277 h.wv.browser = browser310 h.wv.browser = browser
278 h.wv.host = browser.GetHost()311 h.wv.host = browser.GetHost()
279 h.wv.input.setHost(h.wv.host)312 h.wv.input.setHost(h.wv.host)
313+
314+ // Replay any navigation that was requested before the browser existed.
315+ if uri := h.wv.pendingURI; uri != "" {
316+ h.wv.pendingURI = ""
317+ browser.GetMainFrame().LoadURL(uri)
318+ }
280 }319 }
281 320
282 // DoClose returns false to allow the default close behavior.321 // DoClose returns false to allow the default close behavior.
@@ -339,8 +378,18 @@ func (h *handlerSet) OnRenderProcessUnresponsive(_ purecef.Browser, _ purecef.Un
339 378
340 func (h *handlerSet) OnRenderProcessResponsive(_ purecef.Browser) {}379 func (h *handlerSet) OnRenderProcessResponsive(_ purecef.Browser) {}
341 380
381+// maxConsecutiveCrashes caps how many times OnRenderProcessTerminated will
382+// forward the event before suppressing further notifications. This prevents
383+// infinite crash → redirect → crash loops.
384+const maxConsecutiveCrashes = 3
385+
342 // OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.386 // OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.
343 func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {387 func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {
388+ h.wv.crashCount++
389+ if h.wv.crashCount > maxConsecutiveCrashes {
390+ return // suppress to break the loop
391+ }
392+
344 var reason port.WebProcessTerminationReason393 var reason port.WebProcessTerminationReason
345 var label string394 var label string
346 switch status {395 switch status {
A internal/infrastructure/cef/webview_handlers_test.go +71 -0
@@ -0,0 +1,71 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+type stubFrame struct {
12+ main bool
13+ url string
14+}
15+
16+func (f stubFrame) IsValid() bool { return true }
17+func (f stubFrame) Undo() {}
18+func (f stubFrame) Redo() {}
19+func (f stubFrame) Cut() {}
20+func (f stubFrame) Copy() {}
21+func (f stubFrame) Paste() {}
22+func (f stubFrame) PasteAndMatchStyle() {
23+}
24+func (f stubFrame) Del() {}
25+func (f stubFrame) SelectAll() {}
26+func (f stubFrame) ViewSource() {}
27+func (f stubFrame) GetSource(purecef.StringVisitor) {
28+}
29+func (f stubFrame) GetText(purecef.StringVisitor) {
30+}
31+func (f stubFrame) LoadRequest(purecef.Request) {}
32+func (f stubFrame) LoadURL(string) {}
33+func (f stubFrame) ExecuteJavaScript(string, string, int32) {
34+}
35+func (f stubFrame) IsMain() bool { return f.main }
36+func (f stubFrame) IsFocused() bool { return false }
37+func (f stubFrame) GetName() string { return "" }
38+func (f stubFrame) GetIdentifier() string { return "" }
39+func (f stubFrame) GetParent() purecef.Frame { return nil }
40+func (f stubFrame) GetURL() string { return f.url }
41+func (f stubFrame) GetBrowser() purecef.Browser { return nil }
42+func (f stubFrame) GetV8Context() purecef.V8Context { return nil }
43+func (f stubFrame) VisitDom(purecef.Domvisitor) {}
44+func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) purecef.Urlrequest {
45+ return nil
46+}
47+func (f stubFrame) SendProcessMessage(purecef.ProcessID, purecef.ProcessMessage) {
48+}
49+
50+func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
51+ wv := &WebView{}
52+ var gotEvents []port.LoadEvent
53+ wv.SetCallbacks(&port.WebViewCallbacks{
54+ OnLoadChanged: func(event port.LoadEvent) {
55+ gotEvents = append(gotEvents, event)
56+ },
57+ })
58+
59+ h := &handlerSet{wv: wv}
60+ h.OnLoadStart(nil, stubFrame{main: true, url: "https://google.com"}, 0)
61+
62+ if len(gotEvents) != 1 {
63+ t.Fatalf("expected 1 load event, got %d", len(gotEvents))
64+ }
65+ if gotEvents[0] != port.LoadCommitted {
66+ t.Fatalf("expected %v, got %v", port.LoadCommitted, gotEvents[0])
67+ }
68+ if gotURI := wv.URI(); gotURI != "https://google.com" {
69+ t.Fatalf("expected URI to update from frame on load commit, got %q", gotURI)
70+ }
71+}
@@ -0,0 +1,71 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+type stubFrame struct {
12+ main bool
13+ url string
14+}
15+
16+func (f stubFrame) IsValid() bool { return true }
17+func (f stubFrame) Undo() {}
18+func (f stubFrame) Redo() {}
19+func (f stubFrame) Cut() {}
20+func (f stubFrame) Copy() {}
21+func (f stubFrame) Paste() {}
22+func (f stubFrame) PasteAndMatchStyle() {
23+}
24+func (f stubFrame) Del() {}
25+func (f stubFrame) SelectAll() {}
26+func (f stubFrame) ViewSource() {}
27+func (f stubFrame) GetSource(purecef.StringVisitor) {
28+}
29+func (f stubFrame) GetText(purecef.StringVisitor) {
30+}
31+func (f stubFrame) LoadRequest(purecef.Request) {}
32+func (f stubFrame) LoadURL(string) {}
33+func (f stubFrame) ExecuteJavaScript(string, string, int32) {
34+}
35+func (f stubFrame) IsMain() bool { return f.main }
36+func (f stubFrame) IsFocused() bool { return false }
37+func (f stubFrame) GetName() string { return "" }
38+func (f stubFrame) GetIdentifier() string { return "" }
39+func (f stubFrame) GetParent() purecef.Frame { return nil }
40+func (f stubFrame) GetURL() string { return f.url }
41+func (f stubFrame) GetBrowser() purecef.Browser { return nil }
42+func (f stubFrame) GetV8Context() purecef.V8Context { return nil }
43+func (f stubFrame) VisitDom(purecef.Domvisitor) {}
44+func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) purecef.Urlrequest {
45+ return nil
46+}
47+func (f stubFrame) SendProcessMessage(purecef.ProcessID, purecef.ProcessMessage) {
48+}
49+
50+func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
51+ wv := &WebView{}
52+ var gotEvents []port.LoadEvent
53+ wv.SetCallbacks(&port.WebViewCallbacks{
54+ OnLoadChanged: func(event port.LoadEvent) {
55+ gotEvents = append(gotEvents, event)
56+ },
57+ })
58+
59+ h := &handlerSet{wv: wv}
60+ h.OnLoadStart(nil, stubFrame{main: true, url: "https://google.com"}, 0)
61+
62+ if len(gotEvents) != 1 {
63+ t.Fatalf("expected 1 load event, got %d", len(gotEvents))
64+ }
65+ if gotEvents[0] != port.LoadCommitted {
66+ t.Fatalf("expected %v, got %v", port.LoadCommitted, gotEvents[0])
67+ }
68+ if gotURI := wv.URI(); gotURI != "https://google.com" {
69+ t.Fatalf("expected URI to update from frame on load commit, got %q", gotURI)
70+ }
71+}

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