detached 5ec3eb1

fix(cef): resolve random SIGSEGV at startup, switch to modernc sqlite

Root cause: purego callbacks create mixed Go/C stack frames that Go's
runtime cannot traverse when compiler optimizations are enabled. This
causes SIGSEGV in runtime.scanstack during GC, stack growth, or
goroutine scheduling.

Fix: build with -gcflags 'all=-N -l' to disable optimizations and
inlining globally. The -N flag is critical — it prevents register-only
variables and stack slot reuse that corrupt the runtime's stack walker.
-l alone is insufficient.

Other changes included from ongoing CEF work:
- Switch ncruces/go-sqlite3 (WASM) to modernc.org/sqlite (pure Go)
- Remove purego local fork replace directive (upstream is unmodified)
- Browser creation posted to CEF UI thread via PostTask
- Proper engine shutdown with webview cleanup and timeouts
- Lock-free render pipeline (atomic width/height, sequence counters)
- Dirty rect clamping and glUnmapBuffer error handling
- Thread-safe pendingCreate accessor
commit
5ec3eb128563d080ccd5795b991c954a922b23a7
author
brice · · 2026-03-22 15:14 UTC
committer
brice · · 2026-03-28 04:38 UTC
parent
6b1adf4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +554 -66
M Makefile +10 -2
@@ -24,6 +24,14 @@ BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
2424 # Linker flags
2525 LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)"
2626
27+# Disable optimizations and inlining for ALL packages. purego callbacks
28+# create mixed Go/C stack frames; the Go runtime (stack growth, GC,
29+# scheduler) cannot reliably traverse optimised frames across these
30+# boundaries. -N prevents register-only variables and stack slot reuse
31+# that corrupt the runtime's stack walker. -l prevents inlining across
32+# package boundaries. Both are required — -l alone is insufficient.
33+GCFLAGS=-gcflags 'all=-N -l'
34+
2735 # Default target
2836 help: ## Show this help message
2937 @echo "Available targets:"
@@ -33,7 +41,7 @@ help: ## Show this help message
3341 build: build-frontend ## Build the application (pure Go, no CGO)
3442 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
3543 @mkdir -p $(DIST_DIR)
36- CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
44+ CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
3745 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
3846
3947 build-frontend: ## Build homepage and error pages
@@ -44,7 +52,7 @@ build-frontend: ## Build homepage and error pages
4452 build-quick: ## Build without frontend (faster for backend development)
4553 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."
4654 @mkdir -p $(DIST_DIR)
47- CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
55+ CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
4856 CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper
4957 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
5058
@@ -24,6 +24,14 @@ BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
24 # Linker flags24 # Linker flags
25 LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)"25 LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)"
26 26
27+# Disable optimizations and inlining for ALL packages. purego callbacks
28+# create mixed Go/C stack frames; the Go runtime (stack growth, GC,
29+# scheduler) cannot reliably traverse optimised frames across these
30+# boundaries. -N prevents register-only variables and stack slot reuse
31+# that corrupt the runtime's stack walker. -l prevents inlining across
32+# package boundaries. Both are required — -l alone is insufficient.
33+GCFLAGS=-gcflags 'all=-N -l'
34+
27 # Default target35 # Default target
28 help: ## Show this help message36 help: ## Show this help message
29 @echo "Available targets:"37 @echo "Available targets:"
@@ -33,7 +41,7 @@ help: ## Show this help message
33 build: build-frontend ## Build the application (pure Go, no CGO)41 build: build-frontend ## Build the application (pure Go, no CGO)
34 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."42 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
35 @mkdir -p $(DIST_DIR)43 @mkdir -p $(DIST_DIR)
36- CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)44+ CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
37 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"45 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
38 46
39 build-frontend: ## Build homepage and error pages47 build-frontend: ## Build homepage and error pages
@@ -44,7 +52,7 @@ build-frontend: ## Build homepage and error pages
44 build-quick: ## Build without frontend (faster for backend development)52 build-quick: ## Build without frontend (faster for backend development)
45 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."53 @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."
46 @mkdir -p $(DIST_DIR)54 @mkdir -p $(DIST_DIR)
47- CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)55+ CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(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-helper56 CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper
49 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"57 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
50 58
M go.mod +8 -0
@@ -22,6 +22,7 @@ require (
2222 golang.org/x/image v0.38.0
2323 golang.org/x/sys v0.42.0
2424 gopkg.in/yaml.v3 v3.0.1
25+ modernc.org/sqlite v1.47.0
2526 )
2627
2728 replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704
@@ -40,8 +41,10 @@ require (
4041 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
4142 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
4243 github.com/davecgh/go-spew v1.1.1 // indirect
44+ github.com/dustin/go-humanize v1.0.1 // indirect
4345 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
4446 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
47+ github.com/google/uuid v1.6.0 // indirect
4548 github.com/inconshreveable/mousetrap v1.1.0 // indirect
4649 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
4750 github.com/mattn/go-colorable v0.1.14 // indirect
@@ -52,7 +55,9 @@ require (
5255 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
5356 github.com/muesli/cancelreader v0.2.2 // indirect
5457 github.com/muesli/termenv v0.16.0 // indirect
58+ github.com/ncruces/go-strftime v1.0.0 // indirect
5559 github.com/pmezard/go-difflib v1.0.0 // indirect
60+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
5661 github.com/rivo/uniseg v0.4.7 // indirect
5762 github.com/russross/blackfriday/v2 v2.1.0 // indirect
5863 github.com/sagikazarmark/locafero v0.12.0 // indirect
@@ -69,4 +74,7 @@ require (
6974 golang.org/x/sync v0.20.0 // indirect
7075 golang.org/x/text v0.35.0 // indirect
7176 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
77+ modernc.org/libc v1.70.0 // indirect
78+ modernc.org/mathutil v1.7.1 // indirect
79+ modernc.org/memory v1.11.0 // indirect
7280 )
@@ -22,6 +22,7 @@ require (
22 golang.org/x/image v0.38.022 golang.org/x/image v0.38.0
23 golang.org/x/sys v0.42.023 golang.org/x/sys v0.42.0
24 gopkg.in/yaml.v3 v3.0.124 gopkg.in/yaml.v3 v3.0.1
25+ modernc.org/sqlite v1.47.0
25 )26 )
26 27
27 replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a270428 replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704
@@ -40,8 +41,10 @@ require (
40 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect41 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
41 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect42 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
42 github.com/davecgh/go-spew v1.1.1 // indirect43 github.com/davecgh/go-spew v1.1.1 // indirect
44+ github.com/dustin/go-humanize v1.0.1 // indirect
43 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect45 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
44 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect46 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
47+ github.com/google/uuid v1.6.0 // indirect
45 github.com/inconshreveable/mousetrap v1.1.0 // indirect48 github.com/inconshreveable/mousetrap v1.1.0 // indirect
46 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect49 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
47 github.com/mattn/go-colorable v0.1.14 // indirect50 github.com/mattn/go-colorable v0.1.14 // indirect
@@ -52,7 +55,9 @@ require (
52 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect55 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
53 github.com/muesli/cancelreader v0.2.2 // indirect56 github.com/muesli/cancelreader v0.2.2 // indirect
54 github.com/muesli/termenv v0.16.0 // indirect57 github.com/muesli/termenv v0.16.0 // indirect
58+ github.com/ncruces/go-strftime v1.0.0 // indirect
55 github.com/pmezard/go-difflib v1.0.0 // indirect59 github.com/pmezard/go-difflib v1.0.0 // indirect
60+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
56 github.com/rivo/uniseg v0.4.7 // indirect61 github.com/rivo/uniseg v0.4.7 // indirect
57 github.com/russross/blackfriday/v2 v2.1.0 // indirect62 github.com/russross/blackfriday/v2 v2.1.0 // indirect
58 github.com/sagikazarmark/locafero v0.12.0 // indirect63 github.com/sagikazarmark/locafero v0.12.0 // indirect
@@ -69,4 +74,7 @@ require (
69 golang.org/x/sync v0.20.0 // indirect74 golang.org/x/sync v0.20.0 // indirect
70 golang.org/x/text v0.35.0 // indirect75 golang.org/x/text v0.35.0 // indirect
71 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect76 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
77+ modernc.org/libc v1.70.0 // indirect
78+ modernc.org/mathutil v1.7.1 // indirect
79+ modernc.org/memory v1.11.0 // indirect
72 )80 )
M go.sum +32 -10 generated file: collapsed
M internal/bootstrap/gui.go +1 -1
@@ -148,7 +148,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
148148 }
149149
150150 // RunDeferredInit runs deferred initialization checks off the critical path.
151-// This includes SQLite WASM precompile, runtime requirements, and media checks.
151+// This includes runtime requirements and media checks.
152152 func RunDeferredInit(input DeferredInitInput) DeferredInitResult {
153153 var (
154154 runtimeErr error
@@ -148,7 +148,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
148 }148 }
149 149
150 // RunDeferredInit runs deferred initialization checks off the critical path.150 // RunDeferredInit runs deferred initialization checks off the critical path.
151-// This includes SQLite WASM precompile, runtime requirements, and media checks.151+// This includes runtime requirements and media checks.
152 func RunDeferredInit(input DeferredInitInput) DeferredInitResult {152 func RunDeferredInit(input DeferredInitInput) DeferredInitResult {
153 var (153 var (
154 runtimeErr error154 runtimeErr error
M internal/infrastructure/cef/engine.go +74 -0
@@ -122,12 +122,86 @@ func (e *Engine) InternalSchemePath() string {
122122
123123 // Close releases all resources held by the engine.
124124 func (e *Engine) Close() error {
125+ log := logging.FromContext(e.ctx)
126+ activeBefore := e.activeWebViewCount()
127+ log.Debug().
128+ Int("active_webviews", activeBefore).
129+ Msg("cef: engine close started")
130+
125131 e.stopMessagePump()
132+ e.closeActiveWebViews()
126133 e.pool.Close()
134+
135+ if activeAfter := e.activeWebViewCount(); activeAfter > 0 {
136+ log.Warn().
137+ Int("remaining_active_webviews", activeAfter).
138+ Msg("cef: shutting down with active webviews still registered")
139+ }
140+
127141 purecef.Shutdown()
142+ log.Debug().Msg("cef: engine close completed")
128143 return nil
129144 }
130145
146+const (
147+ cefShutdownWaitStep = 10 * time.Millisecond
148+ cefShutdownWaitTimeout = 2 * time.Second
149+)
150+
151+func (e *Engine) activeWebViewCount() int {
152+ count := 0
153+ e.activeWebViews.Range(func(_, _ any) bool {
154+ count++
155+ return true
156+ })
157+ return count
158+}
159+
160+func (e *Engine) closeActiveWebViews() {
161+ if e == nil {
162+ return
163+ }
164+
165+ log := logging.FromContext(e.ctx)
166+ webViews := make([]*WebView, 0, e.activeWebViewCount())
167+ e.activeWebViews.Range(func(_, value any) bool {
168+ wv, ok := value.(*WebView)
169+ if ok && wv != nil {
170+ webViews = append(webViews, wv)
171+ }
172+ return true
173+ })
174+
175+ if len(webViews) == 0 {
176+ return
177+ }
178+
179+ log.Debug().
180+ Int("count", len(webViews)).
181+ Msg("cef: closing active webviews before shutdown")
182+
183+ for _, wv := range webViews {
184+ wv.Destroy()
185+ }
186+
187+ deadline := time.Now().Add(cefShutdownWaitTimeout)
188+ for {
189+ remaining := e.activeWebViewCount()
190+ if remaining == 0 {
191+ log.Debug().Msg("cef: all active webviews closed before shutdown")
192+ return
193+ }
194+ if time.Now().After(deadline) {
195+ log.Warn().
196+ Int("remaining", remaining).
197+ Str("timeout", cefShutdownWaitTimeout.String()).
198+ Msg("cef: timed out waiting for OnBeforeClose before shutdown")
199+ return
200+ }
201+ time.Sleep(cefShutdownWaitStep)
202+ }
203+}
204+
131205 // RegisterHandlers registers all WebUI message bridge handlers with the message router.
132206 // Handler registration follows the same pattern as WebKit's handlers/register.go:
133207 // each handler is registered by message type with the router, which dispatches
@@ -122,12 +122,86 @@ func (e *Engine) InternalSchemePath() string {
122 122
123 // Close releases all resources held by the engine.123 // Close releases all resources held by the engine.
124 func (e *Engine) Close() error {124 func (e *Engine) Close() error {
125+ log := logging.FromContext(e.ctx)
126+ activeBefore := e.activeWebViewCount()
127+ log.Debug().
128+ Int("active_webviews", activeBefore).
129+ Msg("cef: engine close started")
130+
125 e.stopMessagePump()131 e.stopMessagePump()
132+ e.closeActiveWebViews()
126 e.pool.Close()133 e.pool.Close()
134+
135+ if activeAfter := e.activeWebViewCount(); activeAfter > 0 {
136+ log.Warn().
137+ Int("remaining_active_webviews", activeAfter).
138+ Msg("cef: shutting down with active webviews still registered")
139+ }
140+
127 purecef.Shutdown()141 purecef.Shutdown()
142+ log.Debug().Msg("cef: engine close completed")
128 return nil143 return nil
129 }144 }
130 145
146+const (
147+ cefShutdownWaitStep = 10 * time.Millisecond
148+ cefShutdownWaitTimeout = 2 * time.Second
149+)
150+
151+func (e *Engine) activeWebViewCount() int {
152+ count := 0
153+ e.activeWebViews.Range(func(_, _ any) bool {
154+ count++
155+ return true
156+ })
157+ return count
158+}
159+
160+func (e *Engine) closeActiveWebViews() {
161+ if e == nil {
162+ return
163+ }
164+
165+ log := logging.FromContext(e.ctx)
166+ webViews := make([]*WebView, 0, e.activeWebViewCount())
167+ e.activeWebViews.Range(func(_, value any) bool {
168+ wv, ok := value.(*WebView)
169+ if ok && wv != nil {
170+ webViews = append(webViews, wv)
171+ }
172+ return true
173+ })
174+
175+ if len(webViews) == 0 {
176+ return
177+ }
178+
179+ log.Debug().
180+ Int("count", len(webViews)).
181+ Msg("cef: closing active webviews before shutdown")
182+
183+ for _, wv := range webViews {
184+ wv.Destroy()
185+ }
186+
187+ deadline := time.Now().Add(cefShutdownWaitTimeout)
188+ for {
189+ remaining := e.activeWebViewCount()
190+ if remaining == 0 {
191+ log.Debug().Msg("cef: all active webviews closed before shutdown")
192+ return
193+ }
194+ if time.Now().After(deadline) {
195+ log.Warn().
196+ Int("remaining", remaining).
197+ Str("timeout", cefShutdownWaitTimeout.String()).
198+ Msg("cef: timed out waiting for OnBeforeClose before shutdown")
199+ return
200+ }
201+ time.Sleep(cefShutdownWaitStep)
202+ }
203+}
204+
131 // RegisterHandlers registers all WebUI message bridge handlers with the message router.205 // RegisterHandlers registers all WebUI message bridge handlers with the message router.
132 // Handler registration follows the same pattern as WebKit's handlers/register.go:206 // Handler registration follows the same pattern as WebKit's handlers/register.go:
133 // each handler is registered by message type with the router, which dispatches207 // each handler is registered by message type with the router, which dispatches
M internal/infrastructure/cef/factory.go +24 -4
@@ -131,9 +131,17 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
131131 // When the GL area gets its first non-zero size, create the browser.
132132 pipeline.onFirstResize = func(w, h int32) {
133133 log := logging.FromContext(ctx)
134- log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, creating browser")
135- if pc := wv.pendingCreate; pc != nil {
136- wv.pendingCreate = nil
134+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation")
135+ pc := wv.takePendingCreate()
136+ if pc == nil {
137+ return
138+ }
139+
140+ task := purecef.NewTask(cefTaskFunc(func() {
141+ if wv.destroyed.Load() {
142+ log.Debug().Msg("cef: skipping browser creation for destroyed webview")
143+ return
144+ }
137145 result := purecef.BrowserHostCreateBrowser(
138146 pc.windowInfo,
139147 pc.client,
@@ -152,7 +160,19 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
152160 Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).
153161 Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).
154162 Bool("client_nil", pc.client == nil).
155- Msg("cef: BrowserHostCreateBrowser call completed")
163+ Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread")
164+ }))
165+
166+ postResult := purecef.PostTask(purecef.ThreadIDTidUi, task)
167+ if postResult != 1 {
168+ wv.mu.Lock()
169+ if wv.pendingCreate == nil {
170+ wv.pendingCreate = pc
171+ }
172+ wv.mu.Unlock()
173+ log.Error().
174+ Int32("result", postResult).
175+ Msg("cef: failed to post initial browser creation to CEF UI thread")
156176 }
157177 }
158178
@@ -131,9 +131,17 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
131 // When the GL area gets its first non-zero size, create the browser.131 // When the GL area gets its first non-zero size, create the browser.
132 pipeline.onFirstResize = func(w, h int32) {132 pipeline.onFirstResize = func(w, h int32) {
133 log := logging.FromContext(ctx)133 log := logging.FromContext(ctx)
134- log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, creating browser")134+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation")
135- if pc := wv.pendingCreate; pc != nil {135+ pc := wv.takePendingCreate()
136- wv.pendingCreate = nil136+ if pc == nil {
137+ return
138+ }
139+
140+ task := purecef.NewTask(cefTaskFunc(func() {
141+ if wv.destroyed.Load() {
142+ log.Debug().Msg("cef: skipping browser creation for destroyed webview")
143+ return
144+ }
137 result := purecef.BrowserHostCreateBrowser(145 result := purecef.BrowserHostCreateBrowser(
138 pc.windowInfo,146 pc.windowInfo,
139 pc.client,147 pc.client,
@@ -152,7 +160,19 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
152 Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).160 Int32("shared_texture", pc.windowInfo.SharedTextureEnabled).
153 Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).161 Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled).
154 Bool("client_nil", pc.client == nil).162 Bool("client_nil", pc.client == nil).
155- Msg("cef: BrowserHostCreateBrowser call completed")163+ Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread")
164+ }))
165+
166+ postResult := purecef.PostTask(purecef.ThreadIDTidUi, task)
167+ if postResult != 1 {
168+ wv.mu.Lock()
169+ if wv.pendingCreate == nil {
170+ wv.pendingCreate = pc
171+ }
172+ wv.mu.Unlock()
173+ log.Error().
174+ Int32("result", postResult).
175+ Msg("cef: failed to post initial browser creation to CEF UI thread")
156 }176 }
157 }177 }
158 178
M internal/infrastructure/cef/render_pipeline.go +164 -11
@@ -3,6 +3,7 @@ package cef
33 import (
44 "context"
55 "sync"
6+ "sync/atomic"
67 "time"
78 "unsafe"
89
@@ -40,6 +41,10 @@ type renderPipeline struct {
4041 height int32
4142 scale int32
4243
44+ // geometry mirrors the current surface size for lock-free CEF callbacks.
45+ widthAtomic atomic.Int32
46+ heightAtomic atomic.Int32
47+
4348 // Staging: handlePaint copies dirty rects here, render signal uploads.
4449 mu sync.Mutex
4550 staging []byte
@@ -51,6 +56,11 @@ type renderPipeline struct {
5156 glReady bool
5257
5358 // Diagnostics (main thread only).
59+ viewRectSeq atomic.Uint64
60+ screenInfoSeq atomic.Uint64
61+ paintSeq atomic.Uint64
62+ lastQueuedPaintSeq atomic.Uint64
63+ glRenderSeq atomic.Uint64
5464 paintCount uint64
5565 acceleratedPaintCount uint64
5666 queueRenderCount uint64
@@ -151,30 +161,50 @@ func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32) *renderPi
151161 // handlePaint is called from CEF's OnPaint callback (on GTK main thread via
152162 // IdleAdd). It copies dirty rect regions from the CEF buffer into the staging
153163 // buffer and queues a GL redraw.
154-func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect) {
164+func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect, paintSeq uint64) {
155165 if len(buffer) == 0 || width <= 0 || height <= 0 {
156166 return
157167 }
158168
159169 startedAt := time.Now()
170+ rp.lastQueuedPaintSeq.Store(paintSeq)
171+ if rp.ctx != nil {
172+ logging.FromContext(rp.ctx).Debug().
173+ Uint64("paint_seq", paintSeq).
174+ Int32("width", width).
175+ Int32("height", height).
176+ Int("rect_count", len(rects)).
177+ Int("buffer_len", len(buffer)).
178+ Msg("cef: handlePaint begin")
179+ }
160180 rp.mu.Lock()
161181
162182 bufSize := int(int64(width) * int64(height) * 4)
163183 copiedBytes := uint64(0)
184+ sizeChanged := false
164185
165186 // Detect size change, or first paint (staging not yet allocated).
166187 if width != rp.width || height != rp.height || rp.staging == nil {
167188 rp.width = width
168189 rp.height = height
190+ rp.widthAtomic.Store(width)
191+ rp.heightAtomic.Store(height)
169192 rp.staging = make([]byte, bufSize)
170193 rp.sizeChanged = true
194+ sizeChanged = true
171195 // On size change, copy the entire buffer.
172196 copy(rp.staging, buffer)
173197 copiedBytes = uint64(bufSize)
174198 } else {
175199 // Copy only dirty rect rows.
176200 stride := int(width) * 4
177- for _, r := range rects {
201+ sanitized := make([]rect, 0, len(rects))
202+ for _, rawRect := range rects {
203+ r, ok := clampDirtyRect(rawRect, width, height)
204+ if !ok {
205+ continue
206+ }
207+ sanitized = append(sanitized, r)
178208 for row := r.Y; row < r.Y+r.Height; row++ {
179209 srcOff := int(row)*stride + int(r.X)*4
180210 dstOff := srcOff
@@ -183,6 +213,7 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
183213 copiedBytes += uint64(rowBytes)
184214 }
185215 }
216+ rects = sanitized
186217 }
187218
188219 // Accumulate dirty rects across multiple OnPaint calls between renders.
@@ -205,12 +236,28 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
205236 }
206237
207238 rp.glArea.QueueRender()
239+ if rp.ctx != nil {
240+ logging.FromContext(rp.ctx).Debug().
241+ Uint64("paint_seq", paintSeq).
242+ Uint64("copied_bytes", copiedBytes).
243+ Bool("size_changed", sizeChanged).
244+ Dur("copy_duration", copyDuration).
245+ Msg("cef: handlePaint end")
246+ }
208247 rp.maybeLogDiagnostics()
209248 }
210249
211250 // onGLRender is the GTK "render" signal handler. GL context is current.
212251 func (rp *renderPipeline) onGLRender() bool {
252+ renderSeq := rp.glRenderSeq.Add(1)
253+ paintSeq := rp.lastQueuedPaintSeq.Load()
213254 renderStartedAt := time.Now()
255+ if rp.ctx != nil {
256+ logging.FromContext(rp.ctx).Debug().
257+ Uint64("render_seq", renderSeq).
258+ Uint64("paint_seq", paintSeq).
259+ Msg("cef: onGLRender begin")
260+ }
214261 rp.mu.Lock()
215262
216263 needsUpload := rp.needsUpload
@@ -246,7 +293,7 @@ func (rp *renderPipeline) onGLRender() bool {
246293
247294 if needsUpload {
248295 uploadStartedAt := time.Now()
249- uploadedBytes := rp.uploadToPBO(dirtyRects, sizeChanged)
296+ uploadedBytes := rp.uploadToPBO(dirtyRects, sizeChanged, renderSeq, paintSeq)
250297 uploadDuration := time.Since(uploadStartedAt)
251298 rp.uploadCount++
252299 if sizeChanged {
@@ -277,19 +324,35 @@ func (rp *renderPipeline) onGLRender() bool {
277324 rp.maxRenderNs = renderDuration.Nanoseconds()
278325 }
279326 rp.maybeLogDiagnostics()
327+ if rp.ctx != nil {
328+ logging.FromContext(rp.ctx).Debug().
329+ Uint64("render_seq", renderSeq).
330+ Uint64("paint_seq", paintSeq).
331+ Dur("render_duration", renderDuration).
332+ Bool("needs_upload", needsUpload).
333+ Bool("size_changed", sizeChanged).
334+ Msg("cef: onGLRender end")
335+ }
280336
281337 return true
282338 }
283339
284340 // uploadToPBO uploads dirty regions from the staging buffer into a PBO, then
285341 // transfers to the GL texture via async DMA.
286-func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64 {
342+func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, renderSeq, paintSeq uint64) uint64 {
287343 gl := rp.gl
288344
289- rp.mu.Lock()
290- w := rp.width
291- h := rp.height
292- rp.mu.Unlock()
345+ w, h, _ := rp.viewRectSize()
346+ if rp.ctx != nil {
347+ logging.FromContext(rp.ctx).Debug().
348+ Uint64("render_seq", renderSeq).
349+ Uint64("paint_seq", paintSeq).
350+ Int32("width", w).
351+ Int32("height", h).
352+ Int("dirty_rect_count", len(dirtyRects)).
353+ Bool("full_upload", fullUpload).
354+ Msg("cef: uploadToPBO begin")
355+ }
293356
294357 bufSize := int64(w) * int64(h) * 4
295358 stride := int(w) * 4
@@ -313,7 +376,11 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64
313376 copy(mappedSlice, rp.staging)
314377 uploadedBytes = uint64(bufSize)
315378 } else {
316- for _, r := range dirtyRects {
379+ for _, rawRect := range dirtyRects {
380+ r, ok := clampDirtyRect(rawRect, w, h)
381+ if !ok {
382+ continue
383+ }
317384 for row := r.Y; row < r.Y+r.Height; row++ {
318385 off := int(row)*stride + int(r.X)*4
319386 rowBytes := int(r.Width) * 4
@@ -324,7 +391,15 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64
324391 }
325392 rp.mu.Unlock()
326393
327- gl.unmapBuffer(glPixelUnpackBuffer)
394+ if !gl.unmapBuffer(glPixelUnpackBuffer) {
395+ logging.FromContext(rp.ctx).Error().
396+ Int32("width", w).
397+ Int32("height", h).
398+ Bool("full_upload", fullUpload).
399+ Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame")
400+ gl.bindBuffer(glPixelUnpackBuffer, 0)
401+ return 0
402+ }
328403
329404 // Transfer PBO → texture. With PBO bound, the last arg is a byte offset.
330405 gl.bindTexture(glTexture2D, rp.texture)
@@ -335,7 +410,11 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64
335410 // stride, allowing one glTexSubImage2D per dirty rect instead of
336411 // one per row.
337412 gl.pixelStorei(glUnpackRowLength, w)
338- for _, r := range dirtyRects {
413+ for _, rawRect := range dirtyRects {
414+ r, ok := clampDirtyRect(rawRect, w, h)
415+ if !ok {
416+ continue
417+ }
339418 offset := uintptr(int(r.Y)*stride + int(r.X)*4)
340419 gl.texSubImage2D(glTexture2D, 0, r.X, r.Y, r.Width, r.Height,
341420 glBGRA, glUnsignedByte, pboOffset(offset))
@@ -348,6 +427,14 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64
348427 rp.pboIndex = 1 - rp.pboIndex
349428
350429 gl.bindBuffer(glPixelUnpackBuffer, 0)
430+ if rp.ctx != nil {
431+ logging.FromContext(rp.ctx).Debug().
432+ Uint64("render_seq", renderSeq).
433+ Uint64("paint_seq", paintSeq).
434+ Uint64("uploaded_bytes", uploadedBytes).
435+ Int("next_pbo_index", rp.pboIndex).
436+ Msg("cef: uploadToPBO end")
437+ }
351438 return uploadedBytes
352439 }
353440
@@ -553,6 +640,8 @@ func (rp *renderPipeline) onResize(width, height int32) {
553640
554641 rp.width = scaled(width)
555642 rp.height = scaled(height)
643+ rp.widthAtomic.Store(rp.width)
644+ rp.heightAtomic.Store(rp.height)
556645 rp.sizeChanged = true
557646
558647 firstCB := rp.onFirstResize
@@ -571,6 +660,70 @@ func (rp *renderPipeline) onResize(width, height int32) {
571660 }
572661 }
573662
663+func (rp *renderPipeline) viewRectSize() (width, height, scale int32) {
664+ width = rp.widthAtomic.Load()
665+ height = rp.heightAtomic.Load()
666+ if width <= 0 || height <= 0 {
667+ rp.mu.Lock()
668+ width = rp.width
669+ height = rp.height
670+ rp.mu.Unlock()
671+ }
672+ scale = rp.scale
673+ if scale <= 0 {
674+ scale = 1
675+ }
676+ return width, height, scale
677+}
678+
679+func (rp *renderPipeline) nextViewRectSeq() uint64 {
680+ return rp.viewRectSeq.Add(1)
681+}
682+
683+func (rp *renderPipeline) nextScreenInfoSeq() uint64 {
684+ return rp.screenInfoSeq.Add(1)
685+}
686+
687+func (rp *renderPipeline) nextPaintSeq() uint64 {
688+ return rp.paintSeq.Add(1)
689+}
690+
691+func clampDirtyRect(r rect, width, height int32) (rect, bool) {
692+ if width <= 0 || height <= 0 || r.Width <= 0 || r.Height <= 0 {
693+ return rect{}, false
694+ }
695+
696+ x0 := maxInt32(r.X, 0)
697+ y0 := maxInt32(r.Y, 0)
698+ x1 := minInt32(r.X+r.Width, width)
699+ y1 := minInt32(r.Y+r.Height, height)
700+
701+ if x1 <= x0 || y1 <= y0 {
702+ return rect{}, false
703+ }
704+
705+ return rect{
706+ X: x0,
707+ Y: y0,
708+ Width: x1 - x0,
709+ Height: y1 - y0,
710+ }, true
711+}
712+
713+func minInt32(a, b int32) int32 {
714+ if a < b {
715+ return a
716+ }
717+ return b
718+}
719+
720+func maxInt32(a, b int32) int32 {
721+ if a > b {
722+ return a
723+ }
724+ return b
725+}
726+
574727 // destroy releases all GL resources.
575728 func (rp *renderPipeline) destroy() {
576729 if !rp.glReady {
@@ -3,6 +3,7 @@ package cef
3 import (3 import (
4 "context"4 "context"
5 "sync"5 "sync"
6+ "sync/atomic"
6 "time"7 "time"
7 "unsafe"8 "unsafe"
8 9
@@ -40,6 +41,10 @@ type renderPipeline struct {
40 height int3241 height int32
41 scale int3242 scale int32
42 43
44+ // geometry mirrors the current surface size for lock-free CEF callbacks.
45+ widthAtomic atomic.Int32
46+ heightAtomic atomic.Int32
47+
43 // Staging: handlePaint copies dirty rects here, render signal uploads.48 // Staging: handlePaint copies dirty rects here, render signal uploads.
44 mu sync.Mutex49 mu sync.Mutex
45 staging []byte50 staging []byte
@@ -51,6 +56,11 @@ type renderPipeline struct {
51 glReady bool56 glReady bool
52 57
53 // Diagnostics (main thread only).58 // Diagnostics (main thread only).
59+ viewRectSeq atomic.Uint64
60+ screenInfoSeq atomic.Uint64
61+ paintSeq atomic.Uint64
62+ lastQueuedPaintSeq atomic.Uint64
63+ glRenderSeq atomic.Uint64
54 paintCount uint6464 paintCount uint64
55 acceleratedPaintCount uint6465 acceleratedPaintCount uint64
56 queueRenderCount uint6466 queueRenderCount uint64
@@ -151,30 +161,50 @@ func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32) *renderPi
151 // handlePaint is called from CEF's OnPaint callback (on GTK main thread via161 // handlePaint is called from CEF's OnPaint callback (on GTK main thread via
152 // IdleAdd). It copies dirty rect regions from the CEF buffer into the staging162 // IdleAdd). It copies dirty rect regions from the CEF buffer into the staging
153 // buffer and queues a GL redraw.163 // buffer and queues a GL redraw.
154-func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect) {164+func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect, paintSeq uint64) {
155 if len(buffer) == 0 || width <= 0 || height <= 0 {165 if len(buffer) == 0 || width <= 0 || height <= 0 {
156 return166 return
157 }167 }
158 168
159 startedAt := time.Now()169 startedAt := time.Now()
170+ rp.lastQueuedPaintSeq.Store(paintSeq)
171+ if rp.ctx != nil {
172+ logging.FromContext(rp.ctx).Debug().
173+ Uint64("paint_seq", paintSeq).
174+ Int32("width", width).
175+ Int32("height", height).
176+ Int("rect_count", len(rects)).
177+ Int("buffer_len", len(buffer)).
178+ Msg("cef: handlePaint begin")
179+ }
160 rp.mu.Lock()180 rp.mu.Lock()
161 181
162 bufSize := int(int64(width) * int64(height) * 4)182 bufSize := int(int64(width) * int64(height) * 4)
163 copiedBytes := uint64(0)183 copiedBytes := uint64(0)
184+ sizeChanged := false
164 185
165 // Detect size change, or first paint (staging not yet allocated).186 // Detect size change, or first paint (staging not yet allocated).
166 if width != rp.width || height != rp.height || rp.staging == nil {187 if width != rp.width || height != rp.height || rp.staging == nil {
167 rp.width = width188 rp.width = width
168 rp.height = height189 rp.height = height
190+ rp.widthAtomic.Store(width)
191+ rp.heightAtomic.Store(height)
169 rp.staging = make([]byte, bufSize)192 rp.staging = make([]byte, bufSize)
170 rp.sizeChanged = true193 rp.sizeChanged = true
194+ sizeChanged = true
171 // On size change, copy the entire buffer.195 // On size change, copy the entire buffer.
172 copy(rp.staging, buffer)196 copy(rp.staging, buffer)
173 copiedBytes = uint64(bufSize)197 copiedBytes = uint64(bufSize)
174 } else {198 } else {
175 // Copy only dirty rect rows.199 // Copy only dirty rect rows.
176 stride := int(width) * 4200 stride := int(width) * 4
177- for _, r := range rects {201+ sanitized := make([]rect, 0, len(rects))
202+ for _, rawRect := range rects {
203+ r, ok := clampDirtyRect(rawRect, width, height)
204+ if !ok {
205+ continue
206+ }
207+ sanitized = append(sanitized, r)
178 for row := r.Y; row < r.Y+r.Height; row++ {208 for row := r.Y; row < r.Y+r.Height; row++ {
179 srcOff := int(row)*stride + int(r.X)*4209 srcOff := int(row)*stride + int(r.X)*4
180 dstOff := srcOff210 dstOff := srcOff
@@ -183,6 +213,7 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
183 copiedBytes += uint64(rowBytes)213 copiedBytes += uint64(rowBytes)
184 }214 }
185 }215 }
216+ rects = sanitized
186 }217 }
187 218
188 // Accumulate dirty rects across multiple OnPaint calls between renders.219 // Accumulate dirty rects across multiple OnPaint calls between renders.
@@ -205,12 +236,28 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects
205 }236 }
206 237
207 rp.glArea.QueueRender()238 rp.glArea.QueueRender()
239+ if rp.ctx != nil {
240+ logging.FromContext(rp.ctx).Debug().
241+ Uint64("paint_seq", paintSeq).
242+ Uint64("copied_bytes", copiedBytes).
243+ Bool("size_changed", sizeChanged).
244+ Dur("copy_duration", copyDuration).
245+ Msg("cef: handlePaint end")
246+ }
208 rp.maybeLogDiagnostics()247 rp.maybeLogDiagnostics()
209 }248 }
210 249
211 // onGLRender is the GTK "render" signal handler. GL context is current.250 // onGLRender is the GTK "render" signal handler. GL context is current.
212 func (rp *renderPipeline) onGLRender() bool {251 func (rp *renderPipeline) onGLRender() bool {
252+ renderSeq := rp.glRenderSeq.Add(1)
253+ paintSeq := rp.lastQueuedPaintSeq.Load()
213 renderStartedAt := time.Now()254 renderStartedAt := time.Now()
255+ if rp.ctx != nil {
256+ logging.FromContext(rp.ctx).Debug().
257+ Uint64("render_seq", renderSeq).
258+ Uint64("paint_seq", paintSeq).
259+ Msg("cef: onGLRender begin")
260+ }
214 rp.mu.Lock()261 rp.mu.Lock()
215 262
216 needsUpload := rp.needsUpload263 needsUpload := rp.needsUpload
@@ -246,7 +293,7 @@ func (rp *renderPipeline) onGLRender() bool {
246 293
247 if needsUpload {294 if needsUpload {
248 uploadStartedAt := time.Now()295 uploadStartedAt := time.Now()
249- uploadedBytes := rp.uploadToPBO(dirtyRects, sizeChanged)296+ uploadedBytes := rp.uploadToPBO(dirtyRects, sizeChanged, renderSeq, paintSeq)
250 uploadDuration := time.Since(uploadStartedAt)297 uploadDuration := time.Since(uploadStartedAt)
251 rp.uploadCount++298 rp.uploadCount++
252 if sizeChanged {299 if sizeChanged {
@@ -277,19 +324,35 @@ func (rp *renderPipeline) onGLRender() bool {
277 rp.maxRenderNs = renderDuration.Nanoseconds()324 rp.maxRenderNs = renderDuration.Nanoseconds()
278 }325 }
279 rp.maybeLogDiagnostics()326 rp.maybeLogDiagnostics()
327+ if rp.ctx != nil {
328+ logging.FromContext(rp.ctx).Debug().
329+ Uint64("render_seq", renderSeq).
330+ Uint64("paint_seq", paintSeq).
331+ Dur("render_duration", renderDuration).
332+ Bool("needs_upload", needsUpload).
333+ Bool("size_changed", sizeChanged).
334+ Msg("cef: onGLRender end")
335+ }
280 336
281 return true337 return true
282 }338 }
283 339
284 // uploadToPBO uploads dirty regions from the staging buffer into a PBO, then340 // uploadToPBO uploads dirty regions from the staging buffer into a PBO, then
285 // transfers to the GL texture via async DMA.341 // transfers to the GL texture via async DMA.
286-func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64 {342+func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, renderSeq, paintSeq uint64) uint64 {
287 gl := rp.gl343 gl := rp.gl
288 344
289- rp.mu.Lock()345+ w, h, _ := rp.viewRectSize()
290- w := rp.width346+ if rp.ctx != nil {
291- h := rp.height347+ logging.FromContext(rp.ctx).Debug().
292- rp.mu.Unlock()348+ Uint64("render_seq", renderSeq).
349+ Uint64("paint_seq", paintSeq).
350+ Int32("width", w).
351+ Int32("height", h).
352+ Int("dirty_rect_count", len(dirtyRects)).
353+ Bool("full_upload", fullUpload).
354+ Msg("cef: uploadToPBO begin")
355+ }
293 356
294 bufSize := int64(w) * int64(h) * 4357 bufSize := int64(w) * int64(h) * 4
295 stride := int(w) * 4358 stride := int(w) * 4
@@ -313,7 +376,11 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64
313 copy(mappedSlice, rp.staging)376 copy(mappedSlice, rp.staging)
314 uploadedBytes = uint64(bufSize)377 uploadedBytes = uint64(bufSize)
315 } else {378 } else {
316- for _, r := range dirtyRects {379+ for _, rawRect := range dirtyRects {
380+ r, ok := clampDirtyRect(rawRect, w, h)
381+ if !ok {
382+ continue
383+ }
317 for row := r.Y; row < r.Y+r.Height; row++ {384 for row := r.Y; row < r.Y+r.Height; row++ {
318 off := int(row)*stride + int(r.X)*4385 off := int(row)*stride + int(r.X)*4
319 rowBytes := int(r.Width) * 4386 rowBytes := int(r.Width) * 4
@@ -324,7 +391,15 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64
324 }391 }
325 rp.mu.Unlock()392 rp.mu.Unlock()
326 393
327- gl.unmapBuffer(glPixelUnpackBuffer)394+ if !gl.unmapBuffer(glPixelUnpackBuffer) {
395+ logging.FromContext(rp.ctx).Error().
396+ Int32("width", w).
397+ Int32("height", h).
398+ Bool("full_upload", fullUpload).
399+ Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame")
400+ gl.bindBuffer(glPixelUnpackBuffer, 0)
401+ return 0
402+ }
328 403
329 // Transfer PBO → texture. With PBO bound, the last arg is a byte offset.404 // Transfer PBO → texture. With PBO bound, the last arg is a byte offset.
330 gl.bindTexture(glTexture2D, rp.texture)405 gl.bindTexture(glTexture2D, rp.texture)
@@ -335,7 +410,11 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64
335 // stride, allowing one glTexSubImage2D per dirty rect instead of410 // stride, allowing one glTexSubImage2D per dirty rect instead of
336 // one per row.411 // one per row.
337 gl.pixelStorei(glUnpackRowLength, w)412 gl.pixelStorei(glUnpackRowLength, w)
338- for _, r := range dirtyRects {413+ for _, rawRect := range dirtyRects {
414+ r, ok := clampDirtyRect(rawRect, w, h)
415+ if !ok {
416+ continue
417+ }
339 offset := uintptr(int(r.Y)*stride + int(r.X)*4)418 offset := uintptr(int(r.Y)*stride + int(r.X)*4)
340 gl.texSubImage2D(glTexture2D, 0, r.X, r.Y, r.Width, r.Height,419 gl.texSubImage2D(glTexture2D, 0, r.X, r.Y, r.Width, r.Height,
341 glBGRA, glUnsignedByte, pboOffset(offset))420 glBGRA, glUnsignedByte, pboOffset(offset))
@@ -348,6 +427,14 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64
348 rp.pboIndex = 1 - rp.pboIndex427 rp.pboIndex = 1 - rp.pboIndex
349 428
350 gl.bindBuffer(glPixelUnpackBuffer, 0)429 gl.bindBuffer(glPixelUnpackBuffer, 0)
430+ if rp.ctx != nil {
431+ logging.FromContext(rp.ctx).Debug().
432+ Uint64("render_seq", renderSeq).
433+ Uint64("paint_seq", paintSeq).
434+ Uint64("uploaded_bytes", uploadedBytes).
435+ Int("next_pbo_index", rp.pboIndex).
436+ Msg("cef: uploadToPBO end")
437+ }
351 return uploadedBytes438 return uploadedBytes
352 }439 }
353 440
@@ -553,6 +640,8 @@ func (rp *renderPipeline) onResize(width, height int32) {
553 640
554 rp.width = scaled(width)641 rp.width = scaled(width)
555 rp.height = scaled(height)642 rp.height = scaled(height)
643+ rp.widthAtomic.Store(rp.width)
644+ rp.heightAtomic.Store(rp.height)
556 rp.sizeChanged = true645 rp.sizeChanged = true
557 646
558 firstCB := rp.onFirstResize647 firstCB := rp.onFirstResize
@@ -571,6 +660,70 @@ func (rp *renderPipeline) onResize(width, height int32) {
571 }660 }
572 }661 }
573 662
663+func (rp *renderPipeline) viewRectSize() (width, height, scale int32) {
664+ width = rp.widthAtomic.Load()
665+ height = rp.heightAtomic.Load()
666+ if width <= 0 || height <= 0 {
667+ rp.mu.Lock()
668+ width = rp.width
669+ height = rp.height
670+ rp.mu.Unlock()
671+ }
672+ scale = rp.scale
673+ if scale <= 0 {
674+ scale = 1
675+ }
676+ return width, height, scale
677+}
678+
679+func (rp *renderPipeline) nextViewRectSeq() uint64 {
680+ return rp.viewRectSeq.Add(1)
681+}
682+
683+func (rp *renderPipeline) nextScreenInfoSeq() uint64 {
684+ return rp.screenInfoSeq.Add(1)
685+}
686+
687+func (rp *renderPipeline) nextPaintSeq() uint64 {
688+ return rp.paintSeq.Add(1)
689+}
690+
691+func clampDirtyRect(r rect, width, height int32) (rect, bool) {
692+ if width <= 0 || height <= 0 || r.Width <= 0 || r.Height <= 0 {
693+ return rect{}, false
694+ }
695+
696+ x0 := maxInt32(r.X, 0)
697+ y0 := maxInt32(r.Y, 0)
698+ x1 := minInt32(r.X+r.Width, width)
699+ y1 := minInt32(r.Y+r.Height, height)
700+
701+ if x1 <= x0 || y1 <= y0 {
702+ return rect{}, false
703+ }
704+
705+ return rect{
706+ X: x0,
707+ Y: y0,
708+ Width: x1 - x0,
709+ Height: y1 - y0,
710+ }, true
711+}
712+
713+func minInt32(a, b int32) int32 {
714+ if a < b {
715+ return a
716+ }
717+ return b
718+}
719+
720+func maxInt32(a, b int32) int32 {
721+ if a > b {
722+ return a
723+ }
724+ return b
725+}
726+
574 // destroy releases all GL resources.727 // destroy releases all GL resources.
575 func (rp *renderPipeline) destroy() {728 func (rp *renderPipeline) destroy() {
576 if !rp.glReady {729 if !rp.glReady {
M internal/infrastructure/cef/webview.go +16 -8
@@ -90,6 +90,14 @@ type pendingBrowserCreate struct {
9090 settings *purecef.BrowserSettings
9191 }
9292
93+type cefTaskFunc func()
94+
95+func (fn cefTaskFunc) Execute() {
96+ if fn != nil {
97+ fn()
98+ }
99+}
100+
93101 // ---------------------------------------------------------------------------
94102 // Identity
95103 // ---------------------------------------------------------------------------
@@ -549,14 +557,6 @@ func (wv *WebView) updateHoverURI(uri string) {
549557 wv.mu.Unlock()
550558 }
551559
552-type cefTaskFunc func()
553-
554-func (f cefTaskFunc) Execute() {
555- if f != nil {
556- f()
557- }
558-}
559-
560560 func (wv *WebView) scheduleZoomRefresh() {
561561 for _, delayMs := range [...]int64{16, 48} {
562562 purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {
@@ -679,3 +679,11 @@ func (wv *WebView) runOnGTK(fn func()) {
679679 }
680680 glib.IdleAddOnce(cb, 0)
681681 }
682+
683+func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
684+ wv.mu.Lock()
685+ defer wv.mu.Unlock()
686+ pc := wv.pendingCreate
687+ wv.pendingCreate = nil
688+ return pc
689+}
@@ -90,6 +90,14 @@ type pendingBrowserCreate struct {
90 settings *purecef.BrowserSettings90 settings *purecef.BrowserSettings
91 }91 }
92 92
93+type cefTaskFunc func()
94+
95+func (fn cefTaskFunc) Execute() {
96+ if fn != nil {
97+ fn()
98+ }
99+}
100+
93 // ---------------------------------------------------------------------------101 // ---------------------------------------------------------------------------
94 // Identity102 // Identity
95 // ---------------------------------------------------------------------------103 // ---------------------------------------------------------------------------
@@ -549,14 +557,6 @@ func (wv *WebView) updateHoverURI(uri string) {
549 wv.mu.Unlock()557 wv.mu.Unlock()
550 }558 }
551 559
552-type cefTaskFunc func()
553-
554-func (f cefTaskFunc) Execute() {
555- if f != nil {
556- f()
557- }
558-}
559-
560 func (wv *WebView) scheduleZoomRefresh() {560 func (wv *WebView) scheduleZoomRefresh() {
561 for _, delayMs := range [...]int64{16, 48} {561 for _, delayMs := range [...]int64{16, 48} {
562 purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {562 purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() {
@@ -679,3 +679,11 @@ func (wv *WebView) runOnGTK(fn func()) {
679 }679 }
680 glib.IdleAddOnce(cb, 0)680 glib.IdleAddOnce(cb, 0)
681 }681 }
682+
683+func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
684+ wv.mu.Lock()
685+ defer wv.mu.Unlock()
686+ pc := wv.pendingCreate
687+ wv.pendingCreate = nil
688+ return pc
689+}
M internal/infrastructure/cef/webview_handlers.go +47 -19
@@ -79,18 +79,12 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
7979 if rect == nil {
8080 return
8181 }
82- h.wv.pipeline.mu.Lock()
83- w := h.wv.pipeline.width
84- ht := h.wv.pipeline.height
85- s := h.wv.pipeline.scale
86- h.wv.pipeline.mu.Unlock()
82+ callSeq := h.wv.pipeline.nextViewRectSeq()
83+ w, ht, s := h.wv.pipeline.viewRectSize()
8784
8885 // CEF expects view geometry in DIP coordinates while OnPaint dimensions are
8986 // in device pixels. The render pipeline tracks device pixels, so convert
9087 // back to DIP before answering GetViewRect/GetScreenInfo.
91- if s <= 0 {
92- s = 1
93- }
9488 w /= s
9589 ht /= s
9690
@@ -107,6 +101,14 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
107101 rect.Y = 0
108102 rect.Width = w
109103 rect.Height = ht
104+ if h.wv != nil && h.wv.ctx != nil {
105+ logging.FromContext(h.wv.ctx).Debug().
106+ Uint64("view_rect_seq", callSeq).
107+ Int32("width", rect.Width).
108+ Int32("height", rect.Height).
109+ Int32("scale", s).
110+ Msg("cef: GetViewRect")
111+ }
110112 }
111113
112114 func (h *handlerSet) GetScreenPoint(_ purecef.Browser, _, _ int32, _, _ *int32) int32 {
@@ -117,14 +119,8 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo)
117119 if info == nil {
118120 return 0
119121 }
120- h.wv.pipeline.mu.Lock()
121- w := h.wv.pipeline.width
122- ht := h.wv.pipeline.height
123- s := h.wv.pipeline.scale
124- h.wv.pipeline.mu.Unlock()
125- if s <= 0 {
126- s = 1
127- }
122+ callSeq := h.wv.pipeline.nextScreenInfoSeq()
123+ w, ht, s := h.wv.pipeline.viewRectSize()
128124 w /= s
129125 ht /= s
130126 if w <= 0 {
@@ -140,6 +136,14 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo)
140136 )
141137 r := purecef.Rect{X: 0, Y: 0, Width: w, Height: ht}
142138 *info = purecef.NewScreenInfo(float32(s), screenDepth, screenDepthPerComponent, false, r, r)
139+ if h.wv != nil && h.wv.ctx != nil {
140+ logging.FromContext(h.wv.ctx).Debug().
141+ Uint64("screen_info_seq", callSeq).
142+ Int32("width", w).
143+ Int32("height", ht).
144+ Int32("scale", s).
145+ Msg("cef: GetScreenInfo")
146+ }
143147 return 1
144148 }
145149
@@ -157,6 +161,16 @@ func (h *handlerSet) OnPaint(
157161 if len(buffer) == 0 || width <= 0 || height <= 0 {
158162 return
159163 }
164+ paintSeq := h.wv.pipeline.nextPaintSeq()
165+ if h.wv != nil && h.wv.ctx != nil {
166+ logging.FromContext(h.wv.ctx).Debug().
167+ Uint64("paint_seq", paintSeq).
168+ Int32("width", width).
169+ Int32("height", height).
170+ Int("dirty_rect_count", len(dirtyRects)).
171+ Int("buffer_len", len(buffer)).
172+ Msg("cef: OnPaint begin")
173+ }
160174 rects := make([]rect, len(dirtyRects))
161175 for i, dr := range dirtyRects {
162176 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}
@@ -165,11 +179,21 @@ func (h *handlerSet) OnPaint(
165179 pixels := make([]byte, len(buffer))
166180 copy(pixels, buffer)
167181 h.wv.runOnGTK(func() {
168- h.wv.pipeline.handlePaint(pixels, width, height, rects)
182+ h.wv.pipeline.handlePaint(pixels, width, height, rects, paintSeq)
169183 })
184+ if h.wv != nil && h.wv.ctx != nil {
185+ logging.FromContext(h.wv.ctx).Debug().
186+ Uint64("paint_seq", paintSeq).
187+ Msg("cef: OnPaint queued to GTK")
188+ }
170189 return
171190 }
172- h.wv.pipeline.handlePaint(buffer, width, height, rects)
191+ h.wv.pipeline.handlePaint(buffer, width, height, rects, paintSeq)
192+ if h.wv != nil && h.wv.ctx != nil {
193+ logging.FromContext(h.wv.ctx).Debug().
194+ Uint64("paint_seq", paintSeq).
195+ Msg("cef: OnPaint handled inline")
196+ }
173197 }
174198
175199 func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) {
@@ -342,8 +366,12 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
342366 h.wv.crashCount.Store(0)
343367
344368 // Inject scripts and styles after page load.
369+ // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,
370+ // and JavaScript injection requires the main thread.
345371 if h.wv.engine != nil && h.wv.engine.contentInj != nil {
346- h.wv.engine.contentInj.onLoadEnd(h.wv)
372+ h.wv.runOnGTK(func() {
373+ h.wv.engine.contentInj.onLoadEnd(h.wv)
374+ })
347375 }
348376
349377 h.wv.mu.RLock()
@@ -79,18 +79,12 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
79 if rect == nil {79 if rect == nil {
80 return80 return
81 }81 }
82- h.wv.pipeline.mu.Lock()82+ callSeq := h.wv.pipeline.nextViewRectSeq()
83- w := h.wv.pipeline.width83+ w, ht, s := h.wv.pipeline.viewRectSize()
84- ht := h.wv.pipeline.height
85- s := h.wv.pipeline.scale
86- h.wv.pipeline.mu.Unlock()
87 84
88 // CEF expects view geometry in DIP coordinates while OnPaint dimensions are85 // CEF expects view geometry in DIP coordinates while OnPaint dimensions are
89 // in device pixels. The render pipeline tracks device pixels, so convert86 // in device pixels. The render pipeline tracks device pixels, so convert
90 // back to DIP before answering GetViewRect/GetScreenInfo.87 // back to DIP before answering GetViewRect/GetScreenInfo.
91- if s <= 0 {
92- s = 1
93- }
94 w /= s88 w /= s
95 ht /= s89 ht /= s
96 90
@@ -107,6 +101,14 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) {
107 rect.Y = 0101 rect.Y = 0
108 rect.Width = w102 rect.Width = w
109 rect.Height = ht103 rect.Height = ht
104+ if h.wv != nil && h.wv.ctx != nil {
105+ logging.FromContext(h.wv.ctx).Debug().
106+ Uint64("view_rect_seq", callSeq).
107+ Int32("width", rect.Width).
108+ Int32("height", rect.Height).
109+ Int32("scale", s).
110+ Msg("cef: GetViewRect")
111+ }
110 }112 }
111 113
112 func (h *handlerSet) GetScreenPoint(_ purecef.Browser, _, _ int32, _, _ *int32) int32 {114 func (h *handlerSet) GetScreenPoint(_ purecef.Browser, _, _ int32, _, _ *int32) int32 {
@@ -117,14 +119,8 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo)
117 if info == nil {119 if info == nil {
118 return 0120 return 0
119 }121 }
120- h.wv.pipeline.mu.Lock()122+ callSeq := h.wv.pipeline.nextScreenInfoSeq()
121- w := h.wv.pipeline.width123+ w, ht, s := h.wv.pipeline.viewRectSize()
122- ht := h.wv.pipeline.height
123- s := h.wv.pipeline.scale
124- h.wv.pipeline.mu.Unlock()
125- if s <= 0 {
126- s = 1
127- }
128 w /= s124 w /= s
129 ht /= s125 ht /= s
130 if w <= 0 {126 if w <= 0 {
@@ -140,6 +136,14 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo)
140 )136 )
141 r := purecef.Rect{X: 0, Y: 0, Width: w, Height: ht}137 r := purecef.Rect{X: 0, Y: 0, Width: w, Height: ht}
142 *info = purecef.NewScreenInfo(float32(s), screenDepth, screenDepthPerComponent, false, r, r)138 *info = purecef.NewScreenInfo(float32(s), screenDepth, screenDepthPerComponent, false, r, r)
139+ if h.wv != nil && h.wv.ctx != nil {
140+ logging.FromContext(h.wv.ctx).Debug().
141+ Uint64("screen_info_seq", callSeq).
142+ Int32("width", w).
143+ Int32("height", ht).
144+ Int32("scale", s).
145+ Msg("cef: GetScreenInfo")
146+ }
143 return 1147 return 1
144 }148 }
145 149
@@ -157,6 +161,16 @@ func (h *handlerSet) OnPaint(
157 if len(buffer) == 0 || width <= 0 || height <= 0 {161 if len(buffer) == 0 || width <= 0 || height <= 0 {
158 return162 return
159 }163 }
164+ paintSeq := h.wv.pipeline.nextPaintSeq()
165+ if h.wv != nil && h.wv.ctx != nil {
166+ logging.FromContext(h.wv.ctx).Debug().
167+ Uint64("paint_seq", paintSeq).
168+ Int32("width", width).
169+ Int32("height", height).
170+ Int("dirty_rect_count", len(dirtyRects)).
171+ Int("buffer_len", len(buffer)).
172+ Msg("cef: OnPaint begin")
173+ }
160 rects := make([]rect, len(dirtyRects))174 rects := make([]rect, len(dirtyRects))
161 for i, dr := range dirtyRects {175 for i, dr := range dirtyRects {
162 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}176 rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height}
@@ -165,11 +179,21 @@ func (h *handlerSet) OnPaint(
165 pixels := make([]byte, len(buffer))179 pixels := make([]byte, len(buffer))
166 copy(pixels, buffer)180 copy(pixels, buffer)
167 h.wv.runOnGTK(func() {181 h.wv.runOnGTK(func() {
168- h.wv.pipeline.handlePaint(pixels, width, height, rects)182+ h.wv.pipeline.handlePaint(pixels, width, height, rects, paintSeq)
169 })183 })
184+ if h.wv != nil && h.wv.ctx != nil {
185+ logging.FromContext(h.wv.ctx).Debug().
186+ Uint64("paint_seq", paintSeq).
187+ Msg("cef: OnPaint queued to GTK")
188+ }
170 return189 return
171 }190 }
172- h.wv.pipeline.handlePaint(buffer, width, height, rects)191+ h.wv.pipeline.handlePaint(buffer, width, height, rects, paintSeq)
192+ if h.wv != nil && h.wv.ctx != nil {
193+ logging.FromContext(h.wv.ctx).Debug().
194+ Uint64("paint_seq", paintSeq).
195+ Msg("cef: OnPaint handled inline")
196+ }
173 }197 }
174 198
175 func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) {199 func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) {
@@ -342,8 +366,12 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
342 h.wv.crashCount.Store(0)366 h.wv.crashCount.Store(0)
343 367
344 // Inject scripts and styles after page load.368 // Inject scripts and styles after page load.
369+ // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,
370+ // and JavaScript injection requires the main thread.
345 if h.wv.engine != nil && h.wv.engine.contentInj != nil {371 if h.wv.engine != nil && h.wv.engine.contentInj != nil {
346- h.wv.engine.contentInj.onLoadEnd(h.wv)372+ h.wv.runOnGTK(func() {
373+ h.wv.engine.contentInj.onLoadEnd(h.wv)
374+ })
347 }375 }
348 376
349 h.wv.mu.RLock()377 h.wv.mu.RLock()
M internal/infrastructure/favicon/cache.go +16 -0
@@ -1,11 +1,13 @@
11 package favicon
22
33 import (
4+ "context"
45 "os"
56 "path/filepath"
67 "sync"
78
89 domainurl "github.com/bnema/dumber/internal/domain/url"
10+ "github.com/bnema/dumber/internal/logging"
911 )
1012
1113 const (
@@ -55,12 +57,17 @@ func (c *Cache) Get(domain string) ([]byte, bool) {
5557 if domain == "" {
5658 return nil, false
5759 }
60+ log := logging.FromContext(context.Background())
5861
5962 // Check memory cache first
6063 c.mu.RLock()
6164 data, ok := c.memCache[domain]
6265 c.mu.RUnlock()
6366 if ok {
67+ log.Debug().
68+ Str("domain", domain).
69+ Int("bytes", len(data)).
70+ Msg("favicon: Cache.Get memory hit")
6471 return data, true
6572 }
6673
@@ -71,9 +78,14 @@ func (c *Cache) Get(domain string) ([]byte, bool) {
7178 c.mu.Lock()
7279 c.memCache[domain] = data
7380 c.mu.Unlock()
81+ log.Debug().
82+ Str("domain", domain).
83+ Int("bytes", len(data)).
84+ Msg("favicon: Cache.Get disk hit")
7485 return data, true
7586 }
7687
88+ log.Debug().Str("domain", domain).Msg("favicon: Cache.Get miss")
7789 return nil, false
7890 }
7991
@@ -83,6 +95,10 @@ func (c *Cache) Set(domain string, data []byte) {
8395 if domain == "" || len(data) == 0 {
8496 return
8597 }
98+ logging.FromContext(context.Background()).Debug().
99+ Str("domain", domain).
100+ Int("bytes", len(data)).
101+ Msg("favicon: Cache.Set")
86102
87103 // Write to memory cache
88104 c.mu.Lock()
@@ -1,11 +1,13 @@
1 package favicon1 package favicon
2 2
3 import (3 import (
4+ "context"
4 "os"5 "os"
5 "path/filepath"6 "path/filepath"
6 "sync"7 "sync"
7 8
8 domainurl "github.com/bnema/dumber/internal/domain/url"9 domainurl "github.com/bnema/dumber/internal/domain/url"
10+ "github.com/bnema/dumber/internal/logging"
9 )11 )
10 12
11 const (13 const (
@@ -55,12 +57,17 @@ func (c *Cache) Get(domain string) ([]byte, bool) {
55 if domain == "" {57 if domain == "" {
56 return nil, false58 return nil, false
57 }59 }
60+ log := logging.FromContext(context.Background())
58 61
59 // Check memory cache first62 // Check memory cache first
60 c.mu.RLock()63 c.mu.RLock()
61 data, ok := c.memCache[domain]64 data, ok := c.memCache[domain]
62 c.mu.RUnlock()65 c.mu.RUnlock()
63 if ok {66 if ok {
67+ log.Debug().
68+ Str("domain", domain).
69+ Int("bytes", len(data)).
70+ Msg("favicon: Cache.Get memory hit")
64 return data, true71 return data, true
65 }72 }
66 73
@@ -71,9 +78,14 @@ func (c *Cache) Get(domain string) ([]byte, bool) {
71 c.mu.Lock()78 c.mu.Lock()
72 c.memCache[domain] = data79 c.memCache[domain] = data
73 c.mu.Unlock()80 c.mu.Unlock()
81+ log.Debug().
82+ Str("domain", domain).
83+ Int("bytes", len(data)).
84+ Msg("favicon: Cache.Get disk hit")
74 return data, true85 return data, true
75 }86 }
76 87
88+ log.Debug().Str("domain", domain).Msg("favicon: Cache.Get miss")
77 return nil, false89 return nil, false
78 }90 }
79 91
@@ -83,6 +95,10 @@ func (c *Cache) Set(domain string, data []byte) {
83 if domain == "" || len(data) == 0 {95 if domain == "" || len(data) == 0 {
84 return96 return
85 }97 }
98+ logging.FromContext(context.Background()).Debug().
99+ Str("domain", domain).
100+ Int("bytes", len(data)).
101+ Msg("favicon: Cache.Set")
86 102
87 // Write to memory cache103 // Write to memory cache
88 c.mu.Lock()104 c.mu.Lock()
M internal/infrastructure/favicon/service.go +42 -5
@@ -37,20 +37,28 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
3737 if domain == "" {
3838 return nil, nil
3939 }
40+ log := logging.FromContext(ctx)
41+ log.Debug().Str("domain", domain).Msg("favicon: Service.Get begin")
4042
4143 // Internal domain returns the app logo
4244 if domain == InternalDomain {
45+ log.Debug().Str("domain", domain).Msg("favicon: Service.Get internal domain")
4346 return assets.LogoSVG, nil
4447 }
4548
4649 // Check cache first (memory + disk)
4750 if data, ok := s.cache.Get(domain); ok {
51+ log.Debug().
52+ Str("domain", domain).
53+ Int("bytes", len(data)).
54+ Msg("favicon: Service.Get cache hit")
4855 return data, nil
4956 }
5057
5158 // Fetch from external API
5259 data, err := s.fetcher.Fetch(ctx, domain)
5360 if err != nil {
61+ log.Debug().Err(err).Str("domain", domain).Msg("favicon: Service.Get fetch error")
5462 return nil, err
5563 }
5664
@@ -58,6 +66,10 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
5866 if len(data) > 0 {
5967 s.cache.Set(domain, data)
6068 }
69+ log.Debug().
70+ Str("domain", domain).
71+ Int("bytes", len(data)).
72+ Msg("favicon: Service.Get end")
6173
6274 return data, nil
6375 }
@@ -75,7 +87,13 @@ func GetLogoBytes() []byte {
7587
7688 // GetCached returns favicon bytes only if already cached (no external fetch).
7789 func (s *Service) GetCached(domain string) ([]byte, bool) {
78- return s.cache.Get(domain)
90+ data, ok := s.cache.Get(domain)
91+ logging.FromContext(context.Background()).Debug().
92+ Str("domain", domain).
93+ Bool("hit", ok).
94+ Int("bytes", len(data)).
95+ Msg("favicon: Service.GetCached")
96+ return data, ok
7997 }
8098
8199 // Store saves favicon bytes for a domain to cache.
@@ -115,14 +133,15 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {
115133 if domain == "" {
116134 return
117135 }
136+ log := logging.FromContext(ctx)
137+ log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache begin")
118138
119139 // Already on disk?
120140 if s.cache.HasOnDisk(domain) {
141+ log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache already on disk")
121142 return
122143 }
123144
124- log := logging.FromContext(ctx)
125-
126145 // Fetch and store
127146 data, err := s.fetcher.Fetch(ctx, domain)
128147 if err != nil {
@@ -133,6 +152,10 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {
133152 if len(data) > 0 {
134153 s.cache.Set(domain, data)
135154 }
155+ log.Debug().
156+ Str("domain", domain).
157+ Int("bytes", len(data)).
158+ Msg("favicon: EnsureDiskCache end")
136159 }
137160
138161 // DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
@@ -151,9 +174,18 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e
151174 if domain == "" {
152175 return nil
153176 }
177+ log := logging.FromContext(ctx)
178+ log.Debug().
179+ Str("domain", domain).
180+ Int("size", size).
181+ Msg("favicon: EnsureSizedPNG begin")
154182
155183 // Already exists?
156184 if s.HasPNGSizedOnDisk(domain, size) {
185+ log.Debug().
186+ Str("domain", domain).
187+ Int("size", size).
188+ Msg("favicon: EnsureSizedPNG already exists")
157189 return nil
158190 }
159191
@@ -168,10 +200,15 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e
168200 return fmt.Errorf("cannot determine destination path for domain %s", domain)
169201 }
170202
171- log := logging.FromContext(ctx)
172203 log.Debug().Str("domain", domain).Int("size", size).Msg("creating sized favicon")
173204
174- return ResizePNG(srcPath, dstPath, size)
205+ err := ResizePNG(srcPath, dstPath, size)
206+ log.Debug().
207+ Err(err).
208+ Str("domain", domain).
209+ Int("size", size).
210+ Msg("favicon: EnsureSizedPNG end")
211+ return err
175212 }
176213
177214 // EnsureInternalFaviconPNG ensures the internal app logo PNG exists on disk.
@@ -37,20 +37,28 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
37 if domain == "" {37 if domain == "" {
38 return nil, nil38 return nil, nil
39 }39 }
40+ log := logging.FromContext(ctx)
41+ log.Debug().Str("domain", domain).Msg("favicon: Service.Get begin")
40 42
41 // Internal domain returns the app logo43 // Internal domain returns the app logo
42 if domain == InternalDomain {44 if domain == InternalDomain {
45+ log.Debug().Str("domain", domain).Msg("favicon: Service.Get internal domain")
43 return assets.LogoSVG, nil46 return assets.LogoSVG, nil
44 }47 }
45 48
46 // Check cache first (memory + disk)49 // Check cache first (memory + disk)
47 if data, ok := s.cache.Get(domain); ok {50 if data, ok := s.cache.Get(domain); ok {
51+ log.Debug().
52+ Str("domain", domain).
53+ Int("bytes", len(data)).
54+ Msg("favicon: Service.Get cache hit")
48 return data, nil55 return data, nil
49 }56 }
50 57
51 // Fetch from external API58 // Fetch from external API
52 data, err := s.fetcher.Fetch(ctx, domain)59 data, err := s.fetcher.Fetch(ctx, domain)
53 if err != nil {60 if err != nil {
61+ log.Debug().Err(err).Str("domain", domain).Msg("favicon: Service.Get fetch error")
54 return nil, err62 return nil, err
55 }63 }
56 64
@@ -58,6 +66,10 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
58 if len(data) > 0 {66 if len(data) > 0 {
59 s.cache.Set(domain, data)67 s.cache.Set(domain, data)
60 }68 }
69+ log.Debug().
70+ Str("domain", domain).
71+ Int("bytes", len(data)).
72+ Msg("favicon: Service.Get end")
61 73
62 return data, nil74 return data, nil
63 }75 }
@@ -75,7 +87,13 @@ func GetLogoBytes() []byte {
75 87
76 // GetCached returns favicon bytes only if already cached (no external fetch).88 // GetCached returns favicon bytes only if already cached (no external fetch).
77 func (s *Service) GetCached(domain string) ([]byte, bool) {89 func (s *Service) GetCached(domain string) ([]byte, bool) {
78- return s.cache.Get(domain)90+ data, ok := s.cache.Get(domain)
91+ logging.FromContext(context.Background()).Debug().
92+ Str("domain", domain).
93+ Bool("hit", ok).
94+ Int("bytes", len(data)).
95+ Msg("favicon: Service.GetCached")
96+ return data, ok
79 }97 }
80 98
81 // Store saves favicon bytes for a domain to cache.99 // Store saves favicon bytes for a domain to cache.
@@ -115,14 +133,15 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {
115 if domain == "" {133 if domain == "" {
116 return134 return
117 }135 }
136+ log := logging.FromContext(ctx)
137+ log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache begin")
118 138
119 // Already on disk?139 // Already on disk?
120 if s.cache.HasOnDisk(domain) {140 if s.cache.HasOnDisk(domain) {
141+ log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache already on disk")
121 return142 return
122 }143 }
123 144
124- log := logging.FromContext(ctx)
125-
126 // Fetch and store145 // Fetch and store
127 data, err := s.fetcher.Fetch(ctx, domain)146 data, err := s.fetcher.Fetch(ctx, domain)
128 if err != nil {147 if err != nil {
@@ -133,6 +152,10 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {
133 if len(data) > 0 {152 if len(data) > 0 {
134 s.cache.Set(domain, data)153 s.cache.Set(domain, data)
135 }154 }
155+ log.Debug().
156+ Str("domain", domain).
157+ Int("bytes", len(data)).
158+ Msg("favicon: EnsureDiskCache end")
136 }159 }
137 160
138 // DiskPathPNGSized returns the filesystem path for a sized PNG favicon.161 // DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
@@ -151,9 +174,18 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e
151 if domain == "" {174 if domain == "" {
152 return nil175 return nil
153 }176 }
177+ log := logging.FromContext(ctx)
178+ log.Debug().
179+ Str("domain", domain).
180+ Int("size", size).
181+ Msg("favicon: EnsureSizedPNG begin")
154 182
155 // Already exists?183 // Already exists?
156 if s.HasPNGSizedOnDisk(domain, size) {184 if s.HasPNGSizedOnDisk(domain, size) {
185+ log.Debug().
186+ Str("domain", domain).
187+ Int("size", size).
188+ Msg("favicon: EnsureSizedPNG already exists")
157 return nil189 return nil
158 }190 }
159 191
@@ -168,10 +200,15 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e
168 return fmt.Errorf("cannot determine destination path for domain %s", domain)200 return fmt.Errorf("cannot determine destination path for domain %s", domain)
169 }201 }
170 202
171- log := logging.FromContext(ctx)
172 log.Debug().Str("domain", domain).Int("size", size).Msg("creating sized favicon")203 log.Debug().Str("domain", domain).Int("size", size).Msg("creating sized favicon")
173 204
174- return ResizePNG(srcPath, dstPath, size)205+ err := ResizePNG(srcPath, dstPath, size)
206+ log.Debug().
207+ Err(err).
208+ Str("domain", domain).
209+ Int("size", size).
210+ Msg("favicon: EnsureSizedPNG end")
211+ return err
175 }212 }
176 213
177 // EnsureInternalFaviconPNG ensures the internal app logo PNG exists on disk.214 // EnsureInternalFaviconPNG ensures the internal app logo PNG exists on disk.
M internal/infrastructure/persistence/sqlite/connection.go +1 -1
@@ -27,7 +27,7 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
2727 }
2828
2929 // Open database connection
30- db, err := sql.Open("sqlite3", dbPath)
30+ db, err := sql.Open("sqlite", dbPath)
3131 if err != nil {
3232 return nil, fmt.Errorf("failed to open database: %w", err)
3333 }
@@ -27,7 +27,7 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
27 }27 }
28 28
29 // Open database connection29 // Open database connection
30- db, err := sql.Open("sqlite3", dbPath)30+ db, err := sql.Open("sqlite", dbPath)
31 if err != nil {31 if err != nil {
32 return nil, fmt.Errorf("failed to open database: %w", err)32 return nil, fmt.Errorf("failed to open database: %w", err)
33 }33 }
M internal/ui/adapter/favicon.go +112 -5
@@ -3,6 +3,7 @@ package adapter
33
44 import (
55 "context"
6+ "os"
67 "sync"
78
89 "github.com/bnema/dumber/internal/application/port"
@@ -28,10 +29,17 @@ type FaviconAdapter struct {
2829 internalDomain string
2930 normalizedIconSize int
3031 getLogoBytes func() []byte
32+ disabled bool
3133 }
3234
35+const disableFaviconsEnv = "DUMBER_DISABLE_FAVICONS"
36+
3337 type warningLogFunc func(log *zerolog.Logger, err error)
3438
39+type textureResult struct {
40+ texture *gdk.Texture
41+}
42+
3543 // FaviconAdapterConfig holds optional configuration for FaviconAdapter.
3644 // Zero values disable the corresponding features (internal-URL logo, sized-PNG creation).
3745 type FaviconAdapterConfig struct {
@@ -48,6 +56,7 @@ type FaviconAdapterConfig struct {
4856 // NewFaviconAdapter creates a new FaviconAdapter.
4957 // The faviconDB can be nil if a favicon database is not available.
5058 func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {
59+ disabled := os.Getenv(disableFaviconsEnv) == "1"
5160 return &FaviconAdapter{
5261 service: service,
5362 faviconDB: faviconDB,
@@ -57,6 +66,7 @@ func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDataba
5766 internalDomain: cfg.InternalDomain,
5867 normalizedIconSize: cfg.NormalizedIconSize,
5968 getLogoBytes: cfg.GetLogoBytes,
69+ disabled: disabled,
6070 }
6171 }
6272
@@ -92,18 +102,33 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
92102 }
93103
94104 log := logging.FromContext(ctx)
105+ if a.disabled {
106+ log.Warn().
107+ Str("env", disableFaviconsEnv).
108+ Msg("favicon: UI favicon pipeline temporarily disabled for crash isolation")
109+ callback(nil)
110+ return
111+ }
95112
96113 // Handle internal dumb:// scheme URLs - use app logo
97114 if a.isInternalURL != nil && a.isInternalURL(pageURL) {
98- texture := a.getOrCreateLogoTexture()
115+ texture := a.textureFromBytesOnGTK(a.getLogoBytes())
116+ if texture != nil {
117+ a.setTexture(a.internalDomain, texture)
118+ }
99119 callback(texture)
100120 return
101121 }
102122
103123 domain := domainurl.ExtractDomain(pageURL)
124+ log.Debug().
125+ Str("page_url", pageURL).
126+ Str("domain", domain).
127+ Msg("favicon: GetOrFetch begin")
104128
105129 // Check texture cache first
106130 if texture := a.GetTexture(domain); texture != nil {
131+ log.Debug().Str("domain", domain).Msg("favicon: texture cache hit")
107132 // Ensure sized PNG exists for CLI tools (async, idempotent)
108133 go a.ensureSizedPNG(ctx, domain)
109134 callback(texture)
@@ -112,7 +137,11 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
112137
113138 // Check service cache (memory + disk) and convert to texture
114139 if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
115- texture := bytesToTexture(data)
140+ log.Debug().
141+ Str("domain", domain).
142+ Int("bytes", len(data)).
143+ Msg("favicon: service cache hit")
144+ texture := a.textureFromBytesOnGTK(data)
116145 if texture != nil {
117146 a.setTexture(domain, texture)
118147 // Ensure sized PNG exists for CLI tools (async, idempotent)
@@ -124,7 +153,15 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
124153
125154 // Helper to invoke callback on main thread
126155 invokeCallback := func(texture *gdk.Texture) {
156+ log.Debug().
157+ Str("domain", domain).
158+ Bool("texture_nil", texture == nil).
159+ Msg("favicon: scheduling callback on GTK")
127160 cb := glib.SourceFunc(func(uintptr) bool {
161+ log.Debug().
162+ Str("domain", domain).
163+ Bool("texture_nil", texture == nil).
164+ Msg("favicon: callback executing on GTK")
128165 callback(texture)
129166 return false
130167 })
@@ -134,16 +171,30 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
134171 // Helper to fetch via service and invoke callback
135172 fetchViaService := func() {
136173 go func() {
174+ log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin")
137175 data, err := a.service.Get(ctx, domain)
138176 if err != nil || len(data) == 0 {
177+ log.Debug().
178+ Err(err).
179+ Str("domain", domain).
180+ Int("bytes", len(data)).
181+ Msg("favicon: fetchViaService empty/error result")
139182 invokeCallback(nil)
140183 return
141184 }
142185
143- texture := bytesToTexture(data)
186+ log.Debug().
187+ Str("domain", domain).
188+ Int("bytes", len(data)).
189+ Msg("favicon: fetchViaService fetched data")
190+ texture := a.textureFromBytesOnGTK(data)
144191 if texture != nil {
145192 a.setTexture(domain, texture)
146193 }
194+ log.Debug().
195+ Str("domain", domain).
196+ Bool("texture_nil", texture == nil).
197+ Msg("favicon: fetchViaService end")
147198 invokeCallback(texture)
148199 }()
149200 }
@@ -151,6 +202,10 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
151202 // Try engine FaviconDatabase if available
152203 if a.faviconDB != nil {
153204 a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {
205+ log.Debug().
206+ Str("domain", domain).
207+ Bool("texture_nil", tex == nil).
208+ Msg("favicon: engine db callback")
154209 if tex == nil {
155210 log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service")
156211 fetchViaService()
@@ -170,12 +225,19 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
170225 }
171226
172227 // No WebKit DB, fetch via service directly
228+ log.Debug().Str("domain", domain).Msg("favicon: no engine db, fetching via service")
173229 fetchViaService()
174230 }
175231
176232 // StoreFromWebKit stores a favicon texture received from WebKit signals.
177233 // Also ensures the favicon is persisted to disk cache via the service.
178234 func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) {
235+ if a.disabled {
236+ logging.FromContext(ctx).Debug().
237+ Str("page_url", pageURL).
238+ Msg("favicon: StoreFromWebKit skipped because favicon pipeline is disabled")
239+ return
240+ }
179241 if texture == nil {
180242 return
181243 }
@@ -272,25 +334,43 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t
272334 // PreloadFromCache attempts to load a favicon from cache without fetching.
273335 // Returns the texture if found in memory or disk cache, nil otherwise.
274336 func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {
337+ if a.disabled {
338+ logging.FromContext(context.Background()).Debug().
339+ Str("page_url", pageURL).
340+ Str("env", disableFaviconsEnv).
341+ Msg("favicon: PreloadFromCache skipped because favicon pipeline is disabled")
342+ return nil
343+ }
275344 domain := domainurl.ExtractDomain(pageURL)
276345 if domain == "" {
277346 return nil
278347 }
348+ log := logging.FromContext(context.Background())
349+ log.Debug().
350+ Str("page_url", pageURL).
351+ Str("domain", domain).
352+ Msg("favicon: PreloadFromCache begin")
279353
280354 // Check texture cache
281355 if texture := a.GetTexture(domain); texture != nil {
356+ log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache texture hit")
282357 return texture
283358 }
284359
285360 // Check service cache (memory + disk)
286361 if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
287- texture := bytesToTexture(data)
362+ log.Debug().
363+ Str("domain", domain).
364+ Int("bytes", len(data)).
365+ Msg("favicon: PreloadFromCache service hit")
366+ texture := a.textureFromBytesOnGTK(data)
288367 if texture != nil {
289368 a.setTexture(domain, texture)
290369 return texture
291370 }
292371 }
293372
373+ log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache miss")
294374 return nil
295375 }
296376
@@ -414,13 +494,40 @@ func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
414494
415495 // Create texture from logo bytes
416496 logoBytes := a.getLogoBytes()
417- texture := bytesToTexture(logoBytes)
497+ texture := a.textureFromBytesOnGTK(logoBytes)
418498 if texture != nil {
419499 a.setTexture(a.internalDomain, texture)
420500 }
421501 return texture
422502 }
423503
504+func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture {
505+ if len(data) == 0 {
506+ return nil
507+ }
508+ log := logging.FromContext(context.Background())
509+ log.Debug().
510+ Int("bytes", len(data)).
511+ Msg("favicon: textureFromBytesOnGTK begin")
512+
513+ ctx := glib.MainContextDefault()
514+ if ctx != nil && ctx.IsOwner() {
515+ texture := bytesToTexture(data)
516+ log.Debug().Bool("texture_nil", texture == nil).Msg("favicon: textureFromBytesOnGTK inline")
517+ return texture
518+ }
519+
520+ resultCh := make(chan textureResult, 1)
521+ cb := glib.SourceOnceFunc(func(_ uintptr) {
522+ log.Debug().Int("bytes", len(data)).Msg("favicon: textureFromBytesOnGTK executing on GTK")
523+ resultCh <- textureResult{texture: bytesToTexture(data)}
524+ })
525+ glib.IdleAddOnce(&cb, 0)
526+ result := <-resultCh
527+ log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end")
528+ return result.texture
529+}
530+
424531 // bytesToTexture converts raw favicon bytes to a GDK texture.
425532 func bytesToTexture(data []byte) *gdk.Texture {
426533 if len(data) == 0 {
@@ -3,6 +3,7 @@ package adapter
3 3
4 import (4 import (
5 "context"5 "context"
6+ "os"
6 "sync"7 "sync"
7 8
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
@@ -28,10 +29,17 @@ type FaviconAdapter struct {
28 internalDomain string29 internalDomain string
29 normalizedIconSize int30 normalizedIconSize int
30 getLogoBytes func() []byte31 getLogoBytes func() []byte
32+ disabled bool
31 }33 }
32 34
35+const disableFaviconsEnv = "DUMBER_DISABLE_FAVICONS"
36+
33 type warningLogFunc func(log *zerolog.Logger, err error)37 type warningLogFunc func(log *zerolog.Logger, err error)
34 38
39+type textureResult struct {
40+ texture *gdk.Texture
41+}
42+
35 // FaviconAdapterConfig holds optional configuration for FaviconAdapter.43 // FaviconAdapterConfig holds optional configuration for FaviconAdapter.
36 // Zero values disable the corresponding features (internal-URL logo, sized-PNG creation).44 // Zero values disable the corresponding features (internal-URL logo, sized-PNG creation).
37 type FaviconAdapterConfig struct {45 type FaviconAdapterConfig struct {
@@ -48,6 +56,7 @@ type FaviconAdapterConfig struct {
48 // NewFaviconAdapter creates a new FaviconAdapter.56 // NewFaviconAdapter creates a new FaviconAdapter.
49 // The faviconDB can be nil if a favicon database is not available.57 // The faviconDB can be nil if a favicon database is not available.
50 func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {58 func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {
59+ disabled := os.Getenv(disableFaviconsEnv) == "1"
51 return &FaviconAdapter{60 return &FaviconAdapter{
52 service: service,61 service: service,
53 faviconDB: faviconDB,62 faviconDB: faviconDB,
@@ -57,6 +66,7 @@ func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDataba
57 internalDomain: cfg.InternalDomain,66 internalDomain: cfg.InternalDomain,
58 normalizedIconSize: cfg.NormalizedIconSize,67 normalizedIconSize: cfg.NormalizedIconSize,
59 getLogoBytes: cfg.GetLogoBytes,68 getLogoBytes: cfg.GetLogoBytes,
69+ disabled: disabled,
60 }70 }
61 }71 }
62 72
@@ -92,18 +102,33 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
92 }102 }
93 103
94 log := logging.FromContext(ctx)104 log := logging.FromContext(ctx)
105+ if a.disabled {
106+ log.Warn().
107+ Str("env", disableFaviconsEnv).
108+ Msg("favicon: UI favicon pipeline temporarily disabled for crash isolation")
109+ callback(nil)
110+ return
111+ }
95 112
96 // Handle internal dumb:// scheme URLs - use app logo113 // Handle internal dumb:// scheme URLs - use app logo
97 if a.isInternalURL != nil && a.isInternalURL(pageURL) {114 if a.isInternalURL != nil && a.isInternalURL(pageURL) {
98- texture := a.getOrCreateLogoTexture()115+ texture := a.textureFromBytesOnGTK(a.getLogoBytes())
116+ if texture != nil {
117+ a.setTexture(a.internalDomain, texture)
118+ }
99 callback(texture)119 callback(texture)
100 return120 return
101 }121 }
102 122
103 domain := domainurl.ExtractDomain(pageURL)123 domain := domainurl.ExtractDomain(pageURL)
124+ log.Debug().
125+ Str("page_url", pageURL).
126+ Str("domain", domain).
127+ Msg("favicon: GetOrFetch begin")
104 128
105 // Check texture cache first129 // Check texture cache first
106 if texture := a.GetTexture(domain); texture != nil {130 if texture := a.GetTexture(domain); texture != nil {
131+ log.Debug().Str("domain", domain).Msg("favicon: texture cache hit")
107 // Ensure sized PNG exists for CLI tools (async, idempotent)132 // Ensure sized PNG exists for CLI tools (async, idempotent)
108 go a.ensureSizedPNG(ctx, domain)133 go a.ensureSizedPNG(ctx, domain)
109 callback(texture)134 callback(texture)
@@ -112,7 +137,11 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
112 137
113 // Check service cache (memory + disk) and convert to texture138 // Check service cache (memory + disk) and convert to texture
114 if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {139 if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
115- texture := bytesToTexture(data)140+ log.Debug().
141+ Str("domain", domain).
142+ Int("bytes", len(data)).
143+ Msg("favicon: service cache hit")
144+ texture := a.textureFromBytesOnGTK(data)
116 if texture != nil {145 if texture != nil {
117 a.setTexture(domain, texture)146 a.setTexture(domain, texture)
118 // Ensure sized PNG exists for CLI tools (async, idempotent)147 // Ensure sized PNG exists for CLI tools (async, idempotent)
@@ -124,7 +153,15 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
124 153
125 // Helper to invoke callback on main thread154 // Helper to invoke callback on main thread
126 invokeCallback := func(texture *gdk.Texture) {155 invokeCallback := func(texture *gdk.Texture) {
156+ log.Debug().
157+ Str("domain", domain).
158+ Bool("texture_nil", texture == nil).
159+ Msg("favicon: scheduling callback on GTK")
127 cb := glib.SourceFunc(func(uintptr) bool {160 cb := glib.SourceFunc(func(uintptr) bool {
161+ log.Debug().
162+ Str("domain", domain).
163+ Bool("texture_nil", texture == nil).
164+ Msg("favicon: callback executing on GTK")
128 callback(texture)165 callback(texture)
129 return false166 return false
130 })167 })
@@ -134,16 +171,30 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
134 // Helper to fetch via service and invoke callback171 // Helper to fetch via service and invoke callback
135 fetchViaService := func() {172 fetchViaService := func() {
136 go func() {173 go func() {
174+ log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin")
137 data, err := a.service.Get(ctx, domain)175 data, err := a.service.Get(ctx, domain)
138 if err != nil || len(data) == 0 {176 if err != nil || len(data) == 0 {
177+ log.Debug().
178+ Err(err).
179+ Str("domain", domain).
180+ Int("bytes", len(data)).
181+ Msg("favicon: fetchViaService empty/error result")
139 invokeCallback(nil)182 invokeCallback(nil)
140 return183 return
141 }184 }
142 185
143- texture := bytesToTexture(data)186+ log.Debug().
187+ Str("domain", domain).
188+ Int("bytes", len(data)).
189+ Msg("favicon: fetchViaService fetched data")
190+ texture := a.textureFromBytesOnGTK(data)
144 if texture != nil {191 if texture != nil {
145 a.setTexture(domain, texture)192 a.setTexture(domain, texture)
146 }193 }
194+ log.Debug().
195+ Str("domain", domain).
196+ Bool("texture_nil", texture == nil).
197+ Msg("favicon: fetchViaService end")
147 invokeCallback(texture)198 invokeCallback(texture)
148 }()199 }()
149 }200 }
@@ -151,6 +202,10 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
151 // Try engine FaviconDatabase if available202 // Try engine FaviconDatabase if available
152 if a.faviconDB != nil {203 if a.faviconDB != nil {
153 a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {204 a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {
205+ log.Debug().
206+ Str("domain", domain).
207+ Bool("texture_nil", tex == nil).
208+ Msg("favicon: engine db callback")
154 if tex == nil {209 if tex == nil {
155 log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service")210 log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service")
156 fetchViaService()211 fetchViaService()
@@ -170,12 +225,19 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
170 }225 }
171 226
172 // No WebKit DB, fetch via service directly227 // No WebKit DB, fetch via service directly
228+ log.Debug().Str("domain", domain).Msg("favicon: no engine db, fetching via service")
173 fetchViaService()229 fetchViaService()
174 }230 }
175 231
176 // StoreFromWebKit stores a favicon texture received from WebKit signals.232 // StoreFromWebKit stores a favicon texture received from WebKit signals.
177 // Also ensures the favicon is persisted to disk cache via the service.233 // Also ensures the favicon is persisted to disk cache via the service.
178 func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) {234 func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) {
235+ if a.disabled {
236+ logging.FromContext(ctx).Debug().
237+ Str("page_url", pageURL).
238+ Msg("favicon: StoreFromWebKit skipped because favicon pipeline is disabled")
239+ return
240+ }
179 if texture == nil {241 if texture == nil {
180 return242 return
181 }243 }
@@ -272,25 +334,43 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t
272 // PreloadFromCache attempts to load a favicon from cache without fetching.334 // PreloadFromCache attempts to load a favicon from cache without fetching.
273 // Returns the texture if found in memory or disk cache, nil otherwise.335 // Returns the texture if found in memory or disk cache, nil otherwise.
274 func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {336 func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {
337+ if a.disabled {
338+ logging.FromContext(context.Background()).Debug().
339+ Str("page_url", pageURL).
340+ Str("env", disableFaviconsEnv).
341+ Msg("favicon: PreloadFromCache skipped because favicon pipeline is disabled")
342+ return nil
343+ }
275 domain := domainurl.ExtractDomain(pageURL)344 domain := domainurl.ExtractDomain(pageURL)
276 if domain == "" {345 if domain == "" {
277 return nil346 return nil
278 }347 }
348+ log := logging.FromContext(context.Background())
349+ log.Debug().
350+ Str("page_url", pageURL).
351+ Str("domain", domain).
352+ Msg("favicon: PreloadFromCache begin")
279 353
280 // Check texture cache354 // Check texture cache
281 if texture := a.GetTexture(domain); texture != nil {355 if texture := a.GetTexture(domain); texture != nil {
356+ log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache texture hit")
282 return texture357 return texture
283 }358 }
284 359
285 // Check service cache (memory + disk)360 // Check service cache (memory + disk)
286 if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {361 if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
287- texture := bytesToTexture(data)362+ log.Debug().
363+ Str("domain", domain).
364+ Int("bytes", len(data)).
365+ Msg("favicon: PreloadFromCache service hit")
366+ texture := a.textureFromBytesOnGTK(data)
288 if texture != nil {367 if texture != nil {
289 a.setTexture(domain, texture)368 a.setTexture(domain, texture)
290 return texture369 return texture
291 }370 }
292 }371 }
293 372
373+ log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache miss")
294 return nil374 return nil
295 }375 }
296 376
@@ -414,13 +494,40 @@ func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
414 494
415 // Create texture from logo bytes495 // Create texture from logo bytes
416 logoBytes := a.getLogoBytes()496 logoBytes := a.getLogoBytes()
417- texture := bytesToTexture(logoBytes)497+ texture := a.textureFromBytesOnGTK(logoBytes)
418 if texture != nil {498 if texture != nil {
419 a.setTexture(a.internalDomain, texture)499 a.setTexture(a.internalDomain, texture)
420 }500 }
421 return texture501 return texture
422 }502 }
423 503
504+func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture {
505+ if len(data) == 0 {
506+ return nil
507+ }
508+ log := logging.FromContext(context.Background())
509+ log.Debug().
510+ Int("bytes", len(data)).
511+ Msg("favicon: textureFromBytesOnGTK begin")
512+
513+ ctx := glib.MainContextDefault()
514+ if ctx != nil && ctx.IsOwner() {
515+ texture := bytesToTexture(data)
516+ log.Debug().Bool("texture_nil", texture == nil).Msg("favicon: textureFromBytesOnGTK inline")
517+ return texture
518+ }
519+
520+ resultCh := make(chan textureResult, 1)
521+ cb := glib.SourceOnceFunc(func(_ uintptr) {
522+ log.Debug().Int("bytes", len(data)).Msg("favicon: textureFromBytesOnGTK executing on GTK")
523+ resultCh <- textureResult{texture: bytesToTexture(data)}
524+ })
525+ glib.IdleAddOnce(&cb, 0)
526+ result := <-resultCh
527+ log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end")
528+ return result.texture
529+}
530+
424 // bytesToTexture converts raw favicon bytes to a GDK texture.531 // bytesToTexture converts raw favicon bytes to a GDK texture.
425 func bytesToTexture(data []byte) *gdk.Texture {532 func bytesToTexture(data []byte) *gdk.Texture {
426 if len(data) == 0 {533 if len(data) == 0 {
M internal/ui/app.go +7 -0
@@ -385,12 +385,19 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
385385 }
386386
387387 func (a *App) createMainWindow(ctx context.Context) error {
388+ log := logging.FromContext(ctx)
388389 mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition)
389390 if err != nil {
390391 return err
391392 }
392393 a.mainWindow = mainWindow
393394
395+ closeRequestCb := func(_ gtk.Window) bool {
396+ log.Info().Msg("main window close requested")
397+ return false
398+ }
399+ a.mainWindow.Window().ConnectCloseRequest(&closeRequestCb)
400+
394401 // Create permission popup and dialog presenter
395402 if a.deps != nil && a.deps.PermissionUC != nil {
396403 uiScale := 1.0
@@ -385,12 +385,19 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
385 }385 }
386 386
387 func (a *App) createMainWindow(ctx context.Context) error {387 func (a *App) createMainWindow(ctx context.Context) error {
388+ log := logging.FromContext(ctx)
388 mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition)389 mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition)
389 if err != nil {390 if err != nil {
390 return err391 return err
391 }392 }
392 a.mainWindow = mainWindow393 a.mainWindow = mainWindow
393 394
395+ closeRequestCb := func(_ gtk.Window) bool {
396+ log.Info().Msg("main window close requested")
397+ return false
398+ }
399+ a.mainWindow.Window().ConnectCloseRequest(&closeRequestCb)
400+
394 // Create permission popup and dialog presenter401 // Create permission popup and dialog presenter
395 if a.deps != nil && a.deps.PermissionUC != nil {402 if a.deps != nil && a.deps.PermissionUC != nil {
396 uiScale := 1.0403 uiScale := 1.0

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