detached 5041353

accessibility_capture.go RawHistoryFolder

1package cef
2
3import (
4	"fmt"
5	"os"
6	"path/filepath"
7	"strconv"
8	"strings"
9	"sync"
10	"sync/atomic"
11	"syscall"
12
13	"github.com/bnema/dumber/internal/application/port"
14	"github.com/bnema/dumber/internal/infrastructure/config"
15	"github.com/bnema/dumber/internal/logging"
16	"github.com/bnema/dumber/internal/shared/syncdispatch"
17)
18
19const (
20	accessibilityCaptureQueueCapacity = 64
21	accessibilityCaptureDirPerm       = 0o700
22	accessibilityCaptureFilePerm      = 0o600
23	accessibilityCaptureEnvName       = "DUMBER_A11Y_CAPTURE"
24	accessibilityCaptureEnvValue      = "1"
25	accessibilityCaptureKindTree      = "tree"
26	accessibilityCaptureKindLocation  = "location"
27
28	// Per-WebView capture budgets for the durable .../a11y-capture/webview-%d
29	// namespace. Sized to cover an ~80s measurement campaign with headroom for
30	// frequent tree+location updates, while capping unbounded growth when the
31	// same WebView-ID directory is reused across sessions. Existing strict
32	// capture files count toward both budgets; writes reject before create
33	// when either limit would be exceeded. Tests may inject smaller limits.
34	accessibilityCaptureDefaultMaxFiles = 10_000
35	accessibilityCaptureDefaultMaxBytes = 512 << 20 // 512 MiB
36)
37
38type accessibilityStatsSnapshot struct {
39	Count                 int
40	TreeCount             int
41	LocationCount         int
42	TotalBytes            int64
43	MinBytes              int
44	MaxBytes              int
45	AverageBytes          float64
46	TotalSerializeNanos   int64
47	MaxSerializeNanos     int64
48	AverageSerializeNanos float64
49}
50
51type accessibilityStats struct {
52	mu                  sync.Mutex
53	count               int
54	treeCount           int
55	locationCount       int
56	totalBytes          int64
57	minBytes            int
58	maxBytes            int
59	totalSerializeNanos int64
60	maxSerializeNanos   int64
61}
62
63func (s *accessibilityStats) Observe(p accessibilityPayload) {
64	if s == nil {
65		return
66	}
67	s.mu.Lock()
68	defer s.mu.Unlock()
69	s.count++
70	switch p.Kind {
71	case accessibilityCaptureKindTree:
72		s.treeCount++
73	case accessibilityCaptureKindLocation:
74		s.locationCount++
75	}
76	s.totalBytes += int64(p.Bytes)
77	if s.count == 1 || p.Bytes < s.minBytes {
78		s.minBytes = p.Bytes
79	}
80	if p.Bytes > s.maxBytes {
81		s.maxBytes = p.Bytes
82	}
83	s.totalSerializeNanos += p.SerializeNanos
84	if p.SerializeNanos > s.maxSerializeNanos {
85		s.maxSerializeNanos = p.SerializeNanos
86	}
87}
88
89func (s *accessibilityStats) Snapshot() accessibilityStatsSnapshot {
90	if s == nil {
91		return accessibilityStatsSnapshot{}
92	}
93	s.mu.Lock()
94	defer s.mu.Unlock()
95	if s.count == 0 {
96		return accessibilityStatsSnapshot{}
97	}
98	return accessibilityStatsSnapshot{
99		Count:                 s.count,
100		TreeCount:             s.treeCount,
101		LocationCount:         s.locationCount,
102		TotalBytes:            s.totalBytes,
103		MinBytes:              s.minBytes,
104		MaxBytes:              s.maxBytes,
105		AverageBytes:          float64(s.totalBytes) / float64(s.count),
106		TotalSerializeNanos:   s.totalSerializeNanos,
107		MaxSerializeNanos:     s.maxSerializeNanos,
108		AverageSerializeNanos: float64(s.totalSerializeNanos) / float64(s.count),
109	}
110}
111
112type accessibilityCaptureWorker struct {
113	mu      sync.RWMutex
114	closed  bool
115	queue   chan accessibilityPayload
116	wg      sync.WaitGroup
117	consume func(accessibilityPayload)
118	dropped atomic.Uint64
119}
120
121func newAccessibilityCaptureWorker(capacity int, consume func(accessibilityPayload)) *accessibilityCaptureWorker {
122	if capacity < 1 {
123		capacity = 1
124	}
125	w := &accessibilityCaptureWorker{
126		queue:   make(chan accessibilityPayload, capacity),
127		consume: consume,
128	}
129	w.wg.Add(1)
130	go w.loop()
131	return w
132}
133
134func (w *accessibilityCaptureWorker) loop() {
135	defer w.wg.Done()
136	for p := range w.queue {
137		if w.consume != nil {
138			w.consume(p)
139		}
140	}
141}
142
143func (w *accessibilityCaptureWorker) Submit(p accessibilityPayload) bool {
144	if w == nil {
145		return false
146	}
147	w.mu.RLock()
148	defer w.mu.RUnlock()
149	if w.closed {
150		return false
151	}
152	select {
153	case w.queue <- p:
154		return true
155	default:
156		w.dropped.Add(1)
157		return false
158	}
159}
160
161func (w *accessibilityCaptureWorker) Dropped() uint64 {
162	if w == nil {
163		return 0
164	}
165	return w.dropped.Load()
166}
167
168func (w *accessibilityCaptureWorker) Close() {
169	if w == nil {
170		return
171	}
172	w.mu.Lock()
173	if w.closed {
174		w.mu.Unlock()
175		return
176	}
177	w.closed = true
178	close(w.queue)
179	w.mu.Unlock()
180	w.wg.Wait()
181}
182
183type accessibilityCapture struct {
184	mu         sync.Mutex
185	dir        string
186	seq        uint64
187	fileCount  int
188	totalBytes int64
189	maxFiles   int
190	maxBytes   int64
191}
192
193func accessibilityCaptureEnabled() bool {
194	return os.Getenv(accessibilityCaptureEnvName) == accessibilityCaptureEnvValue
195}
196
197func accessibilityCaptureDir(webViewID port.WebViewID) (string, error) {
198	logDir, err := config.GetLogDir()
199	if err != nil {
200		return "", err
201	}
202	return filepath.Join(logDir, "a11y-capture", fmt.Sprintf("webview-%d", webViewID)), nil
203}
204
205func validateAccessibilityCaptureDir(dir string) error {
206	if dir == "" || dir == "." {
207		return fmt.Errorf("accessibility capture dir rejected: %q", dir)
208	}
209	for _, part := range strings.Split(filepath.ToSlash(dir), "/") {
210		if part == ".." {
211			return fmt.Errorf("accessibility capture dir rejected: %q", dir)
212		}
213	}
214	return nil
215}
216
217// parseAccessibilityCaptureName accepts only strict numbered capture names
218// produced by Write: NNNNNN-(tree|location).json with a non-zero sequence.
219func parseAccessibilityCaptureName(name string) (seq uint64, ok bool) {
220	if !strings.HasSuffix(name, ".json") {
221		return 0, false
222	}
223	base := strings.TrimSuffix(name, ".json")
224	seqPart, kind, cut := strings.Cut(base, "-")
225	if !cut || len(seqPart) != 6 || !accessibilityCaptureKindAllowed(kind) {
226		return 0, false
227	}
228	for _, r := range seqPart {
229		if r < '0' || r > '9' {
230			return 0, false
231		}
232	}
233	n, err := strconv.ParseUint(seqPart, 10, 64)
234	if err != nil || n == 0 {
235		return 0, false
236	}
237	return n, true
238}
239
240// scanAccessibilityCaptureDir accounts only strict regular capture files.
241// Symlinks and non-regular entries are ignored (never followed). Gaps are
242// allowed; the returned sequence is the maximum numbered name present.
243func scanAccessibilityCaptureDir(dir string) (maxSeq uint64, fileCount int, totalBytes int64, err error) {
244	entries, err := os.ReadDir(dir)
245	if err != nil {
246		return 0, 0, 0, fmt.Errorf("read accessibility capture dir: %w", err)
247	}
248	for _, entry := range entries {
249		name := entry.Name()
250		seq, ok := parseAccessibilityCaptureName(name)
251		if !ok {
252			continue
253		}
254		path := filepath.Join(dir, name)
255		info, lstatErr := os.Lstat(path)
256		if lstatErr != nil {
257			return 0, 0, 0, fmt.Errorf("lstat accessibility capture %s: %w", name, lstatErr)
258		}
259		if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
260			continue
261		}
262		fileCount++
263		totalBytes += info.Size()
264		if seq > maxSeq {
265			maxSeq = seq
266		}
267	}
268	return maxSeq, fileCount, totalBytes, nil
269}
270
271func newAccessibilityCapture(dir string) (*accessibilityCapture, error) {
272	if err := validateAccessibilityCaptureDir(dir); err != nil {
273		return nil, err
274	}
275	if err := os.MkdirAll(dir, accessibilityCaptureDirPerm); err != nil {
276		return nil, fmt.Errorf("mkdir accessibility capture dir: %w", err)
277	}
278	if err := os.Chmod(dir, accessibilityCaptureDirPerm); err != nil {
279		return nil, fmt.Errorf("chmod accessibility capture dir: %w", err)
280	}
281	maxSeq, fileCount, totalBytes, err := scanAccessibilityCaptureDir(dir)
282	if err != nil {
283		return nil, err
284	}
285	return &accessibilityCapture{
286		dir:        dir,
287		seq:        maxSeq,
288		fileCount:  fileCount,
289		totalBytes: totalBytes,
290		maxFiles:   accessibilityCaptureDefaultMaxFiles,
291		maxBytes:   accessibilityCaptureDefaultMaxBytes,
292	}, nil
293}
294
295func accessibilityCaptureKindAllowed(kind string) bool {
296	switch kind {
297	case accessibilityCaptureKindTree, accessibilityCaptureKindLocation:
298		return true
299	default:
300		return false
301	}
302}
303
304func (c *accessibilityCapture) Write(p accessibilityPayload) error {
305	if c == nil || p.JSON == "" {
306		return nil
307	}
308	if !accessibilityCaptureKindAllowed(p.Kind) {
309		return fmt.Errorf("accessibility capture kind rejected: %q", p.Kind)
310	}
311	payloadBytes := int64(len(p.JSON))
312	c.mu.Lock()
313	defer c.mu.Unlock()
314	if c.fileCount >= c.maxFiles {
315		return fmt.Errorf("accessibility capture file budget exceeded (%d files)", c.maxFiles)
316	}
317	if c.totalBytes+payloadBytes > c.maxBytes {
318		return fmt.Errorf("accessibility capture byte budget exceeded (%d bytes)", c.maxBytes)
319	}
320	next := c.seq + 1
321	name := fmt.Sprintf("%06d-%s.json", next, p.Kind)
322	path := filepath.Join(c.dir, name)
323	file, err := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, accessibilityCaptureFilePerm)
324	if err != nil {
325		return err
326	}
327	c.seq = next
328	c.fileCount++
329	written, writeErr := file.WriteString(p.JSON)
330	c.totalBytes += int64(written)
331	chmodErr := file.Chmod(accessibilityCaptureFilePerm)
332	closeErr := file.Close()
333	if writeErr != nil {
334		return writeErr
335	}
336	if chmodErr != nil {
337		return chmodErr
338	}
339	return closeErr
340}
341
342func (wv *WebView) enableAccessibilityCaptureIfRequested() error {
343	if wv == nil || !accessibilityCaptureEnabled() {
344		return nil
345	}
346	dir, err := accessibilityCaptureDir(wv.id)
347	if err != nil {
348		return err
349	}
350	capture, err := newAccessibilityCapture(dir)
351	if err != nil {
352		return err
353	}
354	wv.a11yCapture = capture
355	wv.a11yWorker = newAccessibilityCaptureWorker(accessibilityCaptureQueueCapacity, wv.consumeAccessibilityPayload)
356	logAccessibilityCaptureEnabled(wv.ctx, wv.id, dir)
357	return nil
358}
359
360func (wv *WebView) consumeAccessibilityPayload(p accessibilityPayload) {
361	if wv == nil {
362		return
363	}
364	wv.a11yStats.Observe(p)
365	logAccessibilityUpdate(wv.ctx, wv.id, p)
366	if wv.a11yCapture == nil {
367		return
368	}
369	if err := wv.a11yCapture.Write(p); err != nil {
370		logging.FromContext(wv.ctx).Warn().Err(err).
371			Uint64("webview_id", uint64(wv.id)).
372			Msg("cef: accessibility capture write failed")
373	}
374}
375
376// abortAccessibilityCaptureSetup closes a capture worker started during
377// newWebView without emitting Destroy's summary. Safe to call when unset.
378func (wv *WebView) abortAccessibilityCaptureSetup() {
379	wv.finalizeAccessibilityCapture(false)
380}
381
382// failNewWebViewAfterAccessibilityCapture centralizes cleanup for every
383// newWebView error return after enableAccessibilityCaptureIfRequested may have
384// started the worker. Closes the worker exactly once without Destroy summary.
385func (wv *WebView) failNewWebViewAfterAccessibilityCapture(
386	operation string,
387	result syncdispatch.SyncDispatchResult,
388) error {
389	wv.abortAccessibilityCaptureSetup()
390	wv.destroyViewBridgeOnGTKAsync()
391	return errGTKSyncDispatchIncomplete(operation, result)
392}
393
394// newWebViewInstallViewportSyncHooks is the viewport-hook install used by
395// newWebView. Tests replace it to force incomplete GTK dispatch after capture
396// init without a live display.
397var newWebViewInstallViewportSyncHooks = func(wv *WebView) syncdispatch.SyncDispatchResult {
398	return wv.installViewportSyncHooks()
399}
400
401// installNewWebViewViewportHooksAfterCapture installs viewport sync hooks and
402// aborts capture setup if GTK dispatch does not complete.
403func (wv *WebView) installNewWebViewViewportHooksAfterCapture() error {
404	result := newWebViewInstallViewportSyncHooks(wv)
405	if result.Completed() {
406		return nil
407	}
408	return wv.failNewWebViewAfterAccessibilityCapture("install CEF viewport sync hooks", result)
409}
410
411func (wv *WebView) shutdownAccessibilityCapture() {
412	wv.finalizeAccessibilityCapture(true)
413}
414
415// finalizeAccessibilityCapture claims one-shot ownership of worker close.
416// Pointers stay published so concurrent enqueueAccessibilityPayload reads are
417// race-free; Submit observes the closed worker and returns false.
418func (wv *WebView) finalizeAccessibilityCapture(emitSummary bool) {
419	if wv == nil {
420		return
421	}
422	if !wv.a11yCaptureFinalized.CompareAndSwap(false, true) {
423		return
424	}
425	worker := wv.a11yWorker
426	if worker == nil {
427		return
428	}
429	worker.Close()
430	if emitSummary {
431		logAccessibilitySummary(wv.ctx, wv.id, wv.a11yStats.Snapshot(), worker.Dropped())
432	}
433}

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