detached c1cf8b7

fix: address copilot and coderabbit review follow-ups

commit
c1cf8b729a85f0a9a451842ffc50e3b8c3046b40
author
brice · · 2026-02-07 09:05 UTC
parent
d34c856
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +93 -43
M CHANGELOG.md +3 -0
@@ -20,6 +20,9 @@ All notable changes to this project will be documented in this file.
2020 ### Fixed
2121 - **Inline ghost suggestion stability**: Reworked ghostwriter flow to keep suggestions selected (not auto-committed), with stronger token guards and stale-callback protection.
2222 - **GTK thread-safety in omnibox**: Removed unsafe off-main-thread widget reads in async flows.
23+- **Review follow-ups (Copilot/CodeRabbit)**: Fixed custom-scheme GET handling for empty WebKit methods, synchronized pool close/send paths, aligned WebKit signal ID storage types, and marshalled OAuth parent refresh reloads back to the GTK main loop.
24+- **Persistence/update safety edges**: Enforced positive visit-count deltas in SQLite repos, removed redundant filter cache directory creation, canceled pending snapshot timer on `SetReady`, and made updater retry-loop exhaustion explicit.
25+- **Logging/dedup consistency**: Unified abrupt-session marker stat warnings under structured session logging and reduced favicon warning dedup growth by using a global cache-dir warning key plus callback coverage tests.
2326 - **Omnibox input/ghost correctness**: Enforced minimum typed input before ghost suggestions, trimmed leading spaces before URL interpretation, deferred omnibox auto-open until layout is ready, and fixed width/layout edge-cases.
2427 - **History/autocomplete edge-cases**: Normalized FTS queries containing slashes and capped `about:blank` visit-count inflation.
2528 - **Navigation/history robustness**: Added pane history cleanup hooks, batched/capped visit increment fallback, and idempotent `NavigateUseCase.Close()`.
@@ -20,6 +20,9 @@ All notable changes to this project will be documented in this file.
20 ### Fixed20 ### Fixed
21 - **Inline ghost suggestion stability**: Reworked ghostwriter flow to keep suggestions selected (not auto-committed), with stronger token guards and stale-callback protection.21 - **Inline ghost suggestion stability**: Reworked ghostwriter flow to keep suggestions selected (not auto-committed), with stronger token guards and stale-callback protection.
22 - **GTK thread-safety in omnibox**: Removed unsafe off-main-thread widget reads in async flows.22 - **GTK thread-safety in omnibox**: Removed unsafe off-main-thread widget reads in async flows.
23+- **Review follow-ups (Copilot/CodeRabbit)**: Fixed custom-scheme GET handling for empty WebKit methods, synchronized pool close/send paths, aligned WebKit signal ID storage types, and marshalled OAuth parent refresh reloads back to the GTK main loop.
24+- **Persistence/update safety edges**: Enforced positive visit-count deltas in SQLite repos, removed redundant filter cache directory creation, canceled pending snapshot timer on `SetReady`, and made updater retry-loop exhaustion explicit.
25+- **Logging/dedup consistency**: Unified abrupt-session marker stat warnings under structured session logging and reduced favicon warning dedup growth by using a global cache-dir warning key plus callback coverage tests.
23 - **Omnibox input/ghost correctness**: Enforced minimum typed input before ghost suggestions, trimmed leading spaces before URL interpretation, deferred omnibox auto-open until layout is ready, and fixed width/layout edge-cases.26 - **Omnibox input/ghost correctness**: Enforced minimum typed input before ghost suggestions, trimmed leading spaces before URL interpretation, deferred omnibox auto-open until layout is ready, and fixed width/layout edge-cases.
24 - **History/autocomplete edge-cases**: Normalized FTS queries containing slashes and capped `about:blank` visit-count inflation.27 - **History/autocomplete edge-cases**: Normalized FTS queries containing slashes and capped `about:blank` visit-count inflation.
25 - **Navigation/history robustness**: Added pane history cleanup hooks, batched/capped visit increment fallback, and idempotent `NavigateUseCase.Close()`.28 - **Navigation/history robustness**: Added pane history cleanup hooks, batched/capped visit increment fallback, and idempotent `NavigateUseCase.Close()`.
M internal/application/port/webkit_context.go +2 -0
@@ -52,9 +52,11 @@ type WebKitContextOptions struct {
5252 CacheDir string
5353
5454 // CookiePolicy controls cookie acceptance behavior.
55+ // Empty value means runtime defaults are used.
5556 CookiePolicy WebKitCookiePolicy
5657
5758 // ITPEnabled controls Intelligent Tracking Prevention for NetworkSession.
59+ // False means ITP remains disabled unless enabled explicitly.
5860 ITPEnabled bool
5961
6062 // WebProcessMemory configures memory pressure for web processes.
@@ -52,9 +52,11 @@ type WebKitContextOptions struct {
52 CacheDir string52 CacheDir string
53 53
54 // CookiePolicy controls cookie acceptance behavior.54 // CookiePolicy controls cookie acceptance behavior.
55+ // Empty value means runtime defaults are used.
55 CookiePolicy WebKitCookiePolicy56 CookiePolicy WebKitCookiePolicy
56 57
57 // ITPEnabled controls Intelligent Tracking Prevention for NetworkSession.58 // ITPEnabled controls Intelligent Tracking Prevention for NetworkSession.
59+ // False means ITP remains disabled unless enabled explicitly.
58 ITPEnabled bool60 ITPEnabled bool
59 61
60 // WebProcessMemory configures memory pressure for web processes.62 // WebProcessMemory configures memory pressure for web processes.
M internal/bootstrap/session.go +16 -5
@@ -4,7 +4,6 @@ import (
44 "context"
55 "errors"
66 "fmt"
7- stdlog "log"
87 "os"
98 "path/filepath"
109 "strings"
@@ -99,7 +98,7 @@ func StartBrowserSession(
9998 persistFn := func(persistCtx context.Context) error {
10099 persistMu.Do(func() {
101100 if lockDir != "" {
102- abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC())
101+ abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC(), &logger)
103102 if abruptErr != nil {
104103 logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers")
105104 } else {
@@ -333,7 +332,7 @@ func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error {
333332 return nil
334333 }
335334
336-func markAbruptExits(lockDir string, detectedAt time.Time) ([]string, error) {
335+func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logger) ([]string, error) {
337336 if lockDir == "" {
338337 return nil, nil
339338 }
@@ -357,14 +356,26 @@ func markAbruptExits(lockDir string, detectedAt time.Time) ([]string, error) {
357356 if _, err := os.Stat(shutdownPath); err == nil {
358357 continue
359358 } else if !os.IsNotExist(err) {
360- stdlog.Printf("markAbruptExits: stat failed session_id=%s path=%s err=%v", sessionID, shutdownPath, err)
359+ if logger != nil {
360+ logger.Warn().
361+ Err(err).
362+ Str("session_id", sessionID).
363+ Str("path", shutdownPath).
364+ Msg("markAbruptExits: stat failed")
365+ }
361366 continue
362367 }
363368 abruptPath := abruptMarkerPath(lockDir, sessionID)
364369 if _, err := os.Stat(abruptPath); err == nil {
365370 continue
366371 } else if !os.IsNotExist(err) {
367- stdlog.Printf("markAbruptExits: stat failed session_id=%s path=%s err=%v", sessionID, abruptPath, err)
372+ if logger != nil {
373+ logger.Warn().
374+ Err(err).
375+ Str("session_id", sessionID).
376+ Str("path", abruptPath).
377+ Msg("markAbruptExits: stat failed")
378+ }
368379 continue
369380 }
370381
@@ -4,7 +4,6 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7- stdlog "log"
8 "os"7 "os"
9 "path/filepath"8 "path/filepath"
10 "strings"9 "strings"
@@ -99,7 +98,7 @@ func StartBrowserSession(
99 persistFn := func(persistCtx context.Context) error {98 persistFn := func(persistCtx context.Context) error {
100 persistMu.Do(func() {99 persistMu.Do(func() {
101 if lockDir != "" {100 if lockDir != "" {
102- abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC())101+ abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC(), &logger)
103 if abruptErr != nil {102 if abruptErr != nil {
104 logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers")103 logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers")
105 } else {104 } else {
@@ -333,7 +332,7 @@ func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error {
333 return nil332 return nil
334 }333 }
335 334
336-func markAbruptExits(lockDir string, detectedAt time.Time) ([]string, error) {335+func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logger) ([]string, error) {
337 if lockDir == "" {336 if lockDir == "" {
338 return nil, nil337 return nil, nil
339 }338 }
@@ -357,14 +356,26 @@ func markAbruptExits(lockDir string, detectedAt time.Time) ([]string, error) {
357 if _, err := os.Stat(shutdownPath); err == nil {356 if _, err := os.Stat(shutdownPath); err == nil {
358 continue357 continue
359 } else if !os.IsNotExist(err) {358 } else if !os.IsNotExist(err) {
360- stdlog.Printf("markAbruptExits: stat failed session_id=%s path=%s err=%v", sessionID, shutdownPath, err)359+ if logger != nil {
360+ logger.Warn().
361+ Err(err).
362+ Str("session_id", sessionID).
363+ Str("path", shutdownPath).
364+ Msg("markAbruptExits: stat failed")
365+ }
361 continue366 continue
362 }367 }
363 abruptPath := abruptMarkerPath(lockDir, sessionID)368 abruptPath := abruptMarkerPath(lockDir, sessionID)
364 if _, err := os.Stat(abruptPath); err == nil {369 if _, err := os.Stat(abruptPath); err == nil {
365 continue370 continue
366 } else if !os.IsNotExist(err) {371 } else if !os.IsNotExist(err) {
367- stdlog.Printf("markAbruptExits: stat failed session_id=%s path=%s err=%v", sessionID, abruptPath, err)372+ if logger != nil {
373+ logger.Warn().
374+ Err(err).
375+ Str("session_id", sessionID).
376+ Str("path", abruptPath).
377+ Msg("markAbruptExits: stat failed")
378+ }
368 continue379 continue
369 }380 }
370 381
M internal/bootstrap/session_markers_test.go +3 -3
@@ -39,7 +39,7 @@ func TestSessionMarkers_MarkAbruptExits(t *testing.T) {
3939 require.NoError(t, writeStartupMarker(lockDir, "clean-b", detectedAt.Add(-4*time.Minute)))
4040 require.NoError(t, writeShutdownMarker(lockDir, "clean-b", detectedAt.Add(-1*time.Minute)))
4141
42- abruptSessions, err := markAbruptExits(lockDir, detectedAt)
42+ abruptSessions, err := markAbruptExits(lockDir, detectedAt, nil)
4343 require.NoError(t, err)
4444 assert.Equal(t, []string{"abrupt-a"}, abruptSessions)
4545
@@ -55,11 +55,11 @@ func TestSessionMarkers_MarkAbruptExitsIdempotent(t *testing.T) {
5555
5656 require.NoError(t, writeStartupMarker(lockDir, "abrupt-c", detectedAt.Add(-10*time.Minute)))
5757
58- first, err := markAbruptExits(lockDir, detectedAt)
58+ first, err := markAbruptExits(lockDir, detectedAt, nil)
5959 require.NoError(t, err)
6060 assert.Equal(t, []string{"abrupt-c"}, first)
6161
62- second, err := markAbruptExits(lockDir, detectedAt.Add(1*time.Minute))
62+ second, err := markAbruptExits(lockDir, detectedAt.Add(1*time.Minute), nil)
6363 require.NoError(t, err)
6464 assert.Empty(t, second)
6565 }
@@ -39,7 +39,7 @@ func TestSessionMarkers_MarkAbruptExits(t *testing.T) {
39 require.NoError(t, writeStartupMarker(lockDir, "clean-b", detectedAt.Add(-4*time.Minute)))39 require.NoError(t, writeStartupMarker(lockDir, "clean-b", detectedAt.Add(-4*time.Minute)))
40 require.NoError(t, writeShutdownMarker(lockDir, "clean-b", detectedAt.Add(-1*time.Minute)))40 require.NoError(t, writeShutdownMarker(lockDir, "clean-b", detectedAt.Add(-1*time.Minute)))
41 41
42- abruptSessions, err := markAbruptExits(lockDir, detectedAt)42+ abruptSessions, err := markAbruptExits(lockDir, detectedAt, nil)
43 require.NoError(t, err)43 require.NoError(t, err)
44 assert.Equal(t, []string{"abrupt-a"}, abruptSessions)44 assert.Equal(t, []string{"abrupt-a"}, abruptSessions)
45 45
@@ -55,11 +55,11 @@ func TestSessionMarkers_MarkAbruptExitsIdempotent(t *testing.T) {
55 55
56 require.NoError(t, writeStartupMarker(lockDir, "abrupt-c", detectedAt.Add(-10*time.Minute)))56 require.NoError(t, writeStartupMarker(lockDir, "abrupt-c", detectedAt.Add(-10*time.Minute)))
57 57
58- first, err := markAbruptExits(lockDir, detectedAt)58+ first, err := markAbruptExits(lockDir, detectedAt, nil)
59 require.NoError(t, err)59 require.NoError(t, err)
60 assert.Equal(t, []string{"abrupt-c"}, first)60 assert.Equal(t, []string{"abrupt-c"}, first)
61 61
62- second, err := markAbruptExits(lockDir, detectedAt.Add(1*time.Minute))62+ second, err := markAbruptExits(lockDir, detectedAt.Add(1*time.Minute), nil)
63 require.NoError(t, err)63 require.NoError(t, err)
64 assert.Empty(t, second)64 assert.Empty(t, second)
65 }65 }
M internal/infrastructure/filtering/downloader.go +0 -4
@@ -164,10 +164,6 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string,
164164 url := fmt.Sprintf("%s/%s", d.baseURL, filename)
165165 localPath := filepath.Join(d.cacheDir, filename)
166166
167- if err := os.MkdirAll(d.cacheDir, cacheDirPerm); err != nil {
168- return "", 0, fmt.Errorf("failed to create cache dir: %w", err)
169- }
170-
171167 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
172168 if err != nil {
173169 return "", 0, fmt.Errorf("failed to create request: %w", err)
@@ -164,10 +164,6 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string,
164 url := fmt.Sprintf("%s/%s", d.baseURL, filename)164 url := fmt.Sprintf("%s/%s", d.baseURL, filename)
165 localPath := filepath.Join(d.cacheDir, filename)165 localPath := filepath.Join(d.cacheDir, filename)
166 166
167- if err := os.MkdirAll(d.cacheDir, cacheDirPerm); err != nil {
168- return "", 0, fmt.Errorf("failed to create cache dir: %w", err)
169- }
170-
171 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)167 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
172 if err != nil {168 if err != nil {
173 return "", 0, fmt.Errorf("failed to create request: %w", err)169 return "", 0, fmt.Errorf("failed to create request: %w", err)
M internal/infrastructure/persistence/sqlite/history_repo.go +1 -1
@@ -317,7 +317,7 @@ func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, del
317317 return nil
318318 }
319319 if delta <= 0 {
320- delta = 1
320+ return fmt.Errorf("visit delta must be > 0, got %d", delta)
321321 }
322322 return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{
323323 VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true},
@@ -317,7 +317,7 @@ func (r *historyRepo) IncrementVisitCountBy(ctx context.Context, url string, del
317 return nil317 return nil
318 }318 }
319 if delta <= 0 {319 if delta <= 0 {
320- delta = 1320+ return fmt.Errorf("visit delta must be > 0, got %d", delta)
321 }321 }
322 return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{322 return r.queries.IncrementVisitCountByDelta(ctx, sqlc.IncrementVisitCountByDeltaParams{
323 VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true},323 VisitCount: sql.NullInt64{Int64: int64(delta), Valid: true},
M internal/infrastructure/persistence/sqlite/lazy_repos.go +4 -0
@@ -18,6 +18,7 @@ package sqlite
1818 import (
1919 "context"
2020 "database/sql"
21+ "fmt"
2122 "sync"
2223 "time"
2324
@@ -115,6 +116,9 @@ func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url str
115116 }
116117
117118 func (r *LazyHistoryRepository) IncrementVisitCountBy(ctx context.Context, url string, delta int) error {
119+ if delta <= 0 {
120+ return fmt.Errorf("visit delta must be > 0, got %d", delta)
121+ }
118122 if err := r.init(ctx); err != nil {
119123 return err
120124 }
@@ -18,6 +18,7 @@ package sqlite
18 import (18 import (
19 "context"19 "context"
20 "database/sql"20 "database/sql"
21+ "fmt"
21 "sync"22 "sync"
22 "time"23 "time"
23 24
@@ -115,6 +116,9 @@ func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url str
115 }116 }
116 117
117 func (r *LazyHistoryRepository) IncrementVisitCountBy(ctx context.Context, url string, delta int) error {118 func (r *LazyHistoryRepository) IncrementVisitCountBy(ctx context.Context, url string, delta int) error {
119+ if delta <= 0 {
120+ return fmt.Errorf("visit delta must be > 0, got %d", delta)
121+ }
118 if err := r.init(ctx); err != nil {122 if err := r.init(ctx); err != nil {
119 return err123 return err
120 }124 }
M internal/infrastructure/snapshot/service.go +4 -0
@@ -67,6 +67,10 @@ func (s *Service) Start(ctx context.Context) {
6767 // to avoid FK constraint violations.
6868 func (s *Service) SetReady() {
6969 s.mu.Lock()
70+ if s.timer != nil {
71+ s.timer.Stop()
72+ s.timer = nil
73+ }
7074 s.ready = true
7175 dirty := s.dirty
7276 ctx := s.ctx
@@ -67,6 +67,10 @@ func (s *Service) Start(ctx context.Context) {
67 // to avoid FK constraint violations.67 // to avoid FK constraint violations.
68 func (s *Service) SetReady() {68 func (s *Service) SetReady() {
69 s.mu.Lock()69 s.mu.Lock()
70+ if s.timer != nil {
71+ s.timer.Stop()
72+ s.timer = nil
73+ }
70 s.ready = true74 s.ready = true
71 dirty := s.dirty75 dirty := s.dirty
72 ctx := s.ctx76 ctx := s.ctx
M internal/infrastructure/updater/github.go +4 -3
@@ -322,7 +322,10 @@ func doRequestWithRetryHelper(
322322 sleep func(ctx context.Context, d time.Duration) error,
323323 randInt63 func(n int64) int64,
324324 ) (*http.Response, error) {
325- for attempt := 1; attempt <= maxRetryAttempts; attempt++ {
325+ for attempt := 1; ; attempt++ {
326+ if attempt > maxRetryAttempts {
327+ return nil, fmt.Errorf("request failed after retries")
328+ }
326329 resp, err := client.Do(req)
327330 if err != nil {
328331 if !isRetryableRequestError(err) || attempt == maxRetryAttempts {
@@ -343,8 +346,6 @@ func doRequestWithRetryHelper(
343346 return nil, waitErr
344347 }
345348 }
346-
347- return nil, fmt.Errorf("request failed after retries")
348349 }
349350
350351 func (g *GitHubChecker) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
@@ -322,7 +322,10 @@ func doRequestWithRetryHelper(
322 sleep func(ctx context.Context, d time.Duration) error,322 sleep func(ctx context.Context, d time.Duration) error,
323 randInt63 func(n int64) int64,323 randInt63 func(n int64) int64,
324 ) (*http.Response, error) {324 ) (*http.Response, error) {
325- for attempt := 1; attempt <= maxRetryAttempts; attempt++ {325+ for attempt := 1; ; attempt++ {
326+ if attempt > maxRetryAttempts {
327+ return nil, fmt.Errorf("request failed after retries")
328+ }
326 resp, err := client.Do(req)329 resp, err := client.Do(req)
327 if err != nil {330 if err != nil {
328 if !isRetryableRequestError(err) || attempt == maxRetryAttempts {331 if !isRetryableRequestError(err) || attempt == maxRetryAttempts {
@@ -343,8 +346,6 @@ func doRequestWithRetryHelper(
343 return nil, waitErr346 return nil, waitErr
344 }347 }
345 }348 }
346-
347- return nil, fmt.Errorf("request failed after retries")
348 }349 }
349 350
350 func (g *GitHubChecker) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {351 func (g *GitHubChecker) doRequestWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
M internal/infrastructure/webkit/pool.go +21 -19
@@ -44,12 +44,10 @@ type PoolConfig struct {
4444 // DefaultPoolConfig returns sensible defaults for the pool.
4545 func DefaultPoolConfig() PoolConfig {
4646 return PoolConfig{
47- MinSize: 2,
48- MaxSize: 8,
49- PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation
50- IdleTimeout: 5 * time.Minute,
51- ReusePolicy: reusePolicyOff,
52- TerminatePolicy: terminatePolicyAuto,
47+ MinSize: 2,
48+ MaxSize: 8,
49+ PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation
50+ IdleTimeout: 5 * time.Minute,
5351 }
5452 }
5553
@@ -66,6 +64,7 @@ type WebViewPool struct {
6664 config PoolConfig
6765
6866 pool chan *WebView
67+ poolMu sync.Mutex
6968 injector *ContentInjector
7069 router *MessageRouter
7170 filterApplier FilterApplier // Optional content filter applier
@@ -299,12 +298,11 @@ func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
299298 return err
300299 }
301300
302- select {
303- case p.pool <- wv:
301+ if p.tryPut(ctx, wv) {
304302 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")
305- default:
306- wv.Destroy() // Pool somehow full (shouldn't happen)
303+ return nil
307304 }
305+ wv.Destroy() // Pool somehow full (shouldn't happen)
308306 return nil
309307 }
310308
@@ -375,10 +373,9 @@ func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
375373 if err != nil {
376374 log.Warn().Err(err).Msg("failed to prewarm webview")
377375 } else {
378- select {
379- case p.pool <- wv:
376+ if p.tryPut(ctx, wv) {
380377 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
381- default:
378+ } else {
382379 wv.Destroy()
383380 }
384381 }
@@ -417,10 +414,9 @@ func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
417414 }
418415
419416 // Try to add to pool
420- select {
421- case p.pool <- wv:
417+ if p.tryPut(ctx, wv) {
422418 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
423- default:
419+ } else {
424420 // Pool full, destroy
425421 wv.Destroy()
426422 }
@@ -468,10 +464,9 @@ refreshLoop:
468464 }
469465
470466 // Put back in pool
471- select {
472- case p.pool <- wv:
467+ if p.tryPut(ctx, wv) {
473468 refreshed++
474- default:
469+ } else {
475470 wv.Destroy()
476471 }
477472 default:
@@ -498,7 +493,9 @@ func (p *WebViewPool) Close(ctx context.Context) {
498493 p.wg.Wait()
499494
500495 // Drain and destroy all pooled views
496+ p.poolMu.Lock()
501497 close(p.pool)
498+ p.poolMu.Unlock()
502499 for wv := range p.pool {
503500 if wv != nil && !wv.IsDestroyed() {
504501 wv.Destroy()
@@ -529,6 +526,11 @@ func (p *WebViewPool) shouldReuseOnRelease(wv *WebView) bool {
529526
530527 func (p *WebViewPool) tryPut(ctx context.Context, wv *WebView) bool {
531528 log := logging.FromContext(ctx)
529+ p.poolMu.Lock()
530+ defer p.poolMu.Unlock()
531+ if p.closed.Load() {
532+ return false
533+ }
532534 select {
533535 case p.pool <- wv:
534536 log.Debug().Uint64("id", uint64(wv.ID())).Int("pool_size", len(p.pool)).Msg("returned webview to pool")
@@ -44,12 +44,10 @@ type PoolConfig struct {
44 // DefaultPoolConfig returns sensible defaults for the pool.44 // DefaultPoolConfig returns sensible defaults for the pool.
45 func DefaultPoolConfig() PoolConfig {45 func DefaultPoolConfig() PoolConfig {
46 return PoolConfig{46 return PoolConfig{
47- MinSize: 2,47+ MinSize: 2,
48- MaxSize: 8,48+ MaxSize: 8,
49- PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation49+ PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation
50- IdleTimeout: 5 * time.Minute,50+ IdleTimeout: 5 * time.Minute,
51- ReusePolicy: reusePolicyOff,
52- TerminatePolicy: terminatePolicyAuto,
53 }51 }
54 }52 }
55 53
@@ -66,6 +64,7 @@ type WebViewPool struct {
66 config PoolConfig64 config PoolConfig
67 65
68 pool chan *WebView66 pool chan *WebView
67+ poolMu sync.Mutex
69 injector *ContentInjector68 injector *ContentInjector
70 router *MessageRouter69 router *MessageRouter
71 filterApplier FilterApplier // Optional content filter applier70 filterApplier FilterApplier // Optional content filter applier
@@ -299,12 +298,11 @@ func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
299 return err298 return err
300 }299 }
301 300
302- select {301+ if p.tryPut(ctx, wv) {
303- case p.pool <- wv:
304 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")302 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")
305- default:303+ return nil
306- wv.Destroy() // Pool somehow full (shouldn't happen)
307 }304 }
305+ wv.Destroy() // Pool somehow full (shouldn't happen)
308 return nil306 return nil
309 }307 }
310 308
@@ -375,10 +373,9 @@ func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
375 if err != nil {373 if err != nil {
376 log.Warn().Err(err).Msg("failed to prewarm webview")374 log.Warn().Err(err).Msg("failed to prewarm webview")
377 } else {375 } else {
378- select {376+ if p.tryPut(ctx, wv) {
379- case p.pool <- wv:
380 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")377 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
381- default:378+ } else {
382 wv.Destroy()379 wv.Destroy()
383 }380 }
384 }381 }
@@ -417,10 +414,9 @@ func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
417 }414 }
418 415
419 // Try to add to pool416 // Try to add to pool
420- select {417+ if p.tryPut(ctx, wv) {
421- case p.pool <- wv:
422 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")418 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
423- default:419+ } else {
424 // Pool full, destroy420 // Pool full, destroy
425 wv.Destroy()421 wv.Destroy()
426 }422 }
@@ -468,10 +464,9 @@ refreshLoop:
468 }464 }
469 465
470 // Put back in pool466 // Put back in pool
471- select {467+ if p.tryPut(ctx, wv) {
472- case p.pool <- wv:
473 refreshed++468 refreshed++
474- default:469+ } else {
475 wv.Destroy()470 wv.Destroy()
476 }471 }
477 default:472 default:
@@ -498,7 +493,9 @@ func (p *WebViewPool) Close(ctx context.Context) {
498 p.wg.Wait()493 p.wg.Wait()
499 494
500 // Drain and destroy all pooled views495 // Drain and destroy all pooled views
496+ p.poolMu.Lock()
501 close(p.pool)497 close(p.pool)
498+ p.poolMu.Unlock()
502 for wv := range p.pool {499 for wv := range p.pool {
503 if wv != nil && !wv.IsDestroyed() {500 if wv != nil && !wv.IsDestroyed() {
504 wv.Destroy()501 wv.Destroy()
@@ -529,6 +526,11 @@ func (p *WebViewPool) shouldReuseOnRelease(wv *WebView) bool {
529 526
530 func (p *WebViewPool) tryPut(ctx context.Context, wv *WebView) bool {527 func (p *WebViewPool) tryPut(ctx context.Context, wv *WebView) bool {
531 log := logging.FromContext(ctx)528 log := logging.FromContext(ctx)
529+ p.poolMu.Lock()
530+ defer p.poolMu.Unlock()
531+ if p.closed.Load() {
532+ return false
533+ }
532 select {534 select {
533 case p.pool <- wv:535 case p.pool <- wv:
534 log.Debug().Uint64("id", uint64(wv.ID())).Int("pool_size", len(p.pool)).Msg("returned webview to pool")536 log.Debug().Uint64("id", uint64(wv.ID())).Int("pool_size", len(p.pool)).Msg("returned webview to pool")
M internal/infrastructure/webkit/scheme_handler.go +2 -2
@@ -179,7 +179,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
179179
180180 // API: Get current config (used by dumb://config)
181181 h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
182- if req.Method != httpGET {
182+ if req.Method != "" && req.Method != httpGET {
183183 return nil
184184 }
185185
@@ -188,7 +188,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
188188
189189 // API: Get default config (used by Reset Defaults in dumb://config)
190190 h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
191- if req.Method != httpGET {
191+ if req.Method != "" && req.Method != httpGET {
192192 return nil
193193 }
194194
@@ -179,7 +179,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
179 179
180 // API: Get current config (used by dumb://config)180 // API: Get current config (used by dumb://config)
181 h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {181 h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
182- if req.Method != httpGET {182+ if req.Method != "" && req.Method != httpGET {
183 return nil183 return nil
184 }184 }
185 185
@@ -188,7 +188,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
188 188
189 // API: Get default config (used by Reset Defaults in dumb://config)189 // API: Get default config (used by Reset Defaults in dumb://config)
190 h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {190 h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
191- if req.Method != httpGET {191+ if req.Method != "" && req.Method != httpGET {
192 return nil192 return nil
193 }193 }
194 194
M internal/infrastructure/webkit/webview.go +3 -3
@@ -171,7 +171,7 @@ type WebView struct {
171171 findControllerOnce sync.Once
172172
173173 backForwardList *webkit.BackForwardList
174- backForwardListSignalID uint
174+ backForwardListSignalID uintptr
175175 }
176176
177177 type runJSErrorStat struct {
@@ -882,7 +882,7 @@ func (wv *WebView) connectBackForwardListChangedSignal() {
882882 }
883883 sigID := backForwardList.ConnectChanged(&changedCb)
884884 wv.backForwardList = backForwardList
885- wv.backForwardListSignalID = sigID
885+ wv.backForwardListSignalID = uintptr(sigID)
886886 }
887887
888888 func webProcessTerminationReasonString(reason webkit.WebProcessTerminationReason) string {
@@ -1333,7 +1333,7 @@ func (wv *WebView) DisconnectSignals() {
13331333
13341334 if wv.backForwardList != nil && wv.backForwardListSignalID != 0 {
13351335 bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer())
1336- gobject.SignalHandlerDisconnect(bfObj, wv.backForwardListSignalID)
1336+ gobject.SignalHandlerDisconnect(bfObj, uint(wv.backForwardListSignalID))
13371337 wv.backForwardListSignalID = 0
13381338 wv.backForwardList = nil
13391339 }
@@ -171,7 +171,7 @@ type WebView struct {
171 findControllerOnce sync.Once171 findControllerOnce sync.Once
172 172
173 backForwardList *webkit.BackForwardList173 backForwardList *webkit.BackForwardList
174- backForwardListSignalID uint174+ backForwardListSignalID uintptr
175 }175 }
176 176
177 type runJSErrorStat struct {177 type runJSErrorStat struct {
@@ -882,7 +882,7 @@ func (wv *WebView) connectBackForwardListChangedSignal() {
882 }882 }
883 sigID := backForwardList.ConnectChanged(&changedCb)883 sigID := backForwardList.ConnectChanged(&changedCb)
884 wv.backForwardList = backForwardList884 wv.backForwardList = backForwardList
885- wv.backForwardListSignalID = sigID885+ wv.backForwardListSignalID = uintptr(sigID)
886 }886 }
887 887
888 func webProcessTerminationReasonString(reason webkit.WebProcessTerminationReason) string {888 func webProcessTerminationReasonString(reason webkit.WebProcessTerminationReason) string {
@@ -1333,7 +1333,7 @@ func (wv *WebView) DisconnectSignals() {
1333 1333
1334 if wv.backForwardList != nil && wv.backForwardListSignalID != 0 {1334 if wv.backForwardList != nil && wv.backForwardListSignalID != 0 {
1335 bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer())1335 bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer())
1336- gobject.SignalHandlerDisconnect(bfObj, wv.backForwardListSignalID)1336+ gobject.SignalHandlerDisconnect(bfObj, uint(wv.backForwardListSignalID))
1337 wv.backForwardListSignalID = 01337 wv.backForwardListSignalID = 0
1338 wv.backForwardList = nil1338 wv.backForwardList = nil
1339 }1339 }
M internal/ui/adapter/favicon.go +1 -1
@@ -201,7 +201,7 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
201201 // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.
202202 func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {
203203 log := logging.FromContext(ctx)
204- cacheDirWarnKey := "cache-dir:" + domain
204+ cacheDirWarnKey := "cache-dir"
205205 savePNGWarnKey := "save-png:" + domain
206206 sizedPNGWarnKey := "sized-png:" + domain
207207
@@ -201,7 +201,7 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
201 // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.201 // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.
202 func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {202 func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {
203 log := logging.FromContext(ctx)203 log := logging.FromContext(ctx)
204- cacheDirWarnKey := "cache-dir:" + domain204+ cacheDirWarnKey := "cache-dir"
205 savePNGWarnKey := "save-png:" + domain205 savePNGWarnKey := "save-png:" + domain
206 sizedPNGWarnKey := "sized-png:" + domain206 sizedPNGWarnKey := "sized-png:" + domain
207 207
M internal/ui/adapter/favicon_test.go +23 -1
@@ -1,6 +1,11 @@
11 package adapter
22
3-import "testing"
3+import (
4+ "sync/atomic"
5+ "testing"
6+
7+ "github.com/rs/zerolog"
8+)
49
510 func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {
611 adapter := NewFaviconAdapter(nil, nil)
@@ -49,3 +54,20 @@ func TestFaviconWarningDedup_ClearResetsState(t *testing.T) {
4954 t.Fatalf("expected suppressed count reset after clear, got %d", suppressed)
5055 }
5156 }
57+
58+func TestFaviconWarningDedup_LogWarningDedupInvokesCallbackOnce(t *testing.T) {
59+ adapter := NewFaviconAdapter(nil, nil)
60+ key := "save-png:example.com"
61+ var calls atomic.Int32
62+
63+ adapter.logWarningDedup(t.Context(), key, nil, func(_ *zerolog.Logger, _ error) {
64+ calls.Add(1)
65+ })
66+ adapter.logWarningDedup(t.Context(), key, nil, func(_ *zerolog.Logger, _ error) {
67+ calls.Add(1)
68+ })
69+
70+ if got := calls.Load(); got != 1 {
71+ t.Fatalf("expected warning callback to run once, got %d", got)
72+ }
73+}
@@ -1,6 +1,11 @@
1 package adapter1 package adapter
2 2
3-import "testing"3+import (
4+ "sync/atomic"
5+ "testing"
6+
7+ "github.com/rs/zerolog"
8+)
4 9
5 func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {10 func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {
6 adapter := NewFaviconAdapter(nil, nil)11 adapter := NewFaviconAdapter(nil, nil)
@@ -49,3 +54,20 @@ func TestFaviconWarningDedup_ClearResetsState(t *testing.T) {
49 t.Fatalf("expected suppressed count reset after clear, got %d", suppressed)54 t.Fatalf("expected suppressed count reset after clear, got %d", suppressed)
50 }55 }
51 }56 }
57+
58+func TestFaviconWarningDedup_LogWarningDedupInvokesCallbackOnce(t *testing.T) {
59+ adapter := NewFaviconAdapter(nil, nil)
60+ key := "save-png:example.com"
61+ var calls atomic.Int32
62+
63+ adapter.logWarningDedup(t.Context(), key, nil, func(_ *zerolog.Logger, _ error) {
64+ calls.Add(1)
65+ })
66+ adapter.logWarningDedup(t.Context(), key, nil, func(_ *zerolog.Logger, _ error) {
67+ calls.Add(1)
68+ })
69+
70+ if got := calls.Load(); got != 1 {
71+ t.Fatalf("expected warning callback to run once, got %d", got)
72+ }
73+}
M internal/ui/coordinator/content.go +6 -1
@@ -23,6 +23,7 @@ import (
2323 "github.com/bnema/dumber/internal/ui/layout"
2424 webkitlib "github.com/bnema/puregotk-webkit/webkit"
2525 "github.com/jwijenbergh/puregotk/v4/gdk"
26+ "github.com/jwijenbergh/puregotk/v4/glib"
2627 )
2728
2829 const (
@@ -1751,7 +1752,11 @@ func (c *ContentCoordinator) scheduleParentPaneRefresh(
17511752 c.popupMu.Lock()
17521753 delete(c.popupRefresh, parentPaneID)
17531754 c.popupMu.Unlock()
1754- c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID)
1755+ cb := glib.SourceFunc(func(_ uintptr) bool {
1756+ c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID)
1757+ return false
1758+ })
1759+ glib.IdleAdd(&cb, 0)
17551760 })
17561761 c.popupMu.Unlock()
17571762 }
@@ -23,6 +23,7 @@ import (
23 "github.com/bnema/dumber/internal/ui/layout"23 "github.com/bnema/dumber/internal/ui/layout"
24 webkitlib "github.com/bnema/puregotk-webkit/webkit"24 webkitlib "github.com/bnema/puregotk-webkit/webkit"
25 "github.com/jwijenbergh/puregotk/v4/gdk"25 "github.com/jwijenbergh/puregotk/v4/gdk"
26+ "github.com/jwijenbergh/puregotk/v4/glib"
26 )27 )
27 28
28 const (29 const (
@@ -1751,7 +1752,11 @@ func (c *ContentCoordinator) scheduleParentPaneRefresh(
1751 c.popupMu.Lock()1752 c.popupMu.Lock()
1752 delete(c.popupRefresh, parentPaneID)1753 delete(c.popupRefresh, parentPaneID)
1753 c.popupMu.Unlock()1754 c.popupMu.Unlock()
1754- c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID)1755+ cb := glib.SourceFunc(func(_ uintptr) bool {
1756+ c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID)
1757+ return false
1758+ })
1759+ glib.IdleAdd(&cb, 0)
1755 })1760 })
1756 c.popupMu.Unlock()1761 c.popupMu.Unlock()
1757 }1762 }

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