Pad/internal/editor/dirwatcher.go
Greg Pomerantz 6d3e070c2d Browser: drive the live list off inotify, not a 1s poll
Replace the 1 s polling re-read of the browser directory with an inotify
watch (dirWatcher, internal/editor/dirwatcher.go) using golang.org/x/sys/unix,
which is already in the build list — no new dependency, no go.mod change.

- The watcher watches the current browser directory and coalesces a burst of
  events to at most one token; the owner re-reads on the token. Create, delete,
  rename, modify, and attrib (mtime) changes are all covered, so an inbound
  sync that touches a file's mtime re-sorts the list in the current sort mode.
- The watch is re-pointed when the directory changes (a no-op while the path is
  unchanged) and is closed when the logic goroutine exits (covers both Shutdown
  and the test harness Done+WaitForExit path).
- A slow 30 s backstop rescan remains as a safety net for the rare event the
  FUSE inotify layer drops (syncthing uses the same inotify+rescan pattern on
  Android). The change-detection from the previous commit means the backstop
  emits no frame when nothing changed, so TestNoFramesWhileIdle_Browser still
  holds.
- If inotify is unavailable (newDirWatcher returns nil) or a watch add fails,
  the 30 s backstop is the only path — a graceful degradation, not a break.

Verified on-device (Notes dir, Date-descending): an external add appears at the
top in well under a second (impossibly fast for the 30 s backstop, so the
inotify path); touching a file's mtime re-sorts it to the top; a delete drops
the row. All sub-second.
2026-09-02 19:21:19 -04:00

99 lines
3.4 KiB
Go

package editor
import (
"sync"
"golang.org/x/sys/unix"
)
// browserWatchMask covers the changes the browser list cares about: a file or
// subdirectory appearing (CREATE/MOVED_TO), disappearing (DELETE/MOVED_FROM),
// being rewritten (MODIFY), or having its mtime/attributes change (ATTRIB —
// this is the signal for an inbound sync that updates a file in place). Any
// event in this set is enough to justify a full re-read of the directory.
const browserWatchMask = unix.IN_CREATE | unix.IN_DELETE |
unix.IN_MOVED_TO | unix.IN_MOVED_FROM |
unix.IN_MODIFY | unix.IN_ATTRIB
// dirWatcher watches a single directory for filesystem changes via inotify and
// reports them as coarse "something changed" tokens on its event channel. It
// backs the browser's live list: while the browser page is on screen the
// current directory is watched, and any event there triggers a re-read (see
// Logic.refreshBrowserDir).
//
// Ownership: only the owner goroutine calls SetDir and Close. The run goroutine
// only reads the inotify fd and sends on event. The inotify fd is the single
// synchronisation point between them: Close() closes the fd, which unblocks
// run()'s blocking Read so the goroutine exits promptly. curDir/curWd are
// therefore owner-only (no locking needed).
type dirWatcher struct {
event chan struct{}
fd int
curDir string // directory currently watched (owner-only)
curWd uint32 // its inotify watch descriptor, for removal on re-point
closeOnce sync.Once
}
// newDirWatcher initialises an inotify instance and starts the run goroutine
// (no directory is watched yet; call SetDir to begin). It returns nil if
// inotify is unavailable, in which case the caller's slow backstop rescan is
// the only refresh path. The returned watcher must be Closed on shutdown.
func newDirWatcher(event chan struct{}) *dirWatcher {
fd, err := unix.InotifyInit1(0)
if err != nil {
return nil
}
w := &dirWatcher{event: event, fd: fd}
go w.run()
return w
}
// SetDir (re)points the watch at dir, removing the previous watch. It is
// best-effort: a failure leaves the prior watch in place and is covered by the
// slow backstop rescan. Owner-goroutine-only.
func (w *dirWatcher) SetDir(dir string) {
if w == nil || dir == "" || dir == w.curDir {
return
}
if w.curWd > 0 {
unix.InotifyRmWatch(w.fd, w.curWd) // best-effort
w.curWd = 0
}
wd, err := unix.InotifyAddWatch(w.fd, dir, browserWatchMask)
if err != nil {
return
}
w.curDir, w.curWd = dir, uint32(wd)
}
// Close stops the watcher: closing the inotify fd unblocks run()'s Read (EBADF)
// so the goroutine exits. Safe to call multiple times. Owner-goroutine-only.
func (w *dirWatcher) Close() {
if w == nil {
return
}
w.closeOnce.Do(func() { unix.Close(w.fd) })
}
// run blocks reading inotify events for the life of the fd. It does not parse
// events: any non-empty read means "the watched directory changed", which is all
// the browser needs (it re-reads the whole directory and re-sorts). The send is
// non-blocking into a buffer-1 channel, so a burst of events coalesces to at
// most one pending token — the manager's in-flight marker then bounds the
// actual re-reads.
func (w *dirWatcher) run() {
buf := make([]byte, 16*1024)
for {
n, err := unix.Read(w.fd, buf)
if n > 0 {
select {
case w.event <- struct{}{}:
default:
}
}
if err != nil {
return // fd closed by Close() (EBADF) or an inotify error
}
}
}