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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) | |||
| 45 | 45 | @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..." | |
| 46 | 46 | @mkdir -p $(DIST_DIR) | |
| 47 | 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 | 49 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 49 | 50 | ||
| 50 | 51 | # 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 targets | 51 | # 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 | |||
| 55 | 55 | func main() { | |
| 56 | 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 | 61 | // CEF subprocess handling: when CEF re-launches the binary with | |
| 59 | 62 | // --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) | |
| 63 | 69 | cef.MaybeExitSubprocess() | |
| 70 | + | fmt.Fprintf(os.Stderr, "cef: MaybeExitSubprocess returned (browser process)\n") | |
| 64 | 71 | } | |
| 65 | 72 | ||
| 66 | 73 | // 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 | 528 | func buildUIDependencies( | |
| 511 | 529 | ctx context.Context, | |
| 512 | 530 | 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 with | 61 | // CEF subprocess handling: when CEF re-launches the binary with | ||
| 59 | // --type=renderer/gpu/etc, we must call ExecuteProcess before anything | 62 | // --type=renderer/gpu/etc, we must call ExecuteProcess before anything | ||
| 60 | - | // else (Cobra, config, arg stripping). This only runs when the engine | 63 | + | // 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 command | 73 | // 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | ||
| 6 | 6 | purecef "github.com/bnema/purego-cef/cef" | |
| 7 | + | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 7 | 8 | "github.com/rs/zerolog" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -12,6 +13,11 @@ import ( | |||
| 12 | 13 | // Compile-time interface check. | |
| 13 | 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 | 21 | // Engine implements port.Engine for the CEF browser backend. | |
| 16 | 22 | // It manages the CEF lifecycle and provides access to all engine subsystems. | |
| 17 | 23 | type Engine struct { | |
| @@ -20,6 +26,19 @@ type Engine struct { | |||
| 20 | 26 | factory *WebViewFactory | |
| 21 | 27 | pool *WebViewPool | |
| 22 | 28 | 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 | 44 | // Factory returns the WebViewFactory for creating new WebView instances. | |
| @@ -59,6 +78,7 @@ func (e *Engine) InternalSchemePath() string { | |||
| 59 | 78 | ||
| 60 | 79 | // Close releases all resources held by the engine. | |
| 61 | 80 | func (e *Engine) Close() error { | |
| 81 | + | e.stopMessagePump() | |
| 62 | 82 | e.pool.Close() | |
| 63 | 83 | purecef.Shutdown() | |
| 64 | 84 | return nil | |
| @@ -79,8 +99,12 @@ func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.Download | |||
| 79 | 99 | 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 | 105 | func (e *Engine) OnToolkitReady(_ context.Context) error { | |
| 106 | + | e.logger.Debug().Msg("cef: OnToolkitReady called, starting message pump") | |
| 107 | + | e.startMessagePump() | |
| 84 | 108 | return nil | |
| 85 | 109 | } | |
| 86 | 110 | ||
| @@ -98,3 +122,78 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate) | |||
| 98 | 122 | func (e *Engine) SetHandlerContext(ctx context.Context) { | |
| 99 | 123 | 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 | + | } | |
| @@ -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 *WebViewFactory | 26 | factory *WebViewFactory | ||
| 21 | pool *WebViewPool | 27 | pool *WebViewPool | ||
| 22 | logger zerolog.Logger | 28 | 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 nil | 84 | return nil | ||
| @@ -79,8 +99,12 @@ func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.Download | |||||
| 79 | return nil | 99 | 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 nil | 108 | 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 = ctx | 123 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 6 | 8 | ||
| 7 | 9 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | - | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 9 | 10 | "github.com/rs/zerolog" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| @@ -25,35 +26,86 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig, logger zerolog.L | |||
| 25 | 26 | // NOTE: MaybeExitSubprocess is called in main() before Cobra, so that | |
| 26 | 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 | 54 | if err := purecef.Init(settings); err != nil { | |
| 55 | + | os.Args = savedArgs | |
| 29 | 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 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. | |
| 41 | 62 | gl, err := newGLLoader() | |
| 42 | 63 | if err != nil { | |
| 43 | 64 | purecef.Shutdown() | |
| 44 | 65 | return nil, fmt.Errorf("GL loader: %w", err) | |
| 45 | 66 | } | |
| 46 | 67 | ||
| 47 | - | // 4. Create factory + pool. | |
| 68 | + | // 3. Create factory + pool. | |
| 48 | 69 | scale := int32(1) // TODO: detect from GDK | |
| 49 | 70 | factory := newWebViewFactory(gl, scale) | |
| 50 | 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 | 78 | ctx: ctx, | |
| 54 | 79 | gl: gl, | |
| 55 | 80 | factory: factory, | |
| 56 | 81 | pool: pool, | |
| 57 | 82 | 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 "" | |
| 59 | 111 | } | |
| @@ -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 that | 26 | // 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 the | 61 | + | // 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 GDK | 69 | 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 | - | }, 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 "" | |||
| 59 | } | 111 | } | ||
M internal/infrastructure/cef/factory.go +30 -10
| @@ -66,16 +66,36 @@ func (f *WebViewFactory) Create(_ context.Context) (port.WebView, error) { | |||
| 66 | 66 | settings := purecef.DefaultBrowserSettings() | |
| 67 | 67 | settings.WindowlessFrameRate = 60 | |
| 68 | 68 | ||
| 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 | + | } | |
| 79 | 99 | ||
| 80 | 100 | return wv, nil | |
| 81 | 101 | } | |
| @@ -66,16 +66,36 @@ func (f *WebViewFactory) Create(_ context.Context) (port.WebView, error) { | |||||
| 66 | settings := purecef.DefaultBrowserSettings() | 66 | settings := purecef.DefaultBrowserSettings() | ||
| 67 | settings.WindowlessFrameRate = 60 | 67 | settings.WindowlessFrameRate = 60 | ||
| 68 | 68 | ||||
| 69 | - | // Create the browser asynchronously. The browser will be nil until | 69 | + | // 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, // extraInfo | 76 | + | } |
| 77 | - | nil, // requestContext | 77 | + | |
| 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, nil | 100 | return wv, nil | ||
| 81 | } | 101 | } | ||
M internal/infrastructure/cef/render_pipeline.go +33 -3
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 4 | 6 | "sync" | |
| 5 | 7 | "unsafe" | |
| 6 | 8 | ||
| @@ -45,6 +47,12 @@ type renderPipeline struct { | |||
| 45 | 47 | ||
| 46 | 48 | // GL initialized flag. | |
| 47 | 49 | 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 | 58 | // Shader sources for the fullscreen textured quad. | |
| @@ -121,8 +129,8 @@ func (rp *renderPipeline) handlePaint(buffer unsafe.Pointer, width, height int32 | |||
| 121 | 129 | bufSize := int(width) * int(height) * 4 | |
| 122 | 130 | srcSlice := unsafe.Slice((*byte)(buffer), bufSize) | |
| 123 | 131 | ||
| 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 { | |
| 126 | 134 | rp.width = width | |
| 127 | 135 | rp.height = height | |
| 128 | 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 | 161 | // onGLRender is the GTK "render" signal handler. GL context is current. | |
| 162 | + | var glRenderCount uint64 | |
| 163 | + | ||
| 154 | 164 | func (rp *renderPipeline) onGLRender() bool { | |
| 155 | 165 | rp.mu.Lock() | |
| 156 | 166 | ||
| @@ -158,6 +168,12 @@ func (rp *renderPipeline) onGLRender() bool { | |||
| 158 | 168 | sizeChanged := rp.sizeChanged | |
| 159 | 169 | 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 | 177 | // Snapshot dirty rects for upload. | |
| 162 | 178 | var dirtyRects []rect | |
| 163 | 179 | if needsUpload { | |
| @@ -370,7 +386,6 @@ func compileShader(gl *glLoader, shaderType uint32, source string) uint32 { | |||
| 370 | 386 | // we multiply by scale to get device pixels. | |
| 371 | 387 | func (rp *renderPipeline) onResize(width, height int32) { | |
| 372 | 388 | rp.mu.Lock() | |
| 373 | - | defer rp.mu.Unlock() | |
| 374 | 389 | ||
| 375 | 390 | scaled := func(v int32) int32 { | |
| 376 | 391 | return v * rp.scale | |
| @@ -379,6 +394,21 @@ func (rp *renderPipeline) onResize(width, height int32) { | |||
| 379 | 394 | rp.width = scaled(width) | |
| 380 | 395 | rp.height = scaled(height) | |
| 381 | 396 | 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 | 414 | // destroy releases all GL resources. | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package cef | 1 | 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 bool | 49 | 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) * 4 | 129 | 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 = width | 134 | rp.width = width | ||
| 127 | rp.height = height | 135 | 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.sizeChanged | 168 | sizeChanged := rp.sizeChanged | ||
| 159 | rp.needsUpload = false | 169 | 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 []rect | 178 | 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.scale | 391 | 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 = true | 396 | 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 { | |||
| 36 | 36 | input *inputBridge | |
| 37 | 37 | 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 | 49 | // Callbacks set by use case layer. | |
| 40 | 50 | mu sync.RWMutex | |
| 41 | 51 | callbacks *port.WebViewCallbacks | |
| @@ -54,6 +64,14 @@ type WebView struct { | |||
| 54 | 64 | 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 | 76 | // Identity | |
| 59 | 77 | // --------------------------------------------------------------------------- | |
| @@ -73,7 +91,9 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error { | |||
| 73 | 91 | return errDestroyed | |
| 74 | 92 | } | |
| 75 | 93 | if wv.browser == nil { | |
| 76 | - | return errNoBrowser | |
| 94 | + | // Browser not yet created — queue the URI for OnAfterCreated. | |
| 95 | + | wv.pendingURI = uri | |
| 96 | + | return nil | |
| 77 | 97 | } | |
| 78 | 98 | wv.browser.GetMainFrame().LoadURL(uri) | |
| 79 | 99 | return nil | |
| @@ -36,6 +36,16 @@ type WebView struct { | |||||
| 36 | input *inputBridge | 36 | input *inputBridge | ||
| 37 | handlers *handlerSet | 37 | 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.RWMutex | 50 | mu sync.RWMutex | ||
| 41 | callbacks *port.WebViewCallbacks | 51 | callbacks *port.WebViewCallbacks | ||
| @@ -54,6 +64,14 @@ type WebView struct { | |||||
| 54 | generation atomic.Uint64 | 64 | 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 | // Identity | 76 | // Identity | ||
| 59 | // --------------------------------------------------------------------------- | 77 | // --------------------------------------------------------------------------- | ||
| @@ -73,7 +91,9 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error { | |||||
| 73 | return errDestroyed | 91 | return errDestroyed | ||
| 74 | } | 92 | } | ||
| 75 | if wv.browser == nil { | 93 | if wv.browser == nil { | ||
| 76 | - | return errNoBrowser | 94 | + | // 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 nil | 99 | return nil | ||
M internal/infrastructure/cef/webview_handlers.go +52 -3
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 4 | 6 | "unsafe" | |
| 5 | 7 | ||
| 6 | 8 | purecef "github.com/bnema/purego-cef/cef" | |
| @@ -63,6 +65,8 @@ func (h *handlerSet) GetRootScreenRect(_ purecef.Browser, _ *purecef.Rect) int32 | |||
| 63 | 65 | // GetViewRect fills the rect struct with the pipeline dimensions. | |
| 64 | 66 | // The rect pointer points to a cef_rect_t: {HostLayout padding, X, Y, Width, Height} all int32. | |
| 65 | 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 | 70 | func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) { | |
| 67 | 71 | if rect == nil { | |
| 68 | 72 | return | |
| @@ -72,6 +76,20 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) { | |||
| 72 | 76 | ht := h.wv.pipeline.height | |
| 73 | 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 | 93 | rect.X = 0 | |
| 76 | 94 | rect.Y = 0 | |
| 77 | 95 | rect.Width = w | |
| @@ -90,10 +108,17 @@ func (h *handlerSet) OnPopupSize(_ purecef.Browser, _ *purecef.Rect) {} | |||
| 90 | 108 | ||
| 91 | 109 | // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects | |
| 92 | 110 | // to the render pipeline for GPU upload. | |
| 111 | + | var onPaintCount uint64 | |
| 112 | + | ||
| 93 | 113 | func (h *handlerSet) OnPaint( | |
| 94 | 114 | _ purecef.Browser, _ purecef.PaintElementType, | |
| 95 | 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 | 122 | rects := make([]rect, len(dirtyRects)) | |
| 98 | 123 | for i, dr := range dirtyRects { | |
| 99 | 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 | 246 | func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purecef.TransitionType) { | |
| 220 | 247 | if frame == nil || !frame.IsMain() { | |
| 221 | 248 | return | |
| 222 | 249 | } | |
| 250 | + | h.wv.updateURI(frame.GetURL()) | |
| 223 | 251 | h.wv.mu.RLock() | |
| 224 | 252 | cb := h.wv.callbacks | |
| 225 | 253 | h.wv.mu.RUnlock() | |
| 226 | 254 | if cb != nil && cb.OnLoadChanged != nil { | |
| 227 | - | cb.OnLoadChanged(port.LoadStarted) | |
| 255 | + | cb.OnLoadChanged(port.LoadCommitted) | |
| 228 | 256 | } | |
| 229 | 257 | } | |
| 230 | 258 | ||
| 231 | 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 | 263 | if frame == nil || !frame.IsMain() { | |
| 234 | 264 | return | |
| 235 | 265 | } | |
| 266 | + | // Successful load — reset the consecutive crash counter. | |
| 267 | + | h.wv.crashCount = 0 | |
| 236 | 268 | h.wv.mu.RLock() | |
| 237 | 269 | cb := h.wv.callbacks | |
| 238 | 270 | h.wv.mu.RUnlock() | |
| @@ -274,9 +306,16 @@ func (h *handlerSet) OnBeforeDevToolsPopup( | |||
| 274 | 306 | ||
| 275 | 307 | // OnAfterCreated stores the browser and host references and enables input. | |
| 276 | 308 | func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |
| 309 | + | fmt.Fprintf(os.Stderr, "cef-debug: OnAfterCreated browser_nil=%v\n", browser == nil) | |
| 277 | 310 | h.wv.browser = browser | |
| 278 | 311 | h.wv.host = browser.GetHost() | |
| 279 | 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 | 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 | 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 | 386 | // OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason. | |
| 343 | 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 | 393 | var reason port.WebProcessTerminationReason | |
| 345 | 394 | var label string | |
| 346 | 395 | switch status { | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package cef | 1 | 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 | return | 72 | return | ||
| @@ -72,6 +76,20 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) { | |||||
| 72 | ht := h.wv.pipeline.height | 76 | 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 = 0 | 93 | rect.X = 0 | ||
| 76 | rect.Y = 0 | 94 | rect.Y = 0 | ||
| 77 | rect.Width = w | 95 | 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 rects | 109 | // 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 | return | 248 | return | ||
| 222 | } | 249 | } | ||
| 250 | + | h.wv.updateURI(frame.GetURL()) | |||
| 223 | h.wv.mu.RLock() | 251 | h.wv.mu.RLock() | ||
| 224 | cb := h.wv.callbacks | 252 | 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 | return | 264 | 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.callbacks | 269 | 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 = browser | 310 | 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.WebProcessTerminationReason | 393 | var reason port.WebProcessTerminationReason | ||
| 345 | var label string | 394 | 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 | + | } | |||