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 Unverified
B5690EEEBB952194The key is unknown
4 files · +76 -37
M go.mod +1 -1
| @@ -7,7 +7,7 @@ require ( | |||
| 7 | 7 | github.com/andybalholm/brotli v1.2.4 | |
| 8 | 8 | github.com/bnema/purego v0.12.0-bnema.1 | |
| 9 | 9 | 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 | |
| 11 | 11 | github.com/bnema/purego-pipewire v0.1.6 | |
| 12 | 12 | github.com/bnema/purego-sqlite v0.1.5 | |
| 13 | 13 | github.com/bnema/purego-webp v0.2.1 | |
| @@ -7,7 +7,7 @@ require ( | |||||
| 7 | github.com/andybalholm/brotli v1.2.4 | 7 | github.com/andybalholm/brotli v1.2.4 | ||
| 8 | github.com/bnema/purego v0.12.0-bnema.1 | 8 | github.com/bnema/purego v0.12.0-bnema.1 | ||
| 9 | github.com/bnema/purego-cef v0.14.3 | 9 | 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 |
| 11 | github.com/bnema/purego-pipewire v0.1.6 | 11 | github.com/bnema/purego-pipewire v0.1.6 | ||
| 12 | github.com/bnema/purego-sqlite v0.1.5 | 12 | github.com/bnema/purego-sqlite v0.1.5 | ||
| 13 | github.com/bnema/purego-webp v0.2.1 | 13 | 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 | |||
| 180 | 180 | 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 | 194 | func (wv *WebView) scheduleResizeRepaintPulse(ctx context.Context, reason string) { | |
| 184 | 195 | if wv == nil || wv.destroyed.Load() { | |
| 185 | 196 | return | |
| @@ -191,29 +202,48 @@ func (wv *WebView) scheduleResizeRepaintPulse(ctx context.Context, reason string | |||
| 191 | 202 | return | |
| 192 | 203 | } | |
| 193 | 204 | 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 { | |
| 205 | 232 | 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) | |
| 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 | 249 | 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 true | 180 | 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 | return | 196 | return | ||
| @@ -191,29 +202,48 @@ func (wv *WebView) scheduleResizeRepaintPulse(ctx context.Context, reason string | |||||
| 191 | return | 202 | 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 | - | return | 208 | + | for _, delayMs := range resizeRepaintOnlyDelaysMs { |
| 198 | - | } | 209 | + | wv.postResizePulse(ctx, host, seq, delayMs, reason, false) |
| 199 | - | wv.mu.RLock() | 210 | + | } |
| 200 | - | currentHost := wv.host | 211 | + | } |
| 201 | - | wv.mu.RUnlock() | 212 | + | |
| 202 | - | if currentHost != host { | 213 | + | func (wv *WebView) postResizePulse( |
| 203 | - | return | 214 | + | 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) { | |||
| 525 | 525 | wv.scheduleResizeRepaintPulse(context.Background(), "first") | |
| 526 | 526 | wv.scheduleResizeRepaintPulse(context.Background(), "second") | |
| 527 | 527 | ||
| 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) | |
| 530 | 531 | ||
| 531 | - | scheduled[0].Execute() | |
| 532 | - | scheduled[1].Execute() | |
| 532 | + | for _, task := range scheduled[:pulses] { | |
| 533 | + | task.Execute() | |
| 534 | + | } | |
| 533 | 535 | require.Empty(t, host.calls) | |
| 534 | 536 | ||
| 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. | |
| 537 | 542 | require.Equal(t, []string{ | |
| 538 | - | "NotifyScreenInfoChanged", | |
| 539 | - | "WasResized", | |
| 543 | + | "NotifyScreenInfoChanged", "WasResized", "Invalidate", | |
| 544 | + | "NotifyScreenInfoChanged", "WasResized", "Invalidate", | |
| 545 | + | "Invalidate", | |
| 546 | + | "Invalidate", | |
| 540 | 547 | "Invalidate", | |
| 541 | - | "NotifyScreenInfoChanged", | |
| 542 | - | "WasResized", | |
| 543 | 548 | "Invalidate", | |
| 544 | 549 | }, host.calls) | |
| 545 | 550 | } | |
| @@ -569,9 +574,10 @@ func TestScheduleResizeRepaintPulse_SkipsStaleHost(t *testing.T) { | |||
| 569 | 574 | wv.scheduleResizeRepaintPulse(context.Background(), "stale-host") | |
| 570 | 575 | wv.host = currentHost | |
| 571 | 576 | ||
| 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 | + | } | |
| 575 | 581 | ||
| 576 | 582 | require.Empty(t, capturedHost.calls) | |
| 577 | 583 | require.Empty(t, currentHost.calls) | |
| @@ -624,9 +630,10 @@ func TestScheduleResizeRepaintPulse_SkipsWhenHostClearedBeforeExecution(t *testi | |||
| 624 | 630 | wv.scheduleResizeRepaintPulse(context.Background(), "nil-host-before-execute") | |
| 625 | 631 | wv.host = nil | |
| 626 | 632 | ||
| 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 | + | } | |
| 630 | 637 | ||
| 631 | 638 | require.Empty(t, host.calls) | |
| 632 | 639 | } | |
| @@ -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 = currentHost | 575 | 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 = nil | 631 | 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 | } | ||