detached 8e234aa

chore(deps): integrate cef2gtk presentation synchronization

commit
8e234aa0672cf5b4d0f5f6cda42a89e26d8e3459
author
brice · · 2026-07-14 08:45 UTC
committer
brice · · 2026-07-14 09:35 UTC
parent
0d8c582
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +134 -19
M go.mod +1 -1
@@ -7,7 +7,7 @@ require (
77 github.com/andybalholm/brotli v1.2.2
88 github.com/bnema/purego v0.11.0-bnema.4
99 github.com/bnema/purego-cef v0.13.3
10- github.com/bnema/purego-cef2gtk v0.8.3
10+ github.com/bnema/purego-cef2gtk v0.8.4-0.20260714083412-f217ece342de
1111 github.com/bnema/purego-pipewire v0.1.4
1212 github.com/bnema/purego-sqlite v0.1.3
1313 github.com/bnema/puregotk v0.7.0
@@ -7,7 +7,7 @@ require (
7 github.com/andybalholm/brotli v1.2.27 github.com/andybalholm/brotli v1.2.2
8 github.com/bnema/purego v0.11.0-bnema.48 github.com/bnema/purego v0.11.0-bnema.4
9 github.com/bnema/purego-cef v0.13.39 github.com/bnema/purego-cef v0.13.3
10- github.com/bnema/purego-cef2gtk v0.8.310+ github.com/bnema/purego-cef2gtk v0.8.4-0.20260714083412-f217ece342de
11 github.com/bnema/purego-pipewire v0.1.411 github.com/bnema/purego-pipewire v0.1.4
12 github.com/bnema/purego-sqlite v0.1.312 github.com/bnema/purego-sqlite v0.1.3
13 github.com/bnema/puregotk v0.7.013 github.com/bnema/puregotk v0.7.0
M go.sum +2 -0 generated file: collapsed
M internal/logging/startup_trace.go +5 -1
@@ -151,9 +151,13 @@ func (st *StartupTrace) mark(name string) bool {
151151
152152 now := st.now()
153153 milestone := Milestone{Name: name, Elapsed: now.Sub(st.t0)}
154+ // Published fields are integer milliseconds. Compute delta on that same
155+ // scale so delta_ms always exactly reconstructs t_ms in collected evidence.
156+ previousMilliseconds := int64(0)
154157 if len(st.milestones) > 0 {
155- milestone.Delta = milestone.Elapsed - st.milestones[len(st.milestones)-1].Elapsed
158+ previousMilliseconds = st.milestones[len(st.milestones)-1].Elapsed.Milliseconds()
156159 }
160+ milestone.Delta = time.Duration(milestone.Elapsed.Milliseconds()-previousMilliseconds) * time.Millisecond
157161 st.milestones = append(st.milestones, milestone)
158162 if st.logger == nil {
159163 st.buffered = append(st.buffered, milestone)
@@ -151,9 +151,13 @@ func (st *StartupTrace) mark(name string) bool {
151 151
152 now := st.now()152 now := st.now()
153 milestone := Milestone{Name: name, Elapsed: now.Sub(st.t0)}153 milestone := Milestone{Name: name, Elapsed: now.Sub(st.t0)}
154+ // Published fields are integer milliseconds. Compute delta on that same
155+ // scale so delta_ms always exactly reconstructs t_ms in collected evidence.
156+ previousMilliseconds := int64(0)
154 if len(st.milestones) > 0 {157 if len(st.milestones) > 0 {
155- milestone.Delta = milestone.Elapsed - st.milestones[len(st.milestones)-1].Elapsed158+ previousMilliseconds = st.milestones[len(st.milestones)-1].Elapsed.Milliseconds()
156 }159 }
160+ milestone.Delta = time.Duration(milestone.Elapsed.Milliseconds()-previousMilliseconds) * time.Millisecond
157 st.milestones = append(st.milestones, milestone)161 st.milestones = append(st.milestones, milestone)
158 if st.logger == nil {162 if st.logger == nil {
159 st.buffered = append(st.buffered, milestone)163 st.buffered = append(st.buffered, milestone)
M internal/logging/startup_trace_test.go +15 -0
@@ -36,6 +36,21 @@ func TestStartupTraceAcceptsOnlyOrderedOneShotMilestones(t *testing.T) {
3636 require.False(t, trace.Mark("first_gtk_presentation"))
3737 }
3838
39+func TestStartupTraceQuantizesDeltaToPublishedMilliseconds(t *testing.T) {
40+ now := time.Unix(100, 0)
41+ trace := newStartupTrace(func() time.Time { return now })
42+
43+ now = now.Add(1500 * time.Microsecond)
44+ require.True(t, trace.Mark("process_entry"))
45+ now = now.Add(900 * time.Microsecond)
46+ require.True(t, trace.Mark("config_complete"))
47+
48+ require.Equal(t, int64(1), trace.milestones[0].Elapsed.Milliseconds())
49+ require.Equal(t, int64(1), trace.milestones[0].Delta.Milliseconds())
50+ require.Equal(t, int64(2), trace.milestones[1].Elapsed.Milliseconds())
51+ require.Equal(t, int64(1), trace.milestones[1].Delta.Milliseconds())
52+}
53+
3954 func TestStartupTraceFinishCannotFabricateFirstGTKPresentation(t *testing.T) {
4055 now := time.Unix(100, 0)
4156 trace := newStartupTrace(func() time.Time { return now })
@@ -36,6 +36,21 @@ func TestStartupTraceAcceptsOnlyOrderedOneShotMilestones(t *testing.T) {
36 require.False(t, trace.Mark("first_gtk_presentation"))36 require.False(t, trace.Mark("first_gtk_presentation"))
37 }37 }
38 38
39+func TestStartupTraceQuantizesDeltaToPublishedMilliseconds(t *testing.T) {
40+ now := time.Unix(100, 0)
41+ trace := newStartupTrace(func() time.Time { return now })
42+
43+ now = now.Add(1500 * time.Microsecond)
44+ require.True(t, trace.Mark("process_entry"))
45+ now = now.Add(900 * time.Microsecond)
46+ require.True(t, trace.Mark("config_complete"))
47+
48+ require.Equal(t, int64(1), trace.milestones[0].Elapsed.Milliseconds())
49+ require.Equal(t, int64(1), trace.milestones[0].Delta.Milliseconds())
50+ require.Equal(t, int64(2), trace.milestones[1].Elapsed.Milliseconds())
51+ require.Equal(t, int64(1), trace.milestones[1].Delta.Milliseconds())
52+}
53+
39 func TestStartupTraceFinishCannotFabricateFirstGTKPresentation(t *testing.T) {54 func TestStartupTraceFinishCannotFabricateFirstGTKPresentation(t *testing.T) {
40 now := time.Unix(100, 0)55 now := time.Unix(100, 0)
41 trace := newStartupTrace(func() time.Time { return now })56 trace := newStartupTrace(func() time.Time { return now })
M vendor/github.com/bnema/purego-cef2gtk/handlers.go +85 -14
@@ -2,6 +2,7 @@ package cef2gtk
22
33 import (
44 "errors"
5+ "sync"
56 "time"
67
78 "github.com/bnema/purego-cef/cef"
@@ -17,6 +18,48 @@ type asyncRenderQueue interface {
1718 ImportAndQueueAsync(*cef.AcceleratedPaintInfo, func(error)) error
1819 }
1920
21+// deferredAcceleratedError prevents an async renderer that reports an error
22+// synchronously from invoking a user hook while OnAcceleratedPaint holds the
23+// lifecycle read lock. Errors reported after release are already outside it.
24+type deferredAcceleratedError struct {
25+ h *renderHandler
26+ mu sync.Mutex
27+ released bool
28+ pending []error
29+}
30+
31+func (d *deferredAcceleratedError) report(err error) {
32+ if d == nil || err == nil {
33+ return
34+ }
35+ d.mu.Lock()
36+ if !d.released {
37+ d.pending = append(d.pending, err)
38+ d.mu.Unlock()
39+ return
40+ }
41+ d.mu.Unlock()
42+ d.h.handleAcceleratedError(err)
43+}
44+
45+func (d *deferredAcceleratedError) release() {
46+ if d == nil {
47+ return
48+ }
49+ d.mu.Lock()
50+ if d.released {
51+ d.mu.Unlock()
52+ return
53+ }
54+ d.released = true
55+ pending := d.pending
56+ d.pending = nil
57+ d.mu.Unlock()
58+ for _, err := range pending {
59+ d.h.handleAcceleratedError(err)
60+ }
61+}
62+
2063 type renderHandler struct {
2164 view *View
2265 renderer renderQueue
@@ -31,7 +74,13 @@ func (v *View) RenderHandler(hooks Hooks) cef.RenderHandler {
3174 if v == nil {
3275 return &renderHandler{staticHooks: hooks}
3376 }
34- v.hooks = hooks
77+
78+ // Serialize handler replacement with Destroy, which clears both handler and
79+ // renderer. Hook readers use firstPresentationMu and always copy before
80+ // invoking user code.
81+ v.renderLifecycleMu.Lock()
82+ defer v.renderLifecycleMu.Unlock()
83+ v.setHooks(hooks)
3584 v.configureFirstPresentationHooks()
3685 if v.handler != nil {
3786 return v.handler
@@ -128,42 +177,64 @@ func (h *renderHandler) OnAcceleratedPaint(_ cef.Browser, _ cef.PaintElementType
128177 if first {
129178 invokeFirstAcceleratedPaintHooks(hooks, unsupported)
130179 }
180+ }
131181
132- // Serialize accelerated-paint imports and render queueing with Destroy's
133- // renderer teardown. The hook above may have destroyed the view.
182+ errorDelivery := &deferredAcceleratedError{h: h}
183+ queued := false
184+ if h.view != nil {
185+ // Keep imports and queueing serialized with renderer teardown. An async
186+ // renderer may call its error function synchronously, so defer its hook
187+ // delivery until after this lock has been released.
134188 h.view.renderLifecycleMu.RLock()
135- defer h.view.renderLifecycleMu.RUnlock()
136189 if h.view.destroyed.Load() {
190+ h.view.renderLifecycleMu.RUnlock()
137191 if h.diag != nil {
138192 h.diag.RecordStaleAcceleratedPaint()
139193 }
140194 return
141195 }
196+ queued = h.importAndQueueAcceleratedFrame(info, errorDelivery.report)
197+ h.view.renderLifecycleMu.RUnlock()
198+ } else {
199+ errorDelivery.release()
200+ queued = h.importAndQueueAcceleratedFrame(info, errorDelivery.report)
201+ }
202+
203+ // No user callback (including ProfileOptions.OnSnapshot) may run while the
204+ // lifecycle lock above protects renderer teardown.
205+ errorDelivery.release()
206+ if queued && h.view != nil {
207+ h.view.emitProfileIfDue(time.Now())
142208 }
209+}
210+
211+// importAndQueueAcceleratedFrame performs renderer work while its caller holds
212+// the lifecycle lock, if any. onError must not invoke user code synchronously.
213+func (h *renderHandler) importAndQueueAcceleratedFrame(info *cef.AcceleratedPaintInfo, onError func(error)) bool {
143214 if h.renderer == nil {
144- h.handleAcceleratedError(gtkgl.ErrNilAcceleratedRenderer)
145- return
215+ onError(gtkgl.ErrNilAcceleratedRenderer)
216+ return false
146217 }
147218 if async, ok := h.renderer.(asyncRenderQueue); ok {
148- if err := async.ImportAndQueueAsync(info, h.handleAcceleratedError); err != nil {
149- h.handleAcceleratedError(err)
150- return
219+ if err := async.ImportAndQueueAsync(info, onError); err != nil {
220+ onError(err)
221+ return false
151222 }
152223 h.recordAcceleratedFrameQueued()
153- return
224+ return true
154225 }
155226 if _, err := h.renderer.ImportAndQueueOnGTKThread(info); err != nil {
156- h.handleAcceleratedError(err)
157- return
227+ onError(err)
228+ return false
158229 }
159230 h.recordAcceleratedFrameQueued()
231+ return true
160232 }
161233
162234 func (h *renderHandler) recordAcceleratedFrameQueued() {
163235 h.renderer.QueueRender()
164236 if h.view != nil {
165237 h.view.recordProfileFrameQueued()
166- h.view.emitProfileIfDue(time.Now())
167238 }
168239 }
169240 func (h *renderHandler) handleAcceleratedError(err error) {
@@ -188,7 +259,7 @@ func isTransientGLAreaLifecycleError(err error) bool {
188259 }
189260 func (h *renderHandler) hooks() Hooks {
190261 if h != nil && h.view != nil {
191- return h.view.hooks
262+ return h.view.snapshotHooks()
192263 }
193264 if h != nil {
194265 return h.staticHooks
@@ -2,6 +2,7 @@ package cef2gtk
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "sync"
5 "time"6 "time"
6 7
7 "github.com/bnema/purego-cef/cef"8 "github.com/bnema/purego-cef/cef"
@@ -17,6 +18,48 @@ type asyncRenderQueue interface {
17 ImportAndQueueAsync(*cef.AcceleratedPaintInfo, func(error)) error18 ImportAndQueueAsync(*cef.AcceleratedPaintInfo, func(error)) error
18 }19 }
19 20
21+// deferredAcceleratedError prevents an async renderer that reports an error
22+// synchronously from invoking a user hook while OnAcceleratedPaint holds the
23+// lifecycle read lock. Errors reported after release are already outside it.
24+type deferredAcceleratedError struct {
25+ h *renderHandler
26+ mu sync.Mutex
27+ released bool
28+ pending []error
29+}
30+
31+func (d *deferredAcceleratedError) report(err error) {
32+ if d == nil || err == nil {
33+ return
34+ }
35+ d.mu.Lock()
36+ if !d.released {
37+ d.pending = append(d.pending, err)
38+ d.mu.Unlock()
39+ return
40+ }
41+ d.mu.Unlock()
42+ d.h.handleAcceleratedError(err)
43+}
44+
45+func (d *deferredAcceleratedError) release() {
46+ if d == nil {
47+ return
48+ }
49+ d.mu.Lock()
50+ if d.released {
51+ d.mu.Unlock()
52+ return
53+ }
54+ d.released = true
55+ pending := d.pending
56+ d.pending = nil
57+ d.mu.Unlock()
58+ for _, err := range pending {
59+ d.h.handleAcceleratedError(err)
60+ }
61+}
62+
20 type renderHandler struct {63 type renderHandler struct {
21 view *View64 view *View
22 renderer renderQueue65 renderer renderQueue
@@ -31,7 +74,13 @@ func (v *View) RenderHandler(hooks Hooks) cef.RenderHandler {
31 if v == nil {74 if v == nil {
32 return &renderHandler{staticHooks: hooks}75 return &renderHandler{staticHooks: hooks}
33 }76 }
34- v.hooks = hooks77+
78+ // Serialize handler replacement with Destroy, which clears both handler and
79+ // renderer. Hook readers use firstPresentationMu and always copy before
80+ // invoking user code.
81+ v.renderLifecycleMu.Lock()
82+ defer v.renderLifecycleMu.Unlock()
83+ v.setHooks(hooks)
35 v.configureFirstPresentationHooks()84 v.configureFirstPresentationHooks()
36 if v.handler != nil {85 if v.handler != nil {
37 return v.handler86 return v.handler
@@ -128,42 +177,64 @@ func (h *renderHandler) OnAcceleratedPaint(_ cef.Browser, _ cef.PaintElementType
128 if first {177 if first {
129 invokeFirstAcceleratedPaintHooks(hooks, unsupported)178 invokeFirstAcceleratedPaintHooks(hooks, unsupported)
130 }179 }
180+ }
131 181
132- // Serialize accelerated-paint imports and render queueing with Destroy's182+ errorDelivery := &deferredAcceleratedError{h: h}
133- // renderer teardown. The hook above may have destroyed the view.183+ queued := false
184+ if h.view != nil {
185+ // Keep imports and queueing serialized with renderer teardown. An async
186+ // renderer may call its error function synchronously, so defer its hook
187+ // delivery until after this lock has been released.
134 h.view.renderLifecycleMu.RLock()188 h.view.renderLifecycleMu.RLock()
135- defer h.view.renderLifecycleMu.RUnlock()
136 if h.view.destroyed.Load() {189 if h.view.destroyed.Load() {
190+ h.view.renderLifecycleMu.RUnlock()
137 if h.diag != nil {191 if h.diag != nil {
138 h.diag.RecordStaleAcceleratedPaint()192 h.diag.RecordStaleAcceleratedPaint()
139 }193 }
140 return194 return
141 }195 }
196+ queued = h.importAndQueueAcceleratedFrame(info, errorDelivery.report)
197+ h.view.renderLifecycleMu.RUnlock()
198+ } else {
199+ errorDelivery.release()
200+ queued = h.importAndQueueAcceleratedFrame(info, errorDelivery.report)
201+ }
202+
203+ // No user callback (including ProfileOptions.OnSnapshot) may run while the
204+ // lifecycle lock above protects renderer teardown.
205+ errorDelivery.release()
206+ if queued && h.view != nil {
207+ h.view.emitProfileIfDue(time.Now())
142 }208 }
209+}
210+
211+// importAndQueueAcceleratedFrame performs renderer work while its caller holds
212+// the lifecycle lock, if any. onError must not invoke user code synchronously.
213+func (h *renderHandler) importAndQueueAcceleratedFrame(info *cef.AcceleratedPaintInfo, onError func(error)) bool {
143 if h.renderer == nil {214 if h.renderer == nil {
144- h.handleAcceleratedError(gtkgl.ErrNilAcceleratedRenderer)215+ onError(gtkgl.ErrNilAcceleratedRenderer)
145- return216+ return false
146 }217 }
147 if async, ok := h.renderer.(asyncRenderQueue); ok {218 if async, ok := h.renderer.(asyncRenderQueue); ok {
148- if err := async.ImportAndQueueAsync(info, h.handleAcceleratedError); err != nil {219+ if err := async.ImportAndQueueAsync(info, onError); err != nil {
149- h.handleAcceleratedError(err)220+ onError(err)
150- return221+ return false
151 }222 }
152 h.recordAcceleratedFrameQueued()223 h.recordAcceleratedFrameQueued()
153- return224+ return true
154 }225 }
155 if _, err := h.renderer.ImportAndQueueOnGTKThread(info); err != nil {226 if _, err := h.renderer.ImportAndQueueOnGTKThread(info); err != nil {
156- h.handleAcceleratedError(err)227+ onError(err)
157- return228+ return false
158 }229 }
159 h.recordAcceleratedFrameQueued()230 h.recordAcceleratedFrameQueued()
231+ return true
160 }232 }
161 233
162 func (h *renderHandler) recordAcceleratedFrameQueued() {234 func (h *renderHandler) recordAcceleratedFrameQueued() {
163 h.renderer.QueueRender()235 h.renderer.QueueRender()
164 if h.view != nil {236 if h.view != nil {
165 h.view.recordProfileFrameQueued()237 h.view.recordProfileFrameQueued()
166- h.view.emitProfileIfDue(time.Now())
167 }238 }
168 }239 }
169 func (h *renderHandler) handleAcceleratedError(err error) {240 func (h *renderHandler) handleAcceleratedError(err error) {
@@ -188,7 +259,7 @@ func isTransientGLAreaLifecycleError(err error) bool {
188 }259 }
189 func (h *renderHandler) hooks() Hooks {260 func (h *renderHandler) hooks() Hooks {
190 if h != nil && h.view != nil {261 if h != nil && h.view != nil {
191- return h.view.hooks262+ return h.view.snapshotHooks()
192 }263 }
193 if h != nil {264 if h != nil {
194 return h.staticHooks265 return h.staticHooks
M vendor/github.com/bnema/purego-cef2gtk/view.go +25 -2 generated file: collapsed
M vendor/modules.txt +1 -1 generated file: collapsed

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