detached 60f4f26

fix(cef): address CodeRabbit findings and fix concurrent map crash

Bug fixes:
- Guard GetMainFrame() nil in LoadURI, LoadHTML, RunJavaScript, OnAfterCreated
- Heap-allocate glib idle callback in runOnGTK to prevent use-after-free
- crashCount → atomic.Int32 for thread-safe access
- lastStallLoggedCreateSeq → atomic.Uint64
- OnAfterCreated: early return on nil browser
- Pool Prewarm: destroy webview on type assert failure
- Hostname-pid parsing: use LastIndex to handle hyphenated hostnames
- navigate.go: swap pending maps before iteration to fix concurrent
  map read/write crash in historyWorker

Improvements:
- InternalSchemePath extracted to package constant
- Separated doc comments for findHelperBinary/cleanStaleSingletonLocks
- HiDPI detection logs fallback reasons at debug level
- Pool uses engine context instead of context.Background()
- GetFaviconAsync callback runs asynchronously
- Command line logging truncated to 200 chars
- CreateRelated warns only once via atomic.Bool
- Tests migrated to testify/require
- History flush logs pending count at debug level
- History queue-full warning logged in UpdateHistoryTitle
commit
60f4f265e3f1d3c39a8f1b043fea15dcd39f6cfc
author
brice · · 2026-03-21 06:43 UTC
committer
brice · · 2026-03-28 04:37 UTC
parent
fed7cca
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +117 -86
M internal/application/usecase/navigate.go +18 -7
@@ -241,16 +241,27 @@ func (uc *NavigateUseCase) historyWorker() {
241241 }
242242
243243 flushPending := func() {
244-
245- for historyURL, visits := range pendingVisits {
246- uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits})
244+ visitCount := len(pendingVisits)
245+ titleCount := len(pendingTitles)
246+ if visitCount == 0 && titleCount == 0 {
247+ return
247248 }
248- clear(pendingVisits)
249249
250- for historyURL, title := range pendingTitles {
250+ log.Debug().Int("visits", visitCount).Int("titles", titleCount).Msg("flushing pending history")
251+
252+ // Swap maps to avoid concurrent iteration/write issues
253+ // (persist callbacks may re-enqueue records).
254+ visits := pendingVisits
255+ titles := pendingTitles
256+ pendingVisits = make(map[string]int)
257+ pendingTitles = make(map[string]string)
258+
259+ for historyURL, v := range visits {
260+ uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: v})
261+ }
262+ for historyURL, title := range titles {
251263 uc.persistTitleUpdate(uc.ctx, historyURL, title)
252264 }
253- clear(pendingTitles)
254265 }
255266
256267 drainQueue := func() {
@@ -371,7 +382,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(_ context.Context, historyURL, tit
371382 select {
372383 case uc.historyQueue <- historyRecord{url: historyURL, title: title}:
373384 default:
374- // Queue full — title update is best-effort.
385+ logging.FromContext(uc.ctx).Warn().Str("url", historyURL).Msg("history queue full, title update dropped")
375386 }
376387 }
377388
@@ -241,16 +241,27 @@ func (uc *NavigateUseCase) historyWorker() {
241 }241 }
242 242
243 flushPending := func() {243 flushPending := func() {
244-244+ visitCount := len(pendingVisits)
245- for historyURL, visits := range pendingVisits {245+ titleCount := len(pendingTitles)
246- uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits})246+ if visitCount == 0 && titleCount == 0 {
247+ return
247 }248 }
248- clear(pendingVisits)
249 249
250- for historyURL, title := range pendingTitles {250+ log.Debug().Int("visits", visitCount).Int("titles", titleCount).Msg("flushing pending history")
251+
252+ // Swap maps to avoid concurrent iteration/write issues
253+ // (persist callbacks may re-enqueue records).
254+ visits := pendingVisits
255+ titles := pendingTitles
256+ pendingVisits = make(map[string]int)
257+ pendingTitles = make(map[string]string)
258+
259+ for historyURL, v := range visits {
260+ uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: v})
261+ }
262+ for historyURL, title := range titles {
251 uc.persistTitleUpdate(uc.ctx, historyURL, title)263 uc.persistTitleUpdate(uc.ctx, historyURL, title)
252 }264 }
253- clear(pendingTitles)
254 }265 }
255 266
256 drainQueue := func() {267 drainQueue := func() {
@@ -371,7 +382,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(_ context.Context, historyURL, tit
371 select {382 select {
372 case uc.historyQueue <- historyRecord{url: historyURL, title: title}:383 case uc.historyQueue <- historyRecord{url: historyURL, title: title}:
373 default:384 default:
374- // Queue full — title update is best-effort.385+ logging.FromContext(uc.ctx).Warn().Str("url", historyURL).Msg("history queue full, title update dropped")
375 }386 }
376 }387 }
377 388
M internal/infrastructure/cef/app.go +6 -0
@@ -23,11 +23,17 @@ func newDumberApp(engine *Engine) purecef.App {
2323 return purecef.NewApp(app)
2424 }
2525
26+// maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.
27+const maxCmdLineLogLen = 200
28+
2629 func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLine purecef.CommandLine) {
2730 log := logging.FromContext(a.engine.ctx)
2831 cmdline := ""
2932 if commandLine != nil {
3033 cmdline = commandLine.GetCommandLineString()
34+ if len(cmdline) > maxCmdLineLogLen {
35+ cmdline = cmdline[:maxCmdLineLogLen] + "…"
36+ }
3137 }
3238 log.Debug().
3339 Str("process_type", processType).
@@ -23,11 +23,17 @@ func newDumberApp(engine *Engine) purecef.App {
23 return purecef.NewApp(app)23 return purecef.NewApp(app)
24 }24 }
25 25
26+// maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.
27+const maxCmdLineLogLen = 200
28+
26 func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLine purecef.CommandLine) {29 func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLine purecef.CommandLine) {
27 log := logging.FromContext(a.engine.ctx)30 log := logging.FromContext(a.engine.ctx)
28 cmdline := ""31 cmdline := ""
29 if commandLine != nil {32 if commandLine != nil {
30 cmdline = commandLine.GetCommandLineString()33 cmdline = commandLine.GetCommandLineString()
34+ if len(cmdline) > maxCmdLineLogLen {
35+ cmdline = cmdline[:maxCmdLineLogLen] + "…"
36+ }
31 }37 }
32 log.Debug().38 log.Debug().
33 Str("process_type", processType).39 Str("process_type", processType).
M internal/infrastructure/cef/engine.go +7 -4
@@ -46,7 +46,7 @@ type Engine struct {
4646 browserCreateLastResult atomic.Int32
4747 browserCreateLastWidth atomic.Int32
4848 browserCreateLastHeight atomic.Int32
49- lastStallLoggedCreateSeq uint64
49+ lastStallLoggedCreateSeq atomic.Uint64
5050 }
5151
5252 // Factory returns the WebViewFactory for creating new WebView instances.
@@ -79,9 +79,12 @@ func (e *Engine) FaviconDatabase() port.FaviconDatabase {
7979 return &noopFaviconDatabase{}
8080 }
8181
82+// internalSchemePath is the URI scheme used for internal app resources.
83+const internalSchemePath = "cef://dumber/"
84+
8285 // InternalSchemePath returns the URI scheme used for internal app resources.
8386 func (e *Engine) InternalSchemePath() string {
84- return "cef://dumber/"
87+ return internalSchemePath
8588 }
8689
8790 // Close releases all resources held by the engine.
@@ -205,7 +208,7 @@ func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) {
205208
206209 func (e *Engine) maybeLogBrowserCreateStall() {
207210 createCount := e.browserCreateRequests.Load()
208- if createCount == 0 || createCount == e.browserAfterCreated.Load() || createCount == e.lastStallLoggedCreateSeq {
211+ if createCount == 0 || createCount == e.browserAfterCreated.Load() || createCount == e.lastStallLoggedCreateSeq.Load() {
209212 return
210213 }
211214
@@ -219,7 +222,7 @@ func (e *Engine) maybeLogBrowserCreateStall() {
219222 return
220223 }
221224
222- e.lastStallLoggedCreateSeq = createCount
225+ e.lastStallLoggedCreateSeq.Store(createCount)
223226 logging.FromContext(e.ctx).Warn().
224227 Uint64("create_requests", createCount).
225228 Uint64("after_created", e.browserAfterCreated.Load()).
@@ -46,7 +46,7 @@ type Engine struct {
46 browserCreateLastResult atomic.Int3246 browserCreateLastResult atomic.Int32
47 browserCreateLastWidth atomic.Int3247 browserCreateLastWidth atomic.Int32
48 browserCreateLastHeight atomic.Int3248 browserCreateLastHeight atomic.Int32
49- lastStallLoggedCreateSeq uint6449+ lastStallLoggedCreateSeq atomic.Uint64
50 }50 }
51 51
52 // Factory returns the WebViewFactory for creating new WebView instances.52 // Factory returns the WebViewFactory for creating new WebView instances.
@@ -79,9 +79,12 @@ func (e *Engine) FaviconDatabase() port.FaviconDatabase {
79 return &noopFaviconDatabase{}79 return &noopFaviconDatabase{}
80 }80 }
81 81
82+// internalSchemePath is the URI scheme used for internal app resources.
83+const internalSchemePath = "cef://dumber/"
84+
82 // InternalSchemePath returns the URI scheme used for internal app resources.85 // InternalSchemePath returns the URI scheme used for internal app resources.
83 func (e *Engine) InternalSchemePath() string {86 func (e *Engine) InternalSchemePath() string {
84- return "cef://dumber/"87+ return internalSchemePath
85 }88 }
86 89
87 // Close releases all resources held by the engine.90 // Close releases all resources held by the engine.
@@ -205,7 +208,7 @@ func (e *Engine) recordBrowserAfterCreated(browser purecef.Browser) {
205 208
206 func (e *Engine) maybeLogBrowserCreateStall() {209 func (e *Engine) maybeLogBrowserCreateStall() {
207 createCount := e.browserCreateRequests.Load()210 createCount := e.browserCreateRequests.Load()
208- if createCount == 0 || createCount == e.browserAfterCreated.Load() || createCount == e.lastStallLoggedCreateSeq {211+ if createCount == 0 || createCount == e.browserAfterCreated.Load() || createCount == e.lastStallLoggedCreateSeq.Load() {
209 return212 return
210 }213 }
211 214
@@ -219,7 +222,7 @@ func (e *Engine) maybeLogBrowserCreateStall() {
219 return222 return
220 }223 }
221 224
222- e.lastStallLoggedCreateSeq = createCount225+ e.lastStallLoggedCreateSeq.Store(createCount)
223 logging.FromContext(e.ctx).Warn().226 logging.FromContext(e.ctx).Warn().
224 Uint64("create_requests", createCount).227 Uint64("create_requests", createCount).
225 Uint64("after_created", e.browserAfterCreated.Load()).228 Uint64("after_created", e.browserAfterCreated.Load()).
M internal/infrastructure/cef/engine_adapters.go +1 -1
@@ -97,5 +97,5 @@ func (n *noopSettingsApplier) ApplyToAll(_ context.Context, _ []port.WebView) {}
9797 type noopFaviconDatabase struct{}
9898
9999 func (n *noopFaviconDatabase) GetFaviconAsync(_ string, callback func(port.Texture)) {
100- callback(nil)
100+ go callback(nil)
101101 }
@@ -97,5 +97,5 @@ func (n *noopSettingsApplier) ApplyToAll(_ context.Context, _ []port.WebView) {}
97 type noopFaviconDatabase struct{}97 type noopFaviconDatabase struct{}
98 98
99 func (n *noopFaviconDatabase) GetFaviconAsync(_ string, callback func(port.Texture)) {99 func (n *noopFaviconDatabase) GetFaviconAsync(_ string, callback func(port.Texture)) {
100- callback(nil)100+ go callback(nil)
101 }101 }
M internal/infrastructure/cef/engine_init.go +21 -17
@@ -105,17 +105,20 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
105105
106106 // 4. Detect HiDPI scale from the primary monitor.
107107 scale := int32(1)
108- if display := gdk.DisplayGetDefault(); display != nil {
109- if monitors := display.GetMonitors(); monitors != nil {
110- obj := monitors.GetObject(0)
111- if obj != nil {
112- mon := &gdk.Monitor{}
113- mon.SetGoPointer(obj.GoPointer())
114- if s := mon.GetScaleFactor(); s > 0 {
115- scale = int32(s)
116- logger.Info().Int32("scale", scale).Msg("cef: detected HiDPI scale from monitor")
117- }
118- }
108+ if display := gdk.DisplayGetDefault(); display == nil {
109+ logger.Debug().Msg("cef: no GDK display available, using scale=1")
110+ } else if monitors := display.GetMonitors(); monitors == nil {
111+ logger.Debug().Msg("cef: no monitors found, using scale=1")
112+ } else if obj := monitors.GetObject(0); obj == nil {
113+ logger.Debug().Msg("cef: primary monitor not available, using scale=1")
114+ } else {
115+ mon := &gdk.Monitor{}
116+ mon.SetGoPointer(obj.GoPointer())
117+ if s := mon.GetScaleFactor(); s > 0 {
118+ scale = int32(s)
119+ logger.Info().Int32("scale", scale).Msg("cef: detected HiDPI scale from monitor")
120+ } else {
121+ logger.Debug().Msg("cef: monitor scale factor <= 0, using scale=1")
119122 }
120123 }
121124
@@ -140,8 +143,6 @@ func appendIfMissing(args []string, flag string) []string {
140143 return append(args, flag)
141144 }
142145
143-// findHelperBinary looks for the cef-helper binary next to the running
144-// executable. Returns empty string if not found.
145146 // cleanStaleSingletonLocks removes CEF's SingletonLock/Socket/Cookie files
146147 // if the owning process is no longer running. CEF leaves these behind on
147148 // unclean shutdown (SIGKILL, SIGSEGV) and the next instance crashes trying
@@ -156,12 +157,13 @@ func cleanStaleSingletonLocks(logger *zerolog.Logger) {
156157 return // No lock file or not a symlink — nothing to clean.
157158 }
158159
159- // SingletonLock is a symlink to "hostname-pid".
160- parts := strings.SplitN(target, "-", 2)
161- if len(parts) != 2 {
160+ // SingletonLock is a symlink to "hostname-pid". Split from the right
161+ // because hostnames may contain hyphens.
162+ lastDash := strings.LastIndex(target, "-")
163+ if lastDash < 0 || lastDash == len(target)-1 {
162164 return
163165 }
164- pid, err := strconv.Atoi(parts[1])
166+ pid, err := strconv.Atoi(target[lastDash+1:])
165167 if err != nil || pid <= 0 {
166168 return
167169 }
@@ -184,6 +186,8 @@ func cleanStaleSingletonLocks(logger *zerolog.Logger) {
184186 }
185187 }
186188
189+// findHelperBinary looks for the cef-helper binary next to the running
190+// executable. Returns empty string if not found.
187191 func findHelperBinary() string {
188192 exe, err := os.Executable()
189193 if err != nil {
@@ -105,17 +105,20 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
105 105
106 // 4. Detect HiDPI scale from the primary monitor.106 // 4. Detect HiDPI scale from the primary monitor.
107 scale := int32(1)107 scale := int32(1)
108- if display := gdk.DisplayGetDefault(); display != nil {108+ if display := gdk.DisplayGetDefault(); display == nil {
109- if monitors := display.GetMonitors(); monitors != nil {109+ logger.Debug().Msg("cef: no GDK display available, using scale=1")
110- obj := monitors.GetObject(0)110+ } else if monitors := display.GetMonitors(); monitors == nil {
111- if obj != nil {111+ logger.Debug().Msg("cef: no monitors found, using scale=1")
112- mon := &gdk.Monitor{}112+ } else if obj := monitors.GetObject(0); obj == nil {
113- mon.SetGoPointer(obj.GoPointer())113+ logger.Debug().Msg("cef: primary monitor not available, using scale=1")
114- if s := mon.GetScaleFactor(); s > 0 {114+ } else {
115- scale = int32(s)115+ mon := &gdk.Monitor{}
116- logger.Info().Int32("scale", scale).Msg("cef: detected HiDPI scale from monitor")116+ mon.SetGoPointer(obj.GoPointer())
117- }117+ if s := mon.GetScaleFactor(); s > 0 {
118- }118+ scale = int32(s)
119+ logger.Info().Int32("scale", scale).Msg("cef: detected HiDPI scale from monitor")
120+ } else {
121+ logger.Debug().Msg("cef: monitor scale factor <= 0, using scale=1")
119 }122 }
120 }123 }
121 124
@@ -140,8 +143,6 @@ func appendIfMissing(args []string, flag string) []string {
140 return append(args, flag)143 return append(args, flag)
141 }144 }
142 145
143-// findHelperBinary looks for the cef-helper binary next to the running
144-// executable. Returns empty string if not found.
145 // cleanStaleSingletonLocks removes CEF's SingletonLock/Socket/Cookie files146 // cleanStaleSingletonLocks removes CEF's SingletonLock/Socket/Cookie files
146 // if the owning process is no longer running. CEF leaves these behind on147 // if the owning process is no longer running. CEF leaves these behind on
147 // unclean shutdown (SIGKILL, SIGSEGV) and the next instance crashes trying148 // unclean shutdown (SIGKILL, SIGSEGV) and the next instance crashes trying
@@ -156,12 +157,13 @@ func cleanStaleSingletonLocks(logger *zerolog.Logger) {
156 return // No lock file or not a symlink — nothing to clean.157 return // No lock file or not a symlink — nothing to clean.
157 }158 }
158 159
159- // SingletonLock is a symlink to "hostname-pid".160+ // SingletonLock is a symlink to "hostname-pid". Split from the right
160- parts := strings.SplitN(target, "-", 2)161+ // because hostnames may contain hyphens.
161- if len(parts) != 2 {162+ lastDash := strings.LastIndex(target, "-")
163+ if lastDash < 0 || lastDash == len(target)-1 {
162 return164 return
163 }165 }
164- pid, err := strconv.Atoi(parts[1])166+ pid, err := strconv.Atoi(target[lastDash+1:])
165 if err != nil || pid <= 0 {167 if err != nil || pid <= 0 {
166 return168 return
167 }169 }
@@ -184,6 +186,8 @@ func cleanStaleSingletonLocks(logger *zerolog.Logger) {
184 }186 }
185 }187 }
186 188
189+// findHelperBinary looks for the cef-helper binary next to the running
190+// executable. Returns empty string if not found.
187 func findHelperBinary() string {191 func findHelperBinary() string {
188 exe, err := os.Executable()192 exe, err := os.Executable()
189 if err != nil {193 if err != nil {
M internal/infrastructure/cef/factory.go +6 -1
@@ -146,10 +146,15 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
146146 return wv, nil
147147 }
148148
149+// createRelatedWarned ensures the session sharing warning is logged only once.
150+var createRelatedWarned atomic.Bool
151+
149152 // CreateRelated creates a WebView that shares session/cookies with the parent.
150153 // TODO(phase2): look up the parent browser by parentID and use the same
151154 // request context so cookies and session state are shared.
152155 func (f *WebViewFactory) CreateRelated(ctx context.Context, _ port.WebViewID) (port.WebView, error) {
153- logging.FromContext(ctx).Warn().Msg("cef: CreateRelated does not yet support session sharing, creating independent WebView")
156+ if createRelatedWarned.CompareAndSwap(false, true) {
157+ logging.FromContext(ctx).Warn().Msg("cef: CreateRelated does not yet support session sharing, creating independent WebView")
158+ }
154159 return f.Create(ctx)
155160 }
@@ -146,10 +146,15 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
146 return wv, nil146 return wv, nil
147 }147 }
148 148
149+// createRelatedWarned ensures the session sharing warning is logged only once.
150+var createRelatedWarned atomic.Bool
151+
149 // CreateRelated creates a WebView that shares session/cookies with the parent.152 // CreateRelated creates a WebView that shares session/cookies with the parent.
150 // TODO(phase2): look up the parent browser by parentID and use the same153 // TODO(phase2): look up the parent browser by parentID and use the same
151 // request context so cookies and session state are shared.154 // request context so cookies and session state are shared.
152 func (f *WebViewFactory) CreateRelated(ctx context.Context, _ port.WebViewID) (port.WebView, error) {155 func (f *WebViewFactory) CreateRelated(ctx context.Context, _ port.WebViewID) (port.WebView, error) {
153- logging.FromContext(ctx).Warn().Msg("cef: CreateRelated does not yet support session sharing, creating independent WebView")156+ if createRelatedWarned.CompareAndSwap(false, true) {
157+ logging.FromContext(ctx).Warn().Msg("cef: CreateRelated does not yet support session sharing, creating independent WebView")
158+ }
154 return f.Create(ctx)159 return f.Create(ctx)
155 }160 }
M internal/infrastructure/cef/input_bridge.go +2 -1
@@ -219,8 +219,9 @@ func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount
219219 }
220220
221221 // Middle-click on a link opens in a new tab instead of sending to CEF.
222+ // The URI is resolved by the callback closure (factory wiring reads wv.lastHoverURI).
222223 if button == 2 && middleCB != nil {
223- middleCB("")
224+ go middleCB("")
224225 return
225226 }
226227
@@ -219,8 +219,9 @@ func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount
219 }219 }
220 220
221 // Middle-click on a link opens in a new tab instead of sending to CEF.221 // Middle-click on a link opens in a new tab instead of sending to CEF.
222+ // The URI is resolved by the callback closure (factory wiring reads wv.lastHoverURI).
222 if button == 2 && middleCB != nil {223 if button == 2 && middleCB != nil {
223- middleCB("")224+ go middleCB("")
224 return225 return
225 }226 }
226 227
M internal/infrastructure/cef/pool.go +2 -1
@@ -68,7 +68,7 @@ func (p *WebViewPool) Prewarm(count int) {
6868 return
6969 }
7070
71- ctx := context.Background()
71+ ctx := p.factory.engine.ctx
7272 log := logging.FromContext(ctx)
7373 views := make([]*WebView, 0, count)
7474 for range count {
@@ -80,6 +80,7 @@ func (p *WebViewPool) Prewarm(count int) {
8080 cefWV, ok := wv.(*WebView)
8181 if !ok {
8282 log.Warn().Str("concrete_type", fmt.Sprintf("%T", wv)).Msg("cef: pool prewarm got unexpected WebView type")
83+ wv.Destroy()
8384 continue
8485 }
8586 views = append(views, cefWV)
@@ -68,7 +68,7 @@ func (p *WebViewPool) Prewarm(count int) {
68 return68 return
69 }69 }
70 70
71- ctx := context.Background()71+ ctx := p.factory.engine.ctx
72 log := logging.FromContext(ctx)72 log := logging.FromContext(ctx)
73 views := make([]*WebView, 0, count)73 views := make([]*WebView, 0, count)
74 for range count {74 for range count {
@@ -80,6 +80,7 @@ func (p *WebViewPool) Prewarm(count int) {
80 cefWV, ok := wv.(*WebView)80 cefWV, ok := wv.(*WebView)
81 if !ok {81 if !ok {
82 log.Warn().Str("concrete_type", fmt.Sprintf("%T", wv)).Msg("cef: pool prewarm got unexpected WebView type")82 log.Warn().Str("concrete_type", fmt.Sprintf("%T", wv)).Msg("cef: pool prewarm got unexpected WebView type")
83+ wv.Destroy()
83 continue84 continue
84 }85 }
85 views = append(views, cefWV)86 views = append(views, cefWV)
M internal/infrastructure/cef/render_pipeline.go +1 -0
@@ -528,6 +528,7 @@ func (rp *renderPipeline) buildShaderProgram() uint32 {
528528 }
529529
530530 // compileShader compiles a single GLSL shader and returns its GL handle.
531+// TODO: add glGetShaderiv + glGetShaderInfoLog to gl_loader to check compilation status.
531532 func compileShader(gl *glLoader, shaderType uint32, source string) uint32 {
532533 shader := gl.createShader(shaderType)
533534
@@ -528,6 +528,7 @@ func (rp *renderPipeline) buildShaderProgram() uint32 {
528 }528 }
529 529
530 // compileShader compiles a single GLSL shader and returns its GL handle.530 // compileShader compiles a single GLSL shader and returns its GL handle.
531+// TODO: add glGetShaderiv + glGetShaderInfoLog to gl_loader to check compilation status.
531 func compileShader(gl *glLoader, shaderType uint32, source string) uint32 {532 func compileShader(gl *glLoader, shaderType uint32, source string) uint32 {
532 shader := gl.createShader(shaderType)533 shader := gl.createShader(shaderType)
533 534
M internal/infrastructure/cef/webview.go +15 -7
@@ -48,7 +48,7 @@ type WebView struct {
4848
4949 // crashCount tracks consecutive renderer crashes to prevent infinite
5050 // crash → redirect → crash loops.
51- crashCount int32
51+ crashCount atomic.Int32
5252
5353 // Callbacks set by use case layer.
5454 mu sync.RWMutex
@@ -108,7 +108,9 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
108108 return nil
109109 }
110110 wv.mu.Unlock()
111- browser.GetMainFrame().LoadURL(uri)
111+ if frame := browser.GetMainFrame(); frame != nil {
112+ frame.LoadURL(uri)
113+ }
112114 return nil
113115 }
114116
@@ -124,7 +126,9 @@ func (wv *WebView) LoadHTML(_ context.Context, content, _ string) error {
124126 return errNoBrowser
125127 }
126128 dataURL := "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(content))
127- browser.GetMainFrame().LoadURL(dataURL)
129+ if frame := browser.GetMainFrame(); frame != nil {
130+ frame.LoadURL(dataURL)
131+ }
128132 return nil
129133 }
130134
@@ -360,7 +364,9 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) {
360364 if browser == nil {
361365 return
362366 }
363- browser.GetMainFrame().ExecuteJavaScript(script, "", 0)
367+ if frame := browser.GetMainFrame(); frame != nil {
368+ frame.ExecuteJavaScript(script, "", 0)
369+ }
364370 }
365371
366372 // colorScale converts a 0.0–1.0 color component to an 8-bit integer.
@@ -496,8 +502,10 @@ func (wv *WebView) runOnGTK(fn func()) {
496502 return
497503 }
498504
499- cb := glib.SourceOnceFunc(func(_ uintptr) {
505+ // Heap-allocate the callback so it survives until glib invokes it.
506+ cb := new(glib.SourceOnceFunc)
507+ *cb = func(_ uintptr) {
500508 fn()
501- })
502- glib.IdleAddOnce(&cb, 0)
509+ }
510+ glib.IdleAddOnce(cb, 0)
503511 }
@@ -48,7 +48,7 @@ type WebView struct {
48 48
49 // crashCount tracks consecutive renderer crashes to prevent infinite49 // crashCount tracks consecutive renderer crashes to prevent infinite
50 // crash → redirect → crash loops.50 // crash → redirect → crash loops.
51- crashCount int3251+ crashCount atomic.Int32
52 52
53 // Callbacks set by use case layer.53 // Callbacks set by use case layer.
54 mu sync.RWMutex54 mu sync.RWMutex
@@ -108,7 +108,9 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
108 return nil108 return nil
109 }109 }
110 wv.mu.Unlock()110 wv.mu.Unlock()
111- browser.GetMainFrame().LoadURL(uri)111+ if frame := browser.GetMainFrame(); frame != nil {
112+ frame.LoadURL(uri)
113+ }
112 return nil114 return nil
113 }115 }
114 116
@@ -124,7 +126,9 @@ func (wv *WebView) LoadHTML(_ context.Context, content, _ string) error {
124 return errNoBrowser126 return errNoBrowser
125 }127 }
126 dataURL := "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(content))128 dataURL := "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(content))
127- browser.GetMainFrame().LoadURL(dataURL)129+ if frame := browser.GetMainFrame(); frame != nil {
130+ frame.LoadURL(dataURL)
131+ }
128 return nil132 return nil
129 }133 }
130 134
@@ -360,7 +364,9 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) {
360 if browser == nil {364 if browser == nil {
361 return365 return
362 }366 }
363- browser.GetMainFrame().ExecuteJavaScript(script, "", 0)367+ if frame := browser.GetMainFrame(); frame != nil {
368+ frame.ExecuteJavaScript(script, "", 0)
369+ }
364 }370 }
365 371
366 // colorScale converts a 0.0–1.0 color component to an 8-bit integer.372 // colorScale converts a 0.0–1.0 color component to an 8-bit integer.
@@ -496,8 +502,10 @@ func (wv *WebView) runOnGTK(fn func()) {
496 return502 return
497 }503 }
498 504
499- cb := glib.SourceOnceFunc(func(_ uintptr) {505+ // Heap-allocate the callback so it survives until glib invokes it.
506+ cb := new(glib.SourceOnceFunc)
507+ *cb = func(_ uintptr) {
500 fn()508 fn()
501- })509+ }
502- glib.IdleAddOnce(&cb, 0)510+ glib.IdleAddOnce(cb, 0)
503 }511 }
M internal/infrastructure/cef/webview_handlers.go +9 -8
@@ -304,7 +304,7 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
304304 return
305305 }
306306 // Successful load — reset the consecutive crash counter.
307- h.wv.crashCount = 0
307+ h.wv.crashCount.Store(0)
308308
309309 h.wv.mu.RLock()
310310 cb := h.wv.callbacks
@@ -355,12 +355,12 @@ func (h *handlerSet) OnBeforeDevToolsPopup(
355355 // OnAfterCreated stores the browser and host references and enables input.
356356 func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
357357 log := logging.FromContext(h.wv.ctx)
358- browserID := int32(0)
359- if browser != nil {
360- browserID = browser.GetIdentifier()
358+ if browser == nil {
359+ log.Warn().Msg("cef: OnAfterCreated called with nil browser")
360+ return
361361 }
362+ browserID := browser.GetIdentifier()
362363 log.Debug().
363- Bool("browser_nil", browser == nil).
364364 Int32("browser_id", browserID).
365365 Msg("cef: OnAfterCreated")
366366 if h.wv.engine != nil {
@@ -377,7 +377,9 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
377377
378378 // Replay any navigation that was requested before the browser existed.
379379 if uri != "" {
380- browser.GetMainFrame().LoadURL(uri)
380+ if frame := browser.GetMainFrame(); frame != nil {
381+ frame.LoadURL(uri)
382+ }
381383 }
382384 h.wv.mu.Unlock()
383385 }
@@ -451,8 +453,7 @@ const maxConsecutiveCrashes = 3
451453
452454 // OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.
453455 func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {
454- h.wv.crashCount++
455- if h.wv.crashCount > maxConsecutiveCrashes {
456+ if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {
456457 return // suppress to break the loop
457458 }
458459
@@ -304,7 +304,7 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
304 return304 return
305 }305 }
306 // Successful load — reset the consecutive crash counter.306 // Successful load — reset the consecutive crash counter.
307- h.wv.crashCount = 0307+ h.wv.crashCount.Store(0)
308 308
309 h.wv.mu.RLock()309 h.wv.mu.RLock()
310 cb := h.wv.callbacks310 cb := h.wv.callbacks
@@ -355,12 +355,12 @@ func (h *handlerSet) OnBeforeDevToolsPopup(
355 // OnAfterCreated stores the browser and host references and enables input.355 // OnAfterCreated stores the browser and host references and enables input.
356 func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {356 func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
357 log := logging.FromContext(h.wv.ctx)357 log := logging.FromContext(h.wv.ctx)
358- browserID := int32(0)358+ if browser == nil {
359- if browser != nil {359+ log.Warn().Msg("cef: OnAfterCreated called with nil browser")
360- browserID = browser.GetIdentifier()360+ return
361 }361 }
362+ browserID := browser.GetIdentifier()
362 log.Debug().363 log.Debug().
363- Bool("browser_nil", browser == nil).
364 Int32("browser_id", browserID).364 Int32("browser_id", browserID).
365 Msg("cef: OnAfterCreated")365 Msg("cef: OnAfterCreated")
366 if h.wv.engine != nil {366 if h.wv.engine != nil {
@@ -377,7 +377,9 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
377 377
378 // Replay any navigation that was requested before the browser existed.378 // Replay any navigation that was requested before the browser existed.
379 if uri != "" {379 if uri != "" {
380- browser.GetMainFrame().LoadURL(uri)380+ if frame := browser.GetMainFrame(); frame != nil {
381+ frame.LoadURL(uri)
382+ }
381 }383 }
382 h.wv.mu.Unlock()384 h.wv.mu.Unlock()
383 }385 }
@@ -451,8 +453,7 @@ const maxConsecutiveCrashes = 3
451 453
452 // OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.454 // OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.
453 func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {455 func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {
454- h.wv.crashCount++456+ if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {
455- if h.wv.crashCount > maxConsecutiveCrashes {
456 return // suppress to break the loop457 return // suppress to break the loop
457 }458 }
458 459
M internal/infrastructure/cef/webview_handlers_test.go +29 -39
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 purecef "github.com/bnema/purego-cef/cef"
7+ "github.com/stretchr/testify/require"
78
89 "github.com/bnema/dumber/internal/application/port"
910 )
@@ -13,39 +14,34 @@ type stubFrame struct {
1314 url string
1415 }
1516
16-func (f stubFrame) IsValid() bool { return true }
17-func (f stubFrame) Undo() {}
18-func (f stubFrame) Redo() {}
19-func (f stubFrame) Cut() {}
20-func (f stubFrame) Copy() {}
21-func (f stubFrame) Paste() {}
22-func (f stubFrame) PasteAndMatchStyle() {
23-}
24-func (f stubFrame) Del() {}
25-func (f stubFrame) SelectAll() {}
26-func (f stubFrame) ViewSource() {}
27-func (f stubFrame) GetSource(purecef.StringVisitor) {
28-}
29-func (f stubFrame) GetText(purecef.StringVisitor) {
30-}
31-func (f stubFrame) LoadRequest(purecef.Request) {}
32-func (f stubFrame) LoadURL(string) {}
33-func (f stubFrame) ExecuteJavaScript(string, string, int32) {
34-}
35-func (f stubFrame) IsMain() bool { return f.main }
36-func (f stubFrame) IsFocused() bool { return false }
37-func (f stubFrame) GetName() string { return "" }
38-func (f stubFrame) GetIdentifier() string { return "" }
39-func (f stubFrame) GetParent() purecef.Frame { return nil }
40-func (f stubFrame) GetURL() string { return f.url }
41-func (f stubFrame) GetBrowser() purecef.Browser { return nil }
42-func (f stubFrame) GetV8Context() purecef.V8Context { return nil }
43-func (f stubFrame) VisitDom(purecef.Domvisitor) {}
17+func (f stubFrame) IsValid() bool { return true }
18+func (f stubFrame) Undo() {}
19+func (f stubFrame) Redo() {}
20+func (f stubFrame) Cut() {}
21+func (f stubFrame) Copy() {}
22+func (f stubFrame) Paste() {}
23+func (f stubFrame) PasteAndMatchStyle() {}
24+func (f stubFrame) Del() {}
25+func (f stubFrame) SelectAll() {}
26+func (f stubFrame) ViewSource() {}
27+func (f stubFrame) GetSource(purecef.StringVisitor) {}
28+func (f stubFrame) GetText(purecef.StringVisitor) {}
29+func (f stubFrame) LoadRequest(purecef.Request) {}
30+func (f stubFrame) LoadURL(string) {}
31+func (f stubFrame) ExecuteJavaScript(string, string, int32) {}
32+func (f stubFrame) IsMain() bool { return f.main }
33+func (f stubFrame) IsFocused() bool { return false }
34+func (f stubFrame) GetName() string { return "" }
35+func (f stubFrame) GetIdentifier() string { return "" }
36+func (f stubFrame) GetParent() purecef.Frame { return nil }
37+func (f stubFrame) GetURL() string { return f.url }
38+func (f stubFrame) GetBrowser() purecef.Browser { return nil }
39+func (f stubFrame) GetV8Context() purecef.V8Context { return nil }
40+func (f stubFrame) VisitDom(purecef.Domvisitor) {}
41+func (f stubFrame) SendProcessMessage(purecef.ProcessID, purecef.ProcessMessage) {}
4442 func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) purecef.Urlrequest {
4543 return nil
4644 }
47-func (f stubFrame) SendProcessMessage(purecef.ProcessID, purecef.ProcessMessage) {
48-}
4945
5046 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
5147 wv := &WebView{}
@@ -59,13 +55,7 @@ func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
5955 h := &handlerSet{wv: wv}
6056 h.OnLoadStart(nil, stubFrame{main: true, url: "https://google.com"}, 0)
6157
62- if len(gotEvents) != 1 {
63- t.Fatalf("expected 1 load event, got %d", len(gotEvents))
64- }
65- if gotEvents[0] != port.LoadCommitted {
66- t.Fatalf("expected %v, got %v", port.LoadCommitted, gotEvents[0])
67- }
68- if gotURI := wv.URI(); gotURI != "https://google.com" {
69- t.Fatalf("expected URI to update from frame on load commit, got %q", gotURI)
70- }
58+ require.Len(t, gotEvents, 1)
59+ require.Equal(t, port.LoadCommitted, gotEvents[0])
60+ require.Equal(t, "https://google.com", wv.URI())
7161 }
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 purecef "github.com/bnema/purego-cef/cef"6 purecef "github.com/bnema/purego-cef/cef"
7+ "github.com/stretchr/testify/require"
7 8
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
9 )10 )
@@ -13,39 +14,34 @@ type stubFrame struct {
13 url string14 url string
14 }15 }
15 16
16-func (f stubFrame) IsValid() bool { return true }17+func (f stubFrame) IsValid() bool { return true }
17-func (f stubFrame) Undo() {}18+func (f stubFrame) Undo() {}
18-func (f stubFrame) Redo() {}19+func (f stubFrame) Redo() {}
19-func (f stubFrame) Cut() {}20+func (f stubFrame) Cut() {}
20-func (f stubFrame) Copy() {}21+func (f stubFrame) Copy() {}
21-func (f stubFrame) Paste() {}22+func (f stubFrame) Paste() {}
22-func (f stubFrame) PasteAndMatchStyle() {23+func (f stubFrame) PasteAndMatchStyle() {}
23-}24+func (f stubFrame) Del() {}
24-func (f stubFrame) Del() {}25+func (f stubFrame) SelectAll() {}
25-func (f stubFrame) SelectAll() {}26+func (f stubFrame) ViewSource() {}
26-func (f stubFrame) ViewSource() {}27+func (f stubFrame) GetSource(purecef.StringVisitor) {}
27-func (f stubFrame) GetSource(purecef.StringVisitor) {28+func (f stubFrame) GetText(purecef.StringVisitor) {}
28-}29+func (f stubFrame) LoadRequest(purecef.Request) {}
29-func (f stubFrame) GetText(purecef.StringVisitor) {30+func (f stubFrame) LoadURL(string) {}
30-}31+func (f stubFrame) ExecuteJavaScript(string, string, int32) {}
31-func (f stubFrame) LoadRequest(purecef.Request) {}32+func (f stubFrame) IsMain() bool { return f.main }
32-func (f stubFrame) LoadURL(string) {}33+func (f stubFrame) IsFocused() bool { return false }
33-func (f stubFrame) ExecuteJavaScript(string, string, int32) {34+func (f stubFrame) GetName() string { return "" }
34-}35+func (f stubFrame) GetIdentifier() string { return "" }
35-func (f stubFrame) IsMain() bool { return f.main }36+func (f stubFrame) GetParent() purecef.Frame { return nil }
36-func (f stubFrame) IsFocused() bool { return false }37+func (f stubFrame) GetURL() string { return f.url }
37-func (f stubFrame) GetName() string { return "" }38+func (f stubFrame) GetBrowser() purecef.Browser { return nil }
38-func (f stubFrame) GetIdentifier() string { return "" }39+func (f stubFrame) GetV8Context() purecef.V8Context { return nil }
39-func (f stubFrame) GetParent() purecef.Frame { return nil }40+func (f stubFrame) VisitDom(purecef.Domvisitor) {}
40-func (f stubFrame) GetURL() string { return f.url }41+func (f stubFrame) SendProcessMessage(purecef.ProcessID, purecef.ProcessMessage) {}
41-func (f stubFrame) GetBrowser() purecef.Browser { return nil }
42-func (f stubFrame) GetV8Context() purecef.V8Context { return nil }
43-func (f stubFrame) VisitDom(purecef.Domvisitor) {}
44 func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) purecef.Urlrequest {42 func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) purecef.Urlrequest {
45 return nil43 return nil
46 }44 }
47-func (f stubFrame) SendProcessMessage(purecef.ProcessID, purecef.ProcessMessage) {
48-}
49 45
50 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {46 func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
51 wv := &WebView{}47 wv := &WebView{}
@@ -59,13 +55,7 @@ func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
59 h := &handlerSet{wv: wv}55 h := &handlerSet{wv: wv}
60 h.OnLoadStart(nil, stubFrame{main: true, url: "https://google.com"}, 0)56 h.OnLoadStart(nil, stubFrame{main: true, url: "https://google.com"}, 0)
61 57
62- if len(gotEvents) != 1 {58+ require.Len(t, gotEvents, 1)
63- t.Fatalf("expected 1 load event, got %d", len(gotEvents))59+ require.Equal(t, port.LoadCommitted, gotEvents[0])
64- }60+ require.Equal(t, "https://google.com", wv.URI())
65- if gotEvents[0] != port.LoadCommitted {
66- t.Fatalf("expected %v, got %v", port.LoadCommitted, gotEvents[0])
67- }
68- if gotURI := wv.URI(); gotURI != "https://google.com" {
69- t.Fatalf("expected URI to update from frame on load commit, got %q", gotURI)
70- }
71 }61 }

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