detached d7e36b4

fix(cef): extend resize repaint pulses past capturer size adoption (#456)

* fix(cef): extend resize repaint pulses past capturer size adoption

CEF's OSR capturer letterboxes frames until it captures one at the new size,
which can take up to ~1s on static pages. Keep issuing refresh demands for
400ms after a resize so the correctly sized frame arrives quickly.

* fix(cef): limit late resize pulses to repaint requests

Only the first two delayed resize pulses resync the viewport. Later pulses
call Invalidate alone, which is enough to force a new capture, and avoid
reallocating the CEF surface ID on every pulse during drag resizes.

* chore(deps): bump purego-cef2gtk to v0.11.3
commit
d7e36b4c596f0710684f0c881875381b4b0a2ca1
author
bnema · · 2026-09-29 16:31 UTC
committer
GitHub · · 2026-09-29 16:31 UTC
parent
6e9ad5e
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
4 files · +76 -37
M go.mod +1 -1
@@ -7,7 +7,7 @@ require (
77 github.com/andybalholm/brotli v1.2.4
88 github.com/bnema/purego v0.12.0-bnema.1
99 github.com/bnema/purego-cef v0.14.3
10- github.com/bnema/purego-cef2gtk v0.11.2
10+ github.com/bnema/purego-cef2gtk v0.11.3
1111 github.com/bnema/purego-pipewire v0.1.6
1212 github.com/bnema/purego-sqlite v0.1.5
1313 github.com/bnema/purego-webp v0.2.1
@@ -7,7 +7,7 @@ require (
7 github.com/andybalholm/brotli v1.2.47 github.com/andybalholm/brotli v1.2.4
8 github.com/bnema/purego v0.12.0-bnema.18 github.com/bnema/purego v0.12.0-bnema.1
9 github.com/bnema/purego-cef v0.14.39 github.com/bnema/purego-cef v0.14.3
10- github.com/bnema/purego-cef2gtk v0.11.210+ github.com/bnema/purego-cef2gtk v0.11.3
11 github.com/bnema/purego-pipewire v0.1.611 github.com/bnema/purego-pipewire v0.1.6
12 github.com/bnema/purego-sqlite v0.1.512 github.com/bnema/purego-sqlite v0.1.5
13 github.com/bnema/purego-webp v0.2.113 github.com/bnema/purego-webp v0.2.1
M go.sum +2 -0 generated file: collapsed
M internal/infrastructure/cef/webview_viewport.go +50 -20
@@ -180,6 +180,17 @@ func (wv *WebView) syncResizeViewportOnGTK(ctx context.Context, reason string) b
180180 return true
181181 }
182182
183+// resizeViewportResyncDelaysMs re-run the full viewport resize notification
184+// shortly after a GTK size change.
185+var resizeViewportResyncDelaysMs = [...]int64{16, 48}
186+
187+// resizeRepaintOnlyDelaysMs keep requesting refresh frames until CEF's OSR
188+// video capturer adopts the new frame size. Until then captured frames are
189+// letterboxed; Invalidate forces a fresh capture instead of waiting for the
190+// capturer's idle refresh timer (up to ~1s on static pages). These pulses skip
191+// NotifyScreenInfoChanged, which would reallocate the surface ID each time.
192+var resizeRepaintOnlyDelaysMs = [...]int64{96, 160, 250, 400}
193+
183194 func (wv *WebView) scheduleResizeRepaintPulse(ctx context.Context, reason string) {
184195 if wv == nil || wv.destroyed.Load() {
185196 return
@@ -191,29 +202,48 @@ func (wv *WebView) scheduleResizeRepaintPulse(ctx context.Context, reason string
191202 return
192203 }
193204 seq := wv.viewportResizePulseSeq.Add(1)
194- for _, delayMs := range [...]int64{16, 48} {
195- task := cefNewTask(cefTaskFunc(func() {
196- if wv == nil || wv.destroyed.Load() || wv.viewportResizePulseSeq.Load() != seq {
197- return
198- }
199- wv.mu.RLock()
200- currentHost := wv.host
201- wv.mu.RUnlock()
202- if currentHost != host {
203- return
204- }
205+ for _, delayMs := range resizeViewportResyncDelaysMs {
206+ wv.postResizePulse(ctx, host, seq, delayMs, reason, true)
207+ }
208+ for _, delayMs := range resizeRepaintOnlyDelaysMs {
209+ wv.postResizePulse(ctx, host, seq, delayMs, reason, false)
210+ }
211+}
212+
213+func (wv *WebView) postResizePulse(
214+ ctx context.Context,
215+ host viewportSyncBrowserHost,
216+ seq uint64,
217+ delayMs int64,
218+ reason string,
219+ resync bool,
220+) {
221+ task := cefNewTask(cefTaskFunc(func() {
222+ if wv == nil || wv.destroyed.Load() || wv.viewportResizePulseSeq.Load() != seq {
223+ return
224+ }
225+ wv.mu.RLock()
226+ currentHost := wv.host
227+ wv.mu.RUnlock()
228+ if currentHost != host {
229+ return
230+ }
231+ if resync {
205232 notifyBrowserViewportResize(host)
206- logging.FromContext(ctx).Debug().
207- Uint64("webview_id", uint64(wv.id)).
208- Int64("delay_ms", delayMs).
209- Str("reason", reason).
210- Msg("cef: delayed resize viewport sync")
211- }))
212- if task == nil {
213- continue
233+ } else {
234+ host.Invalidate(purecef.PaintElementTypePetView)
214235 }
215- cefPostDelayedTask(purecef.ThreadIDTidUi, task, delayMs)
236+ logging.FromContext(ctx).Debug().
237+ Uint64("webview_id", uint64(wv.id)).
238+ Int64("delay_ms", delayMs).
239+ Bool("resync", resync).
240+ Str("reason", reason).
241+ Msg("cef: delayed resize viewport sync")
242+ }))
243+ if task == nil {
244+ return
216245 }
246+ cefPostDelayedTask(purecef.ThreadIDTidUi, task, delayMs)
217247 }
218248
219249 func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, reason string) bool {
@@ -180,6 +180,17 @@ func (wv *WebView) syncResizeViewportOnGTK(ctx context.Context, reason string) b
180 return true180 return true
181 }181 }
182 182
183+// resizeViewportResyncDelaysMs re-run the full viewport resize notification
184+// shortly after a GTK size change.
185+var resizeViewportResyncDelaysMs = [...]int64{16, 48}
186+
187+// resizeRepaintOnlyDelaysMs keep requesting refresh frames until CEF's OSR
188+// video capturer adopts the new frame size. Until then captured frames are
189+// letterboxed; Invalidate forces a fresh capture instead of waiting for the
190+// capturer's idle refresh timer (up to ~1s on static pages). These pulses skip
191+// NotifyScreenInfoChanged, which would reallocate the surface ID each time.
192+var resizeRepaintOnlyDelaysMs = [...]int64{96, 160, 250, 400}
193+
183 func (wv *WebView) scheduleResizeRepaintPulse(ctx context.Context, reason string) {194 func (wv *WebView) scheduleResizeRepaintPulse(ctx context.Context, reason string) {
184 if wv == nil || wv.destroyed.Load() {195 if wv == nil || wv.destroyed.Load() {
185 return196 return
@@ -191,29 +202,48 @@ func (wv *WebView) scheduleResizeRepaintPulse(ctx context.Context, reason string
191 return202 return
192 }203 }
193 seq := wv.viewportResizePulseSeq.Add(1)204 seq := wv.viewportResizePulseSeq.Add(1)
194- for _, delayMs := range [...]int64{16, 48} {205+ for _, delayMs := range resizeViewportResyncDelaysMs {
195- task := cefNewTask(cefTaskFunc(func() {206+ wv.postResizePulse(ctx, host, seq, delayMs, reason, true)
196- if wv == nil || wv.destroyed.Load() || wv.viewportResizePulseSeq.Load() != seq {207+ }
197- return208+ for _, delayMs := range resizeRepaintOnlyDelaysMs {
198- }209+ wv.postResizePulse(ctx, host, seq, delayMs, reason, false)
199- wv.mu.RLock()210+ }
200- currentHost := wv.host211+}
201- wv.mu.RUnlock()212+
202- if currentHost != host {213+func (wv *WebView) postResizePulse(
203- return214+ ctx context.Context,
204- }215+ host viewportSyncBrowserHost,
216+ seq uint64,
217+ delayMs int64,
218+ reason string,
219+ resync bool,
220+) {
221+ task := cefNewTask(cefTaskFunc(func() {
222+ if wv == nil || wv.destroyed.Load() || wv.viewportResizePulseSeq.Load() != seq {
223+ return
224+ }
225+ wv.mu.RLock()
226+ currentHost := wv.host
227+ wv.mu.RUnlock()
228+ if currentHost != host {
229+ return
230+ }
231+ if resync {
205 notifyBrowserViewportResize(host)232 notifyBrowserViewportResize(host)
206- logging.FromContext(ctx).Debug().233+ } else {
207- Uint64("webview_id", uint64(wv.id)).234+ host.Invalidate(purecef.PaintElementTypePetView)
208- Int64("delay_ms", delayMs).
209- Str("reason", reason).
210- Msg("cef: delayed resize viewport sync")
211- }))
212- if task == nil {
213- continue
214 }235 }
215- cefPostDelayedTask(purecef.ThreadIDTidUi, task, delayMs)236+ logging.FromContext(ctx).Debug().
237+ Uint64("webview_id", uint64(wv.id)).
238+ Int64("delay_ms", delayMs).
239+ Bool("resync", resync).
240+ Str("reason", reason).
241+ Msg("cef: delayed resize viewport sync")
242+ }))
243+ if task == nil {
244+ return
216 }245 }
246+ cefPostDelayedTask(purecef.ThreadIDTidUi, task, delayMs)
217 }247 }
218 248
219 func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, reason string) bool {249 func (wv *WebView) tryStartPendingBrowserCreateOnGTKThread(ctx context.Context, reason string) bool {
M internal/infrastructure/cef/webview_viewport_test.go +23 -16
@@ -525,21 +525,26 @@ func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) {
525525 wv.scheduleResizeRepaintPulse(context.Background(), "first")
526526 wv.scheduleResizeRepaintPulse(context.Background(), "second")
527527
528- require.Equal(t, []int64{16, 48, 16, 48}, delays)
529- require.Len(t, scheduled, 4)
528+ const pulses = 6
529+ require.Equal(t, []int64{16, 48, 96, 160, 250, 400, 16, 48, 96, 160, 250, 400}, delays)
530+ require.Len(t, scheduled, 2*pulses)
530531
531- scheduled[0].Execute()
532- scheduled[1].Execute()
532+ for _, task := range scheduled[:pulses] {
533+ task.Execute()
534+ }
533535 require.Empty(t, host.calls)
534536
535- scheduled[2].Execute()
536- scheduled[3].Execute()
537+ for _, task := range scheduled[pulses:] {
538+ task.Execute()
539+ }
540+ // Only the first two pulses resync the viewport; later ones only request a
541+ // refresh frame so the surface ID is not reallocated repeatedly.
537542 require.Equal(t, []string{
538- "NotifyScreenInfoChanged",
539- "WasResized",
543+ "NotifyScreenInfoChanged", "WasResized", "Invalidate",
544+ "NotifyScreenInfoChanged", "WasResized", "Invalidate",
545+ "Invalidate",
546+ "Invalidate",
540547 "Invalidate",
541- "NotifyScreenInfoChanged",
542- "WasResized",
543548 "Invalidate",
544549 }, host.calls)
545550 }
@@ -569,9 +574,10 @@ func TestScheduleResizeRepaintPulse_SkipsStaleHost(t *testing.T) {
569574 wv.scheduleResizeRepaintPulse(context.Background(), "stale-host")
570575 wv.host = currentHost
571576
572- require.Len(t, scheduled, 2)
573- scheduled[0].Execute()
574- scheduled[1].Execute()
577+ require.Len(t, scheduled, 6)
578+ for _, task := range scheduled {
579+ task.Execute()
580+ }
575581
576582 require.Empty(t, capturedHost.calls)
577583 require.Empty(t, currentHost.calls)
@@ -624,9 +630,10 @@ func TestScheduleResizeRepaintPulse_SkipsWhenHostClearedBeforeExecution(t *testi
624630 wv.scheduleResizeRepaintPulse(context.Background(), "nil-host-before-execute")
625631 wv.host = nil
626632
627- require.Len(t, scheduled, 2)
628- scheduled[0].Execute()
629- scheduled[1].Execute()
633+ require.Len(t, scheduled, 6)
634+ for _, task := range scheduled {
635+ task.Execute()
636+ }
630637
631638 require.Empty(t, host.calls)
632639 }
@@ -525,21 +525,26 @@ func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) {
525 wv.scheduleResizeRepaintPulse(context.Background(), "first")525 wv.scheduleResizeRepaintPulse(context.Background(), "first")
526 wv.scheduleResizeRepaintPulse(context.Background(), "second")526 wv.scheduleResizeRepaintPulse(context.Background(), "second")
527 527
528- require.Equal(t, []int64{16, 48, 16, 48}, delays)528+ const pulses = 6
529- require.Len(t, scheduled, 4)529+ require.Equal(t, []int64{16, 48, 96, 160, 250, 400, 16, 48, 96, 160, 250, 400}, delays)
530+ require.Len(t, scheduled, 2*pulses)
530 531
531- scheduled[0].Execute()532+ for _, task := range scheduled[:pulses] {
532- scheduled[1].Execute()533+ task.Execute()
534+ }
533 require.Empty(t, host.calls)535 require.Empty(t, host.calls)
534 536
535- scheduled[2].Execute()537+ for _, task := range scheduled[pulses:] {
536- scheduled[3].Execute()538+ task.Execute()
539+ }
540+ // Only the first two pulses resync the viewport; later ones only request a
541+ // refresh frame so the surface ID is not reallocated repeatedly.
537 require.Equal(t, []string{542 require.Equal(t, []string{
538- "NotifyScreenInfoChanged",543+ "NotifyScreenInfoChanged", "WasResized", "Invalidate",
539- "WasResized",544+ "NotifyScreenInfoChanged", "WasResized", "Invalidate",
545+ "Invalidate",
546+ "Invalidate",
540 "Invalidate",547 "Invalidate",
541- "NotifyScreenInfoChanged",
542- "WasResized",
543 "Invalidate",548 "Invalidate",
544 }, host.calls)549 }, host.calls)
545 }550 }
@@ -569,9 +574,10 @@ func TestScheduleResizeRepaintPulse_SkipsStaleHost(t *testing.T) {
569 wv.scheduleResizeRepaintPulse(context.Background(), "stale-host")574 wv.scheduleResizeRepaintPulse(context.Background(), "stale-host")
570 wv.host = currentHost575 wv.host = currentHost
571 576
572- require.Len(t, scheduled, 2)577+ require.Len(t, scheduled, 6)
573- scheduled[0].Execute()578+ for _, task := range scheduled {
574- scheduled[1].Execute()579+ task.Execute()
580+ }
575 581
576 require.Empty(t, capturedHost.calls)582 require.Empty(t, capturedHost.calls)
577 require.Empty(t, currentHost.calls)583 require.Empty(t, currentHost.calls)
@@ -624,9 +630,10 @@ func TestScheduleResizeRepaintPulse_SkipsWhenHostClearedBeforeExecution(t *testi
624 wv.scheduleResizeRepaintPulse(context.Background(), "nil-host-before-execute")630 wv.scheduleResizeRepaintPulse(context.Background(), "nil-host-before-execute")
625 wv.host = nil631 wv.host = nil
626 632
627- require.Len(t, scheduled, 2)633+ require.Len(t, scheduled, 6)
628- scheduled[0].Execute()634+ for _, task := range scheduled {
629- scheduled[1].Execute()635+ task.Execute()
636+ }
630 637
631 require.Empty(t, host.calls)638 require.Empty(t, host.calls)
632 }639 }

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