detached 985dbf4

wip: attempt to stabilize filtering after splits

commit
985dbf4d711d80a230257879fb8bc9403e097018
author
bnema · · 2025-12-07 07:52 UTC
parent
1eb5fcd
signature
unsigned
3 files · +133 -37
M internal/app/browser/browser.go +3 -0
@@ -8,6 +8,7 @@ import (
88 "os"
99 "os/signal"
1010 "runtime"
11+ "sync"
1112 "syscall"
1213 "time"
1314
@@ -60,6 +61,8 @@ type BrowserApp struct {
6061 filterManager *filtering.FilterManager
6162 contentBlockingService *filtering.ContentBlockingService
6263 bypassRegistry *filtering.BypassRegistry
64+ pendingFilteringMu sync.Mutex
65+ pendingFiltering []*webkit.WebView
6366
6467 // Handlers
6568 schemeHandler *api.SchemeHandler
@@ -8,6 +8,7 @@ import (
8 "os"8 "os"
9 "os/signal"9 "os/signal"
10 "runtime"10 "runtime"
11+ "sync"
11 "syscall"12 "syscall"
12 "time"13 "time"
13 14
@@ -60,6 +61,8 @@ type BrowserApp struct {
60 filterManager *filtering.FilterManager61 filterManager *filtering.FilterManager
61 contentBlockingService *filtering.ContentBlockingService62 contentBlockingService *filtering.ContentBlockingService
62 bypassRegistry *filtering.BypassRegistry63 bypassRegistry *filtering.BypassRegistry
64+ pendingFilteringMu sync.Mutex
65+ pendingFiltering []*webkit.WebView
63 66
64 // Handlers67 // Handlers
65 schemeHandler *api.SchemeHandler68 schemeHandler *api.SchemeHandler
M internal/app/browser/webview.go +43 -3
@@ -259,6 +259,9 @@ func (app *BrowserApp) createWebView() error {
259259 return err
260260 }
261261
262+ // Ensure root WebView participates in content blocking even if the service initializes later
263+ app.RegisterWebViewForFiltering(view)
264+
262265 pane, err := app.createPaneForView(view)
263266 if err != nil {
264267 return err
@@ -404,6 +407,16 @@ func (app *BrowserApp) setupContentBlocking() error {
404407 }
405408 app.contentBlockingService = cbService
406409
410+ // Register any WebViews created before content blocking was ready
411+ app.pendingFilteringMu.Lock()
412+ for _, pending := range app.pendingFiltering {
413+ if pending != nil && !pending.IsDestroyed() {
414+ cbService.RegisterWebView(pending)
415+ }
416+ }
417+ app.pendingFiltering = nil
418+ app.pendingFilteringMu.Unlock()
419+
407420 // Set up callback to apply network filters when they become ready
408421 // This callback will be called from the async filter loading process
409422 filterManager.SetFiltersReadyCallback(func() {
@@ -453,14 +466,41 @@ func (app *BrowserApp) setupContentBlocking() error {
453466 // RegisterWebViewForFiltering registers a WebView with the content blocking service.
454467 // This should be called for every new WebView created in the application.
455468 func (app *BrowserApp) RegisterWebViewForFiltering(wv *webkit.WebView) {
456- if app.contentBlockingService != nil && wv != nil {
457- app.contentBlockingService.RegisterWebView(wv)
469+ if wv == nil || wv.IsDestroyed() {
470+ return
458471 }
472+
473+ if app.contentBlockingService == nil {
474+ app.pendingFilteringMu.Lock()
475+ app.pendingFiltering = append(app.pendingFiltering, wv)
476+ app.pendingFilteringMu.Unlock()
477+ return
478+ }
479+
480+ app.contentBlockingService.RegisterWebView(wv)
459481 }
460482
461483 // UnregisterWebViewFromFiltering removes a WebView from the content blocking service.
462484 func (app *BrowserApp) UnregisterWebViewFromFiltering(wv *webkit.WebView) {
463- if app.contentBlockingService != nil && wv != nil {
485+ if wv == nil {
486+ return
487+ }
488+
489+ if app.contentBlockingService == nil {
490+ // Drop from pending list to avoid registering destroyed WebViews later
491+ app.pendingFilteringMu.Lock()
492+ filtered := app.pendingFiltering[:0]
493+ for _, pending := range app.pendingFiltering {
494+ if pending != nil && pending != wv {
495+ filtered = append(filtered, pending)
496+ }
497+ }
498+ app.pendingFiltering = filtered
499+ app.pendingFilteringMu.Unlock()
500+ return
501+ }
502+
503+ if !wv.IsDestroyed() {
464504 app.contentBlockingService.UnregisterWebView(wv)
465505 }
466506 }
@@ -259,6 +259,9 @@ func (app *BrowserApp) createWebView() error {
259 return err259 return err
260 }260 }
261 261
262+ // Ensure root WebView participates in content blocking even if the service initializes later
263+ app.RegisterWebViewForFiltering(view)
264+
262 pane, err := app.createPaneForView(view)265 pane, err := app.createPaneForView(view)
263 if err != nil {266 if err != nil {
264 return err267 return err
@@ -404,6 +407,16 @@ func (app *BrowserApp) setupContentBlocking() error {
404 }407 }
405 app.contentBlockingService = cbService408 app.contentBlockingService = cbService
406 409
410+ // Register any WebViews created before content blocking was ready
411+ app.pendingFilteringMu.Lock()
412+ for _, pending := range app.pendingFiltering {
413+ if pending != nil && !pending.IsDestroyed() {
414+ cbService.RegisterWebView(pending)
415+ }
416+ }
417+ app.pendingFiltering = nil
418+ app.pendingFilteringMu.Unlock()
419+
407 // Set up callback to apply network filters when they become ready420 // Set up callback to apply network filters when they become ready
408 // This callback will be called from the async filter loading process421 // This callback will be called from the async filter loading process
409 filterManager.SetFiltersReadyCallback(func() {422 filterManager.SetFiltersReadyCallback(func() {
@@ -453,14 +466,41 @@ func (app *BrowserApp) setupContentBlocking() error {
453 // RegisterWebViewForFiltering registers a WebView with the content blocking service.466 // RegisterWebViewForFiltering registers a WebView with the content blocking service.
454 // This should be called for every new WebView created in the application.467 // This should be called for every new WebView created in the application.
455 func (app *BrowserApp) RegisterWebViewForFiltering(wv *webkit.WebView) {468 func (app *BrowserApp) RegisterWebViewForFiltering(wv *webkit.WebView) {
456- if app.contentBlockingService != nil && wv != nil {469+ if wv == nil || wv.IsDestroyed() {
457- app.contentBlockingService.RegisterWebView(wv)470+ return
458 }471 }
472+
473+ if app.contentBlockingService == nil {
474+ app.pendingFilteringMu.Lock()
475+ app.pendingFiltering = append(app.pendingFiltering, wv)
476+ app.pendingFilteringMu.Unlock()
477+ return
478+ }
479+
480+ app.contentBlockingService.RegisterWebView(wv)
459 }481 }
460 482
461 // UnregisterWebViewFromFiltering removes a WebView from the content blocking service.483 // UnregisterWebViewFromFiltering removes a WebView from the content blocking service.
462 func (app *BrowserApp) UnregisterWebViewFromFiltering(wv *webkit.WebView) {484 func (app *BrowserApp) UnregisterWebViewFromFiltering(wv *webkit.WebView) {
463- if app.contentBlockingService != nil && wv != nil {485+ if wv == nil {
486+ return
487+ }
488+
489+ if app.contentBlockingService == nil {
490+ // Drop from pending list to avoid registering destroyed WebViews later
491+ app.pendingFilteringMu.Lock()
492+ filtered := app.pendingFiltering[:0]
493+ for _, pending := range app.pendingFiltering {
494+ if pending != nil && pending != wv {
495+ filtered = append(filtered, pending)
496+ }
497+ }
498+ app.pendingFiltering = filtered
499+ app.pendingFilteringMu.Unlock()
500+ return
501+ }
502+
503+ if !wv.IsDestroyed() {
464 app.contentBlockingService.UnregisterWebView(wv)504 app.contentBlockingService.UnregisterWebView(wv)
465 }505 }
466 }506 }
M internal/filtering/service.go +87 -34
@@ -7,12 +7,58 @@ import (
77 "sync"
88
99 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
10+ glib "github.com/diamondburned/gotk4/pkg/glib/v2"
1011
1112 "github.com/bnema/dumber/internal/filtering/cosmetic"
1213 "github.com/bnema/dumber/internal/logging"
1314 pkgwebkit "github.com/bnema/dumber/pkg/webkit"
1415 )
1516
17+// runWhenWidgetReady waits until the WebView exposes a valid UserContentManager before invoking fn on the GTK main thread.
18+// This avoids racing freshly split panes whose underlying widgets aren't fully initialized yet.
19+func (s *ContentBlockingService) runWhenWidgetReady(wv *pkgwebkit.WebView, fn func()) {
20+ if wv == nil || wv.IsDestroyed() || fn == nil {
21+ return
22+ }
23+
24+ const maxAttempts = 50 // up to ~1s total
25+ const retryDelayMS = uint(20) // 20ms between checks
26+
27+ var tryApply func(attempt int) bool
28+ tryApply = func(attempt int) bool {
29+ if wv.IsDestroyed() {
30+ return false
31+ }
32+
33+ gtkView := wv.GetWebView()
34+ if gtkView == nil {
35+ return attempt < maxAttempts
36+ }
37+
38+ if ucm := gtkView.UserContentManager(); ucm == nil {
39+ return attempt < maxAttempts
40+ }
41+
42+ pkgwebkit.RunOnMainThread(func() {
43+ if wv.IsDestroyed() {
44+ return
45+ }
46+ fn()
47+ })
48+ return false
49+ }
50+
51+ attempts := 0
52+ if !tryApply(attempts) {
53+ return
54+ }
55+
56+ glib.TimeoutAdd(retryDelayMS, func() bool {
57+ attempts++
58+ return tryApply(attempts)
59+ })
60+}
61+
1662 // ContentBlockingService manages content blocking for all WebViews.
1763 // It ensures network filters and cosmetic rules are applied consistently
1864 // across all WebViews in the application.
@@ -79,10 +125,7 @@ func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) {
79125 // This hides ads visually within milliseconds while network filters compile in background
80126 for _, wv := range webViews {
81127 webView := wv // capture for closure
82- pkgwebkit.RunOnMainThread(func() {
83- if webView.IsDestroyed() {
84- return
85- }
128+ s.runWhenWidgetReady(webView, func() {
86129 s.injectCosmeticBaseScript(webView)
87130 if uri := webView.GetCurrentURL(); uri != "" {
88131 s.injectCosmeticRules(webView, uri)
@@ -117,10 +160,7 @@ func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) {
117160 // Apply network filters to all registered WebViews
118161 for _, wv := range currentWebViews {
119162 webView := wv // capture for closure
120- pkgwebkit.RunOnMainThread(func() {
121- if webView.IsDestroyed() {
122- return
123- }
163+ s.runWhenWidgetReady(webView, func() {
124164 s.applyFiltersToWebView(webView)
125165 })
126166 }
@@ -154,13 +194,10 @@ func (s *ContentBlockingService) RegisterWebView(wv *pkgwebkit.WebView) {
154194 s.injectCosmeticBaseScript(wv)
155195 }
156196
157- // Apply network filters if already compiled (instant)
197+ // If network filters are already compiled, apply them immediately (non-blocking)
158198 if s.contentBlockingMgr.IsFilterCompiled() {
159199 webView := wv // capture for closure
160- pkgwebkit.RunOnMainThread(func() {
161- if webView.IsDestroyed() {
162- return
163- }
200+ s.runWhenWidgetReady(webView, func() {
164201 s.applyFiltersToWebView(webView)
165202 })
166203 }
@@ -231,21 +268,36 @@ func (s *ContentBlockingService) applyFiltersToWebView(wv *pkgwebkit.WebView) {
231268 return
232269 }
233270
234- s.mu.RLock()
235- networkJSON := s.networkFilterJSON
271+ // Avoid triggering compilation on the GTK thread; only apply once compiled.
272+ if !s.contentBlockingMgr.IsFilterCompiled() {
273+ return
274+ }
275+
276+ s.mu.Lock()
236277 state := s.webViews[wv]
237- s.mu.RUnlock()
278+ if state.networkApplied {
279+ s.mu.Unlock()
280+ return
281+ }
282+ // Mark as applying to avoid double AddFilter races
283+ state.networkApplied = true
284+ s.webViews[wv] = state
285+ networkJSON := s.networkFilterJSON
286+ s.mu.Unlock()
238287
239- // Apply network filters if not already applied
240- if !state.networkApplied && len(networkJSON) > 0 {
241- webView := wv // capture for closure
242- pkgwebkit.RunOnMainThread(func() {
243- if webView.IsDestroyed() {
244- return
245- }
246- s.applyNetworkFilters(webView, networkJSON)
247- })
288+ if len(networkJSON) == 0 {
289+ // Nothing to apply; reset flag
290+ s.mu.Lock()
291+ state.networkApplied = false
292+ s.webViews[wv] = state
293+ s.mu.Unlock()
294+ return
248295 }
296+
297+ webView := wv // capture for closure
298+ s.runWhenWidgetReady(webView, func() {
299+ s.applyNetworkFilters(webView, networkJSON)
300+ })
249301 }
250302
251303 // applyNetworkFilters applies network blocking rules to a WebView.
@@ -261,6 +313,13 @@ func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filt
261313
262314 ucm := gtkView.UserContentManager()
263315 if ucm == nil {
316+ // Reset state so a future ready WebView can retry
317+ s.mu.Lock()
318+ if state, exists := s.webViews[wv]; exists {
319+ state.networkApplied = false
320+ s.webViews[wv] = state
321+ }
322+ s.mu.Unlock()
264323 return
265324 }
266325
@@ -284,14 +343,6 @@ func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filt
284343 return
285344 }
286345
287- // Mark as applied
288- s.mu.Lock()
289- if state, exists := s.webViews[wv]; exists {
290- state.networkApplied = true
291- s.webViews[wv] = state
292- }
293- s.mu.Unlock()
294-
295346 logging.Debug(fmt.Sprintf("[filtering] Applied network filters to WebView %d", wv.ID()))
296347 }
297348
@@ -307,7 +358,9 @@ func (s *ContentBlockingService) setupCosmeticHooks(wv *pkgwebkit.WebView) {
307358 if webView.IsDestroyed() {
308359 return
309360 }
310- s.injectCosmeticRules(webView, uri)
361+ s.runWhenWidgetReady(webView, func() {
362+ s.injectCosmeticRules(webView, uri)
363+ })
311364 })
312365 }
313366
@@ -7,12 +7,58 @@ import (
7 "sync"7 "sync"
8 8
9 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"9 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
10+ glib "github.com/diamondburned/gotk4/pkg/glib/v2"
10 11
11 "github.com/bnema/dumber/internal/filtering/cosmetic"12 "github.com/bnema/dumber/internal/filtering/cosmetic"
12 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
13 pkgwebkit "github.com/bnema/dumber/pkg/webkit"14 pkgwebkit "github.com/bnema/dumber/pkg/webkit"
14 )15 )
15 16
17+// runWhenWidgetReady waits until the WebView exposes a valid UserContentManager before invoking fn on the GTK main thread.
18+// This avoids racing freshly split panes whose underlying widgets aren't fully initialized yet.
19+func (s *ContentBlockingService) runWhenWidgetReady(wv *pkgwebkit.WebView, fn func()) {
20+ if wv == nil || wv.IsDestroyed() || fn == nil {
21+ return
22+ }
23+
24+ const maxAttempts = 50 // up to ~1s total
25+ const retryDelayMS = uint(20) // 20ms between checks
26+
27+ var tryApply func(attempt int) bool
28+ tryApply = func(attempt int) bool {
29+ if wv.IsDestroyed() {
30+ return false
31+ }
32+
33+ gtkView := wv.GetWebView()
34+ if gtkView == nil {
35+ return attempt < maxAttempts
36+ }
37+
38+ if ucm := gtkView.UserContentManager(); ucm == nil {
39+ return attempt < maxAttempts
40+ }
41+
42+ pkgwebkit.RunOnMainThread(func() {
43+ if wv.IsDestroyed() {
44+ return
45+ }
46+ fn()
47+ })
48+ return false
49+ }
50+
51+ attempts := 0
52+ if !tryApply(attempts) {
53+ return
54+ }
55+
56+ glib.TimeoutAdd(retryDelayMS, func() bool {
57+ attempts++
58+ return tryApply(attempts)
59+ })
60+}
61+
16 // ContentBlockingService manages content blocking for all WebViews.62 // ContentBlockingService manages content blocking for all WebViews.
17 // It ensures network filters and cosmetic rules are applied consistently63 // It ensures network filters and cosmetic rules are applied consistently
18 // across all WebViews in the application.64 // across all WebViews in the application.
@@ -79,10 +125,7 @@ func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) {
79 // This hides ads visually within milliseconds while network filters compile in background125 // This hides ads visually within milliseconds while network filters compile in background
80 for _, wv := range webViews {126 for _, wv := range webViews {
81 webView := wv // capture for closure127 webView := wv // capture for closure
82- pkgwebkit.RunOnMainThread(func() {128+ s.runWhenWidgetReady(webView, func() {
83- if webView.IsDestroyed() {
84- return
85- }
86 s.injectCosmeticBaseScript(webView)129 s.injectCosmeticBaseScript(webView)
87 if uri := webView.GetCurrentURL(); uri != "" {130 if uri := webView.GetCurrentURL(); uri != "" {
88 s.injectCosmeticRules(webView, uri)131 s.injectCosmeticRules(webView, uri)
@@ -117,10 +160,7 @@ func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) {
117 // Apply network filters to all registered WebViews160 // Apply network filters to all registered WebViews
118 for _, wv := range currentWebViews {161 for _, wv := range currentWebViews {
119 webView := wv // capture for closure162 webView := wv // capture for closure
120- pkgwebkit.RunOnMainThread(func() {163+ s.runWhenWidgetReady(webView, func() {
121- if webView.IsDestroyed() {
122- return
123- }
124 s.applyFiltersToWebView(webView)164 s.applyFiltersToWebView(webView)
125 })165 })
126 }166 }
@@ -154,13 +194,10 @@ func (s *ContentBlockingService) RegisterWebView(wv *pkgwebkit.WebView) {
154 s.injectCosmeticBaseScript(wv)194 s.injectCosmeticBaseScript(wv)
155 }195 }
156 196
157- // Apply network filters if already compiled (instant)197+ // If network filters are already compiled, apply them immediately (non-blocking)
158 if s.contentBlockingMgr.IsFilterCompiled() {198 if s.contentBlockingMgr.IsFilterCompiled() {
159 webView := wv // capture for closure199 webView := wv // capture for closure
160- pkgwebkit.RunOnMainThread(func() {200+ s.runWhenWidgetReady(webView, func() {
161- if webView.IsDestroyed() {
162- return
163- }
164 s.applyFiltersToWebView(webView)201 s.applyFiltersToWebView(webView)
165 })202 })
166 }203 }
@@ -231,21 +268,36 @@ func (s *ContentBlockingService) applyFiltersToWebView(wv *pkgwebkit.WebView) {
231 return268 return
232 }269 }
233 270
234- s.mu.RLock()271+ // Avoid triggering compilation on the GTK thread; only apply once compiled.
235- networkJSON := s.networkFilterJSON272+ if !s.contentBlockingMgr.IsFilterCompiled() {
273+ return
274+ }
275+
276+ s.mu.Lock()
236 state := s.webViews[wv]277 state := s.webViews[wv]
237- s.mu.RUnlock()278+ if state.networkApplied {
279+ s.mu.Unlock()
280+ return
281+ }
282+ // Mark as applying to avoid double AddFilter races
283+ state.networkApplied = true
284+ s.webViews[wv] = state
285+ networkJSON := s.networkFilterJSON
286+ s.mu.Unlock()
238 287
239- // Apply network filters if not already applied288+ if len(networkJSON) == 0 {
240- if !state.networkApplied && len(networkJSON) > 0 {289+ // Nothing to apply; reset flag
241- webView := wv // capture for closure290+ s.mu.Lock()
242- pkgwebkit.RunOnMainThread(func() {291+ state.networkApplied = false
243- if webView.IsDestroyed() {292+ s.webViews[wv] = state
244- return293+ s.mu.Unlock()
245- }294+ return
246- s.applyNetworkFilters(webView, networkJSON)
247- })
248 }295 }
296+
297+ webView := wv // capture for closure
298+ s.runWhenWidgetReady(webView, func() {
299+ s.applyNetworkFilters(webView, networkJSON)
300+ })
249 }301 }
250 302
251 // applyNetworkFilters applies network blocking rules to a WebView.303 // applyNetworkFilters applies network blocking rules to a WebView.
@@ -261,6 +313,13 @@ func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filt
261 313
262 ucm := gtkView.UserContentManager()314 ucm := gtkView.UserContentManager()
263 if ucm == nil {315 if ucm == nil {
316+ // Reset state so a future ready WebView can retry
317+ s.mu.Lock()
318+ if state, exists := s.webViews[wv]; exists {
319+ state.networkApplied = false
320+ s.webViews[wv] = state
321+ }
322+ s.mu.Unlock()
264 return323 return
265 }324 }
266 325
@@ -284,14 +343,6 @@ func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filt
284 return343 return
285 }344 }
286 345
287- // Mark as applied
288- s.mu.Lock()
289- if state, exists := s.webViews[wv]; exists {
290- state.networkApplied = true
291- s.webViews[wv] = state
292- }
293- s.mu.Unlock()
294-
295 logging.Debug(fmt.Sprintf("[filtering] Applied network filters to WebView %d", wv.ID()))346 logging.Debug(fmt.Sprintf("[filtering] Applied network filters to WebView %d", wv.ID()))
296 }347 }
297 348
@@ -307,7 +358,9 @@ func (s *ContentBlockingService) setupCosmeticHooks(wv *pkgwebkit.WebView) {
307 if webView.IsDestroyed() {358 if webView.IsDestroyed() {
308 return359 return
309 }360 }
310- s.injectCosmeticRules(webView, uri)361+ s.runWhenWidgetReady(webView, func() {
362+ s.injectCosmeticRules(webView, uri)
363+ })
311 })364 })
312 }365 }
313 366

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