favicon_refresh.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"crypto/sha256"
6	"encoding/hex"
7	"net"
8	"net/url"
9	"slices"
10	"strings"
11	"sync"
12	"time"
13
14	appport "github.com/bnema/dumber/internal/application/port"
15	"github.com/bnema/dumber/internal/domain/favicon"
16)
17
18const (
19	// maxRefreshCandidates bounds how many icon URLs one refresh operation
20	// accepts; extras are skipped, never queued.
21	maxRefreshCandidates = 16
22	// maxRefreshCandidateLen bounds a single candidate URL length; longer
23	// inputs are skipped as invalid rather than fetched.
24	maxRefreshCandidateLen = 8 * 1024
25	// maxActiveRefreshes bounds distinct concurrent refresh operations
26	// across direct and scheduler-backed paths. Overload skips optional
27	// background work or reports ErrFaviconBusy to direct callers.
28	maxActiveRefreshes = 8
29	// refreshOpTimeout bounds a whole refresh operation, including
30	// context-aware DNS/validation, not per candidate.
31	refreshOpTimeout = 5 * time.Second
32	// faviconMissTTL bounds negative-cache entries for genuine absence.
33	faviconMissTTL = 30 * time.Second
34	// maxMissEntries bounds the negative cache; oldest entries are evicted.
35	maxMissEntries = 256
36)
37
38// refreshRequestKey builds a fixed-size digest identifying request-equivalent
39// refresh work: the validated page origin plus the ordered, normalized
40// candidate list. Scheme, host, port, path, and query are significant;
41// fragments are dropped and hosts lowercased. Relative candidates resolve
42// against the page URL. At most maxRefreshCandidates URLs of at most
43// maxRefreshCandidateLen bytes are accepted; the rest are skipped. It
44// returns false when the page URL has no valid origin hierarchy.
45func refreshRequestKey(pageURL string, iconURLs []string) (string, []string, bool) {
46	pageKey, ok := favicon.CanonicalKey(pageURL)
47	if !ok {
48		return "", nil, false
49	}
50	page, err := url.Parse(strings.TrimSpace(pageURL))
51	if err != nil || page.Hostname() == "" {
52		return "", nil, false
53	}
54	accepted := make([]string, 0, min(len(iconURLs), maxRefreshCandidates))
55	for _, raw := range iconURLs {
56		raw = strings.TrimSpace(raw)
57		if raw == "" || len(raw) > maxRefreshCandidateLen {
58			continue
59		}
60		candidate, err := page.Parse(raw)
61		if err != nil {
62			continue
63		}
64		if candidate.Scheme != "http" && candidate.Scheme != "https" {
65			continue
66		}
67		if candidate.Hostname() == "" {
68			continue
69		}
70		candidate.Fragment = ""
71		candidate.Host = strings.ToLower(candidate.Host)
72		if host, port, ok := splitHostPort(candidate.Host); ok {
73			if isDefaultPort(candidate.Scheme, port) {
74				// Re-bracket IPv6 literals: SplitHostPort strips
75				// brackets, and url.URL requires them in Host.
76				if strings.Contains(host, ":") {
77					candidate.Host = "[" + host + "]"
78				} else {
79					candidate.Host = host
80				}
81			}
82		}
83		accepted = append(accepted, candidate.String())
84		if len(accepted) >= maxRefreshCandidates {
85			break
86		}
87	}
88	sum := sha256.Sum256([]byte(string(pageKey) + "\x00" + strings.Join(accepted, "\x00")))
89	return hex.EncodeToString(sum[:]), accepted, true
90}
91
92// splitHostPort splits an explicit host:port pair. Bracketed IPv6 literals
93// (as produced by url.URL.Host) require net.SplitHostPort; a bare
94// strings.Cut on ":" misparses them. Hosts without a port report false.
95func splitHostPort(hostport string) (string, string, bool) {
96	host, port, err := net.SplitHostPort(hostport)
97	if err != nil {
98		return "", "", false
99	}
100	return host, port, true
101}
102
103func isDefaultPort(scheme, port string) bool {
104	return (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
105}
106
107// refreshCall is one shared in-flight refresh operation. Waiters join by
108// request key; the first waiter executes while the rest observe. One
109// canceled waiter never cancels the others; the operation is canceled only
110// when the last waiter leaves, decided atomically under the coordinator
111// lock so no joiner can observe a half-canceled call.
112type refreshCall struct {
113	done    chan struct{}
114	err     error
115	waiters int
116	cancel  context.CancelFunc
117	// closing marks a call whose last waiter left: it still occupies its
118	// slot and map entry until the executor finishes, but no new waiter
119	// may join it; joiners create a fresh operation instead.
120	closing bool
121}
122
123// refreshMiss records a negatively cached genuine absence.
124type refreshMiss struct {
125	expiresAt time.Time
126}
127
128// refreshCoordinator state lives on FaviconUseCase; see favicon.go.
129type refreshCoordinator struct {
130	mu     sync.Mutex
131	active map[string]*refreshCall
132	slots  chan struct{}
133	// wg tracks every spawned executor independently of the active map:
134	// a fresh same-key operation may replace a canceled-but-still-running
135	// call in active, so the map alone cannot prove shutdown quiescence.
136	// Adds happen under mu before the goroutine starts; Done runs when the
137	// executor finishes. Sealed admission (closed) guarantees no Add can
138	// race a completed Wait.
139	wg     sync.WaitGroup
140	epoch  uint64
141	// closed seals admission: set by Close before cancel/drain, so no
142	// refresh can start after the drain observes zero active operations.
143	closed bool
144	misses map[string]refreshMiss
145	order  []string
146}
147
148func newRefreshCoordinator() *refreshCoordinator {
149	return &refreshCoordinator{
150		active: make(map[string]*refreshCall),
151		slots:  make(chan struct{}, maxActiveRefreshes),
152		misses: make(map[string]refreshMiss),
153	}
154}
155
156// epochValue returns the current invalidation epoch.
157func (uc *FaviconUseCase) epochValue() uint64 {
158	uc.shared.mu.Lock()
159	defer uc.shared.mu.Unlock()
160	return uc.shared.epoch
161}
162
163// bumpEpoch advances the invalidation epoch so older in-flight completions
164// cannot repopulate invalidated entries.
165func (uc *FaviconUseCase) bumpEpoch() {
166	uc.shared.mu.Lock()
167	defer uc.shared.mu.Unlock()
168	uc.shared.epoch++
169}
170
171// cachedMiss reports whether key holds an unexpired genuine-absence entry.
172func (uc *FaviconUseCase) cachedMiss(key string) bool {
173	_, miss := uc.sealedOrMiss(key)
174	return miss
175}
176
177// sealedOrMiss atomically reports coordinator shutdown and negative-cache
178// state under one lock acquisition, giving late refreshes a single
179// linearization point: sealed admission always wins over a cached absence,
180// so a refresh arriving after Close reports ErrFaviconShutdown even when
181// the key holds a cached 404/410.
182func (uc *FaviconUseCase) sealedOrMiss(key string) (closed, miss bool) {
183	now := uc.now()
184	uc.shared.mu.Lock()
185	defer uc.shared.mu.Unlock()
186	if uc.shared.closed {
187		return true, false
188	}
189	m, ok := uc.shared.misses[key]
190	if !ok {
191		return false, false
192	}
193	if !now.Before(m.expiresAt) {
194		delete(uc.shared.misses, key)
195		return false, false
196	}
197	return false, true
198}
199
200// clearMiss drops any absence record for key: a later success proves the
201// resource exists and must not stay suppressed.
202func (uc *FaviconUseCase) clearMiss(key string) {
203	uc.shared.mu.Lock()
204	defer uc.shared.mu.Unlock()
205	delete(uc.shared.misses, key)
206}
207
208// noteMiss records a genuine absence (verified 404/410) for request key.
209// Only 404/410 are cacheable: 401/403, 5xx, cancellation, DNS/transport,
210// and validation failures must never suppress a later retry.
211func (uc *FaviconUseCase) noteMiss(key string, fetchErr error) {
212	status, ok := appport.FetchStatusCode(fetchErr)
213	if !ok || (status != 404 && status != 410) {
214		return
215	}
216	uc.shared.mu.Lock()
217	defer uc.shared.mu.Unlock()
218	if _, exists := uc.shared.misses[key]; !exists {
219		for len(uc.shared.misses) >= maxMissEntries {
220			uc.evictMissLocked()
221		}
222		uc.shared.order = append(uc.shared.order, key)
223	}
224	uc.shared.misses[key] = refreshMiss{expiresAt: uc.now().Add(faviconMissTTL)}
225	// Expired names linger in order until evicted; compact before the
226	// backlog can grow without bound.
227	if len(uc.shared.order) > 4*maxMissEntries {
228		uc.compactMissOrderLocked()
229	}
230}
231
232// compactMissOrderLocked drops order names with no live entry and collapses
233// duplicates to one entry per live key, keeping the newest occurrence.
234// Eviction removes entries from the map but not from order, so an evicted
235// key that is reinserted would otherwise leave a stale duplicate behind:
236// repeated cycles grow order without bound and let an old duplicate evict
237// the live entry early. Caller must hold refresh.mu.
238func (uc *FaviconUseCase) compactMissOrderLocked() {
239	last := make(map[string]int, len(uc.shared.misses))
240	for i, key := range uc.shared.order {
241		if _, ok := uc.shared.misses[key]; ok {
242			last[key] = i
243		}
244	}
245	ordered := make([]string, 0, len(last))
246	for key := range last {
247		ordered = append(ordered, key)
248	}
249	slices.SortFunc(ordered, func(a, b string) int { return last[a] - last[b] })
250	for i := len(ordered); i < len(uc.shared.order); i++ {
251		uc.shared.order[i] = ""
252	}
253	uc.shared.order = append(uc.shared.order[:0], ordered...)
254}
255
256// evictMissLocked removes one entry: expired first, oldest otherwise.
257// Caller must hold refresh.mu.
258func (uc *FaviconUseCase) evictMissLocked() {
259	now := uc.now()
260	for key, miss := range uc.shared.misses {
261		if !now.Before(miss.expiresAt) {
262			delete(uc.shared.misses, key)
263			return
264		}
265	}
266	for len(uc.shared.order) > 0 {
267		oldest := uc.shared.order[0]
268		uc.shared.order = uc.shared.order[1:]
269		if _, ok := uc.shared.misses[oldest]; ok {
270			delete(uc.shared.misses, oldest)
271			return
272		}
273	}
274	for key := range uc.shared.misses {
275		delete(uc.shared.misses, key)
276		return
277	}
278}
279
280// joinRefreshOp shares equivalent in-flight work identified by key. The
281// first waiter executes exec under a whole-operation deadline derived from
282// the owned background context; joiners observe the result. The creating
283// waiter also advances the invalidation epoch, so concurrent joiners never
284// invalidate the operation they joined. A canceled waiter leaves without
285// affecting others; the operation is canceled atomically when the last
286// waiter leaves, and late joiners start a fresh operation instead of
287// observing a dying call. When the admission budget is exhausted, or the
288// coordinator is closed, direct callers receive ErrFaviconBusy or
289// ErrFaviconShutdown respectively (never false success).
290func (uc *FaviconUseCase) joinRefreshOp(ctx context.Context, key string, exec func(opCtx context.Context, startEpoch uint64) error) error {
291	uc.shared.mu.Lock()
292	if uc.shared.closed {
293		uc.shared.mu.Unlock()
294		return appport.ErrFaviconShutdown
295	}
296	if call, ok := uc.shared.active[key]; ok && !call.closing {
297		call.waiters++
298		uc.shared.mu.Unlock()
299		return uc.awaitRefreshCall(ctx, call)
300	}
301	select {
302	case uc.shared.slots <- struct{}{}:
303	default:
304		uc.shared.mu.Unlock()
305		return appport.ErrFaviconBusy
306	}
307	opCtx, cancel := context.WithTimeout(uc.background, refreshOpTimeout)
308	// The creator advances the epoch with creation itself: older
309	// completions cannot repopulate entries this operation supersedes,
310	// and joiners share the creator's epoch instead of invalidating it.
311	uc.shared.epoch++
312	startEpoch := uc.shared.epoch
313	call := &refreshCall{done: make(chan struct{}), waiters: 1, cancel: cancel}
314	uc.shared.active[key] = call
315	uc.shared.wg.Add(1)
316	uc.shared.mu.Unlock()
317
318	go func() {
319		defer uc.shared.wg.Done()
320		err := exec(opCtx, startEpoch)
321		<-uc.shared.slots
322		cancel()
323		uc.shared.mu.Lock()
324		call.err = err
325		if uc.shared.active[key] == call {
326			delete(uc.shared.active, key)
327		}
328		uc.shared.mu.Unlock()
329		close(call.done)
330	}()
331
332	return uc.awaitRefreshCall(ctx, call)
333}
334
335// awaitRefreshCall waits for a shared operation or the waiter's own
336// cancellation, releasing the waiter slot exactly once. The last waiter to
337// leave marks the call closing and cancels it atomically under the lock:
338// context cancellation only signals, so invoking it while holding the
339// coordinator mutex cannot block.
340func (uc *FaviconUseCase) awaitRefreshCall(ctx context.Context, call *refreshCall) error {
341	select {
342	case <-call.done:
343		uc.shared.mu.Lock()
344		err := call.err
345		uc.shared.mu.Unlock()
346		return err
347	case <-ctx.Done():
348		uc.shared.mu.Lock()
349		call.waiters--
350		if call.waiters <= 0 && !call.closing {
351			call.closing = true
352			call.cancel()
353		}
354		uc.shared.mu.Unlock()
355		return ctx.Err()
356	}
357}
358
359// drainRefreshOps waits until every spawned executor finished, up to a
360// bounded multiple of the operation deadline. It waits on the executor
361// wait group rather than the active map: a replaced
362// canceled-but-still-running call no longer occupies its map entry, but
363// its executor may still touch repositories. Callers cancel first (see
364// Close); cooperative operations observe the background cancellation and
365// finish promptly, so hitting the bound means stuck work that shutdown
366// must not wait out.
367func (uc *FaviconUseCase) drainRefreshOps() {
368	done := make(chan struct{})
369	go func() {
370		uc.shared.wg.Wait()
371		close(done)
372	}()
373	deadline := time.NewTimer(3 * refreshOpTimeout)
374	defer deadline.Stop()
375	select {
376	case <-done:
377	case <-deadline.C:
378	}
379}

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