watch.go RawHistoryFolder

1package config
2
3import (
4	"context"
5	"errors"
6	"os"
7	"path/filepath"
8	"slices"
9	"sort"
10	"time"
11	"unsafe"
12
13	"github.com/bnema/neferwl/internal/ports"
14	"github.com/bnema/zerowrap"
15	"golang.org/x/sys/unix"
16)
17
18var directoryPollInterval = 2 * time.Second
19
20func loadFile(path string) (ports.Config, map[string]string, []Warning, error) {
21	f, err := os.Open(path)
22	if err != nil {
23		return ports.Config{}, nil, nil, err
24	}
25	defer f.Close()
26	return parse(f)
27}
28
29// loadRaw returns the effective keys of the file at startup; missing means none.
30func loadRaw(path string) map[string]string {
31	_, raw, _, err := loadFile(path)
32	if err != nil {
33		return map[string]string{}
34	}
35	return raw
36}
37
38// diff lists keys added, removed or changed, sorted.
39func diff(old, cur map[string]string) []string {
40	var keys []string
41	for k, v := range cur {
42		if o, ok := old[k]; !ok || o != v {
43			keys = append(keys, k)
44		}
45	}
46	for k := range old {
47		if _, ok := cur[k]; !ok {
48			keys = append(keys, k)
49		}
50	}
51	sort.Strings(keys)
52	return keys
53}
54
55// watchedFiles are the files whose changes reload the config: path, then
56// each symlink it goes through (dotfiles) down to the final file, which may
57// not exist yet. Symlinked parent directories are followed by inotify
58// itself; retargeting one is not detected.
59func watchedFiles(path string) []string {
60	files := []string{filepath.Clean(path)}
61	for len(files) <= 40 { // the kernel's symlink limit
62		cur := files[len(files)-1]
63		target, err := os.Readlink(cur)
64		if err != nil {
65			break
66		}
67		if !filepath.IsAbs(target) {
68			target = filepath.Join(filepath.Dir(cur), target)
69		}
70		target = filepath.Clean(target)
71		if slices.Contains(files, target) {
72			break // a loop: the config is unreadable anyway
73		}
74		files = append(files, target)
75	}
76	return files
77}
78
79// Watch observes the parent directory so atomic file replacements are detected.
80// A symlinked config is also watched at its target, and a retargeted link
81// moves that watch. If the directory is absent, it retries every two seconds
82// until it appears.
83func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, log zerowrap.Logger) error {
84	fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
85	if err != nil {
86		return err
87	}
88	defer unix.Close(fd)
89	// IN_MODIFY restarts the debounce on every write, the truncation of an
90	// in-place save included: the file is never read half written.
91	const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM
92	// names maps each watch to the file names that matter in its directory.
93	names := map[int32]map[string]bool{}
94	var files []string
95	rewatch := true
96	// retry sets the watches up again while a directory is missing.
97	var retry time.Time
98	var pending time.Time
99	last := loadRaw(path)
100	buf := make([]byte, 4096)
101	for {
102		if ctx.Err() != nil {
103			return nil
104		}
105		if !retry.IsZero() && !time.Now().Before(retry) {
106			rewatch = true
107		}
108		if rewatch {
109			rewatch, retry = false, time.Time{}
110			for wd := range names {
111				_, _ = unix.InotifyRmWatch(fd, uint32(wd))
112			}
113			clear(names)
114			files = watchedFiles(path)
115			for _, file := range files {
116				wd, err := unix.InotifyAddWatch(fd, filepath.Dir(file), mask)
117				if errors.Is(err, unix.ENOENT) {
118					retry = time.Now().Add(directoryPollInterval)
119					continue
120				}
121				if err != nil {
122					return err
123				}
124				if names[int32(wd)] == nil {
125					names[int32(wd)] = map[string]bool{}
126				}
127				names[int32(wd)][filepath.Base(file)] = true
128			}
129		}
130		timeout := 200
131		if !retry.IsZero() {
132			timeout = min(timeout, int(time.Until(retry).Milliseconds())+1)
133		}
134		if !pending.IsZero() {
135			remaining := time.Until(pending)
136			if remaining <= 0 {
137				pending = time.Time{}
138				cfg, raw, warnings, loadErr := loadFile(path)
139				switch {
140				case os.IsNotExist(loadErr):
141					log.Warn().Msg("config file removed; keeping current config")
142				case loadErr != nil:
143					log.Warn().Err(loadErr).Msg("config reload failed")
144				default:
145					for _, w := range warnings {
146						log.Warn().Int("line", w.Line).Msg(w.Msg)
147					}
148					changed := diff(last, raw)
149					last = raw
150					if len(changed) == 0 {
151						log.Debug().Msg("config unchanged")
152						continue
153					}
154					select {
155					case <-ctx.Done():
156						return nil
157					case out <- ports.ConfigChanged{Config: cfg}:
158					}
159					log.Info().Strs("changed", changed).Msg("config reloaded")
160				}
161				continue
162			}
163			timeout = min(timeout, int(remaining.Milliseconds())+1)
164		}
165		fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
166		_, err = unix.Poll(fds, timeout)
167		if err == unix.EINTR {
168			continue
169		}
170		if err != nil {
171			return err
172		}
173		if fds[0].Revents&unix.POLLIN == 0 {
174			continue
175		}
176		for {
177			n, readErr := unix.Read(fd, buf)
178			if readErr == unix.EAGAIN {
179				break
180			}
181			if readErr == unix.EINTR {
182				continue
183			}
184			if readErr != nil {
185				return readErr
186			}
187			if n == 0 {
188				break
189			}
190			for offset := 0; offset+unix.SizeofInotifyEvent <= n; {
191				event := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset]))
192				size := unix.SizeofInotifyEvent + int(event.Len)
193				if offset+size > n {
194					break
195				}
196				// A removed watch of ours (its directory is gone) is set up
197				// again; removals we asked for are no longer in names.
198				if event.Mask&unix.IN_IGNORED != 0 && names[event.Wd] != nil {
199					rewatch = true
200				}
201				if event.Len > 0 {
202					file := buf[offset+unix.SizeofInotifyEvent : offset+size]
203					for len(file) > 0 && file[len(file)-1] == 0 {
204						file = file[:len(file)-1]
205					}
206					if names[event.Wd][string(file)] && event.Mask&mask != 0 {
207						pending = time.Now().Add(100 * time.Millisecond)
208						// The link may point elsewhere now.
209						if !slices.Equal(files, watchedFiles(path)) {
210							rewatch = true
211						}
212					}
213				}
214				offset += size
215			}
216		}
217	}
218}

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 25

Tags 10

↑↓ moveenter openesc close