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.
This commit is contained in:
Greg Pomerantz 2026-09-02 19:21:19 -04:00
parent 16740b0de0
commit 6d3e070c2d
3 changed files with 200 additions and 69 deletions

View File

@ -544,23 +544,29 @@ Only the visible byte range is shaped and drawn each frame:
- The browser renders via `ListView`/`ListItem` elements; rows carry - The browser renders via `ListView`/`ListItem` elements; rows carry
`Interaction` handlers that send channel requests (tap) or mutate state `Interaction` handlers that send channel requests (tap) or mutate state
directly when the handler runs on the owner (scroll). directly when the handler runs on the owner (scroll).
- **Live refresh.** While the browser page is on screen, the current directory - **Live refresh.** While the browser page is on screen the current directory
is re-read on a short timer (≈1 s) so external changes — files added, is watched with **inotify** (`dirWatcher`, `internal/editor/dirwatcher.go`):
removed, renamed, or mtime-changed (including inbound syncs) — appear live, a create / delete / rename / modify / attrib event there — including an
re-sorted in the *current* sort mode. Invariant: **a refresh that finds the inbound sync that changes a file's mtime — re-reads the directory within tens
directory unchanged rebuilds nothing and emits no frame**, so the of ms, re-sorted in the *current* sort mode. A slow **backstop rescan** (30 s)
event-driven emission contract (§2; `TestNoFramesWhileIdle_Browser`) still covers the rare event the FUSE inotify layer drops; it is the only remaining
holds while the browser idles. A landing (re)index is applied in place: the periodic component and, by the invariant below, emits no frame when nothing
visible+prefetch pages are repopulated synchronously from the fresh index changed. The watch is re-pointed whenever the directory changes (a no-op while
(no async gap / flicker), the scroll offset is preserved then clamped, and an the path is unchanged) and is closed when the logic goroutine exits. Invariant:
active search is re-filtered without a scroll jump. Bounded to **one read per **a re-read that finds the directory unchanged rebuilds nothing and emits no
directory**: a refresh requested while a read of the same directory is in frame**, so the event-driven emission contract (§2;
flight coalesces (skips), and a stale result for a directory the user has `TestNoFramesWhileIdle_Browser`) still holds while the browser idles. A
since left is dropped rather than clobbering the current view. A failed landing (re)index is applied in place: the visible+prefetch pages are
*refresh* keeps the last good view (the next tick retries); a failed repopulated synchronously from the fresh index (no async gap / flicker), the
*navigation* still recovers to the previous directory. Returning to the scroll offset is preserved then clamped, and an active search is re-filtered
browser from the editor triggers one immediate refresh so the list is current without a scroll jump. Bounded to **one read per directory**: a re-read
on the frame you land back, not one tick later. requested while a read of the same directory is in flight coalesces (skips),
and a stale result for a directory the user has since left is dropped rather
than clobbering the current view. A failed *refresh* keeps the last good view
(the next event or backstop tick retries); a failed *navigation* still recovers
to the previous directory. Returning to the browser from the editor triggers
one immediate re-read so the list is current on the frame you land back (events
that fired while the editor was on screen are no-ops there).
## 8. Render pipeline (`internal/ui`) ## 8. Render pipeline (`internal/ui`)

View File

@ -0,0 +1,98 @@
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
}
}
}

View File

@ -93,14 +93,18 @@ type Logic struct {
done chan struct{} done chan struct{}
exitWg sync.WaitGroup exitWg sync.WaitGroup
saveTimer *time.Timer // auto-save debounce timer; non-nil while pending saveTimer *time.Timer // auto-save debounce timer; non-nil while pending
// browserRefresh* keep the visible browser directory fresh while the // Browser live-list (see dirwatcher.go). The primary trigger is inotify:
// browser page is on screen (external adds/removes/renames/mtime changes, // browserWatcher watches the current browser directory and sends a token on
// incl. inbound syncs). The timer goroutine only sends a token; the owner // browserEventChan when it changes (file created/removed/renamed or
// does the re-read. All touched only on the owner goroutine. // mtime-changed, incl. inbound syncs). browserRefreshTimer is a slow
browserRefreshChan chan struct{} // periodic browser re-read (timer -> owner) // safety-net rescan that covers the rare event the FUSE inotify layer drops.
browserRefreshTimer *time.Timer // browser refresh timer; non-nil while pending // Both are owner-goroutine-only and funnel into refreshBrowserDir.
lastEmit time.Time // time of the last frame emission (profiler cadence) browserEventChan chan struct{} // inotify: dirWatcher -> owner (buffered 1)
debugCmdC chan string // one-shot debug commands from the cmd-file poller; nil = disabled browserWatcher *dirWatcher // inotify watch on the current browser dir
browserRefreshChan chan struct{} // backstop rescan tick (timer -> owner)
browserRefreshTimer *time.Timer // backstop rescan timer; non-nil while pending
lastEmit time.Time // time of the last frame emission (profiler cadence)
debugCmdC chan string // one-shot debug commands from the cmd-file poller; nil = disabled
// Relaunch state restoration (spec §7, see session.go): session is the // Relaunch state restoration (spec §7, see session.go): session is the
// snapshot handed in by the cmd layer via BeginRestore (zero = none), // snapshot handed in by the cmd layer via BeginRestore (zero = none),
@ -177,27 +181,28 @@ func NewLogic(mfs pool.FileSystem, path string, openfunc func(string)) *Logic {
bm, _ := browser.NewBrowserManager(&state.Browser, wp, mockFS) bm, _ := browser.NewBrowserManager(&state.Browser, wp, mockFS)
TheLogic = &Logic{ TheLogic = &Logic{
state: state, state: state,
browserManager: bm, browserManager: bm,
configChan: make(chan ConfigUpdate), configChan: make(chan ConfigUpdate),
frameChan: make(chan Frame, 1), frameChan: make(chan Frame, 1),
inputChan: make(chan []ui.InputEvent), inputChan: make(chan []ui.InputEvent),
layoutChan: make(chan ui.LayoutFeedback), layoutChan: make(chan ui.LayoutFeedback),
resultChan: make(chan ResultEvent), resultChan: make(chan ResultEvent),
searchQueryChan: make(chan string), searchQueryChan: make(chan string),
findQueryChan: make(chan string), findQueryChan: make(chan string),
openFileChan: make(chan string), openFileChan: make(chan string),
retryChan: make(chan string, 1), // Buffered channel retryChan: make(chan string, 1), // Buffered channel
autosaveChan: make(chan struct{}), autosaveChan: make(chan struct{}),
browserEventChan: make(chan struct{}, 1),
browserRefreshChan: make(chan struct{}), browserRefreshChan: make(chan struct{}),
flushSession: make(chan struct{}, 1), flushSession: make(chan struct{}, 1),
inspectChan: make(chan *inspectReq), inspectChan: make(chan *inspectReq),
writeInFlight: make(map[string]int), writeInFlight: make(map[string]int),
savePending: make(map[string]bool), savePending: make(map[string]bool),
retryScheduled: make(map[string]time.Time), retryScheduled: make(map[string]time.Time),
workerPool: wp, workerPool: wp,
mockFS: mockFS, mockFS: mockFS,
done: make(chan struct{}), done: make(chan struct{}),
} }
return TheLogic return TheLogic
} }
@ -266,6 +271,16 @@ var TheLogic *Logic
func (l *Logic) Run() { func (l *Logic) Run() {
l.exitWg.Add(1) l.exitWg.Add(1)
defer l.exitWg.Done() defer l.exitWg.Done()
// Close the inotify watcher on any exit. Run is the owner goroutine, so at
// return the watcher is single-threaded and safe to close here (covers both
// Shutdown and the test-harness Done+WaitForExit path). The closure reads
// browserWatcher at exit time, after emitFrame has created it if the browser
// was ever shown.
defer func() {
if l.browserWatcher != nil {
l.browserWatcher.Close()
}
}()
// Dispatch the initial directory index build on startup, routed through the // Dispatch the initial directory index build on startup, routed through the
// manager so the in-flight marker is set (a later refresh of the same // manager so the in-flight marker is set (a later refresh of the same
@ -371,11 +386,18 @@ func (l *Logic) Run() {
// the owner snapshots content and dispatches the write. // the owner snapshots content and dispatches the write.
l.saveTimer = nil l.saveTimer = nil
l.requestSave(l.state.Editor.Filename) l.requestSave(l.state.Editor.Filename)
case <-l.browserEventChan:
// inotify: the current browser directory changed (an external create,
// delete, rename, or mtime change). Re-read it; no-op while the
// editor is on screen (the change is picked up on the next return).
l.refreshBrowserDir()
case <-l.browserRefreshChan: case <-l.browserRefreshChan:
// Periodic browser-refresh tick. The timer goroutine only sent a // Slow backstop rescan tick (safety net for a dropped inotify event).
// token; the owner re-reads the current directory (bounded) and // The timer goroutine only sent a token; the owner re-reads the current
// re-arms. No-op while the editor is on screen. // directory (bounded) and re-arms. No-op while the editor is on screen.
l.doBrowserRefresh() l.browserRefreshTimer = nil
l.refreshBrowserDir()
l.armBrowserRefresh()
case p := <-l.state.pasteChan: case p := <-l.state.pasteChan:
// Clipboard content arrived from the main goroutine: insert it // Clipboard content arrived from the main goroutine: insert it
// (replacing any live selection, per the selection-aware edit rule). // (replacing any live selection, per the selection-aware edit rule).
@ -427,10 +449,15 @@ func (l *Logic) emitFrame() {
l.saveSessionIfChanged() l.saveSessionIfChanged()
// While the browser is on screen, keep its directory fresh so external // While the browser is on screen, keep its directory fresh so external
// adds/removes/renames/mtime changes (incl. inbound syncs) appear live, in // adds/removes/renames/mtime changes (incl. inbound syncs) appear live, in
// the current sort order. Arming here covers every entry onto the browser // the current sort order. Point the inotify watch at the current directory
// page (startup, back from the editor, sort toggle) and re-arms after each // (created on first use; re-pointed on navigation, a no-op while the path is
// refresh tick; the timer goroutine only sends a token. // unchanged) and arm the slow backstop rescan. Runs on every browser frame,
// covering startup, back-from-editor, and sort toggle.
if l.state.page == BrowserPage { if l.state.page == BrowserPage {
if l.browserWatcher == nil {
l.browserWatcher = newDirWatcher(l.browserEventChan)
}
l.browserWatcher.SetDir(l.state.Browser.CurrentPath)
l.armBrowserRefresh() l.armBrowserRefresh()
} }
elems := l.state.layout(l.browserManager) elems := l.state.layout(l.browserManager)
@ -677,36 +704,36 @@ func (l *Logic) markDirty() {
}) })
} }
// browserRefreshInterval is how often the visible browser directory is re-read // browserBackstopInterval is how often the visible browser directory is
// while the browser page is on screen, so external changes (files added, // re-read as a SLOW safety net while the browser page is on screen. The primary
// removed, renamed, or mtime-changed — including inbound syncs) appear live, in // trigger is inotify (browserWatcher, see dirwatcher.go), which re-reads within
// the current sort order. One directory read per interval is a single ReadDir // ~tens of ms of a real change; this rescan only covers the rare event the FUSE
// plus a per-entry stat; the manager's in-flight marker keeps it to one read at // inotify layer drops. One directory read per interval is a single ReadDir plus
// a time, so a slow read simply coalesces the next tick. // a per-entry stat; the manager's in-flight marker keeps it to one read at a
const browserRefreshInterval = time.Second // time, and its change-detection emits no frame when nothing changed.
const browserBackstopInterval = 30 * time.Second
// doBrowserRefresh runs on the owner goroutine when the refresh timer fires: // refreshBrowserDir re-reads the current browser directory (bounded to one in
// re-read the current directory (bounded to one in flight) and re-arm. It is a // flight by the manager). Triggered by either an inotify event
// no-op while the editor is on screen — there is no list to keep fresh there. // (browserEventChan) or the slow backstop tick (browserRefreshChan). No-op
func (l *Logic) doBrowserRefresh() { // while the editor is on screen — there is no list to keep fresh there.
l.browserRefreshTimer = nil func (l *Logic) refreshBrowserDir() {
if l.state.page != BrowserPage { if l.state.page != BrowserPage {
return return
} }
l.browserManager.Refresh(l.state.Browser.CurrentPath) l.browserManager.Refresh(l.state.Browser.CurrentPath)
l.armBrowserRefresh()
} }
// armBrowserRefresh starts (or leaves running) the periodic browser-refresh // armBrowserRefresh starts (or leaves running) the slow backstop rescan timer.
// timer. Idempotent: at most one timer at a time. The timer goroutine only // Idempotent: at most one timer at a time. The timer goroutine only sends a
// sends a token on browserRefreshChan; the owner does the re-read. The send is // token on browserRefreshChan; the owner does the re-read. The send is
// non-blocking so the timer goroutine can never block, even after the logic // non-blocking so the timer goroutine can never block, even after the logic
// loop has exited (the token is then simply dropped). Owner-goroutine-only. // loop has exited (the token is then simply dropped). Owner-goroutine-only.
func (l *Logic) armBrowserRefresh() { func (l *Logic) armBrowserRefresh() {
if l.browserRefreshTimer != nil { if l.browserRefreshTimer != nil {
return return
} }
l.browserRefreshTimer = time.AfterFunc(browserRefreshInterval, func() { l.browserRefreshTimer = time.AfterFunc(browserBackstopInterval, func() {
select { select {
case l.browserRefreshChan <- struct{}{}: case l.browserRefreshChan <- struct{}{}:
default: default: