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
`Interaction` handlers that send channel requests (tap) or mutate state
directly when the handler runs on the owner (scroll).
- **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,
removed, renamed, or mtime-changed (including inbound syncs) — appear live,
re-sorted in the *current* sort mode. Invariant: **a refresh that finds the
directory unchanged rebuilds nothing and emits no frame**, so the
event-driven emission contract (§2; `TestNoFramesWhileIdle_Browser`) still
holds while the browser idles. A landing (re)index is applied in place: the
visible+prefetch pages are repopulated synchronously from the fresh index
(no async gap / flicker), the scroll offset is preserved then clamped, and an
active search is re-filtered without a scroll jump. Bounded to **one read per
directory**: a refresh 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 tick retries); a failed
*navigation* still recovers to the previous directory. Returning to the
browser from the editor triggers one immediate refresh so the list is current
on the frame you land back, not one tick later.
- **Live refresh.** While the browser page is on screen the current directory
is watched with **inotify** (`dirWatcher`, `internal/editor/dirwatcher.go`):
a create / delete / rename / modify / attrib event there — including an
inbound sync that changes a file's mtime — re-reads the directory within tens
of ms, re-sorted in the *current* sort mode. A slow **backstop rescan** (30 s)
covers the rare event the FUSE inotify layer drops; it is the only remaining
periodic component and, by the invariant below, emits no frame when nothing
changed. The watch is re-pointed whenever the directory changes (a no-op while
the path is unchanged) and is closed when the logic goroutine exits. Invariant:
**a re-read that finds the directory unchanged rebuilds nothing and emits no
frame**, so the event-driven emission contract (§2;
`TestNoFramesWhileIdle_Browser`) still holds while the browser idles. A
landing (re)index is applied in place: the visible+prefetch pages are
repopulated synchronously from the fresh index (no async gap / flicker), the
scroll offset is preserved then clamped, and an active search is re-filtered
without a scroll jump. Bounded to **one read per directory**: a re-read
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`)

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{}
exitWg sync.WaitGroup
saveTimer *time.Timer // auto-save debounce timer; non-nil while pending
// browserRefresh* keep the visible browser directory fresh while the
// browser page is on screen (external adds/removes/renames/mtime changes,
// incl. inbound syncs). The timer goroutine only sends a token; the owner
// does the re-read. All touched only on the owner goroutine.
browserRefreshChan chan struct{} // periodic browser re-read (timer -> owner)
browserRefreshTimer *time.Timer // browser refresh 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
// Browser live-list (see dirwatcher.go). The primary trigger is inotify:
// browserWatcher watches the current browser directory and sends a token on
// browserEventChan when it changes (file created/removed/renamed or
// mtime-changed, incl. inbound syncs). browserRefreshTimer is a slow
// safety-net rescan that covers the rare event the FUSE inotify layer drops.
// Both are owner-goroutine-only and funnel into refreshBrowserDir.
browserEventChan chan struct{} // inotify: dirWatcher -> owner (buffered 1)
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
// 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)
TheLogic = &Logic{
state: state,
browserManager: bm,
configChan: make(chan ConfigUpdate),
frameChan: make(chan Frame, 1),
inputChan: make(chan []ui.InputEvent),
layoutChan: make(chan ui.LayoutFeedback),
resultChan: make(chan ResultEvent),
searchQueryChan: make(chan string),
findQueryChan: make(chan string),
openFileChan: make(chan string),
retryChan: make(chan string, 1), // Buffered channel
autosaveChan: make(chan struct{}),
state: state,
browserManager: bm,
configChan: make(chan ConfigUpdate),
frameChan: make(chan Frame, 1),
inputChan: make(chan []ui.InputEvent),
layoutChan: make(chan ui.LayoutFeedback),
resultChan: make(chan ResultEvent),
searchQueryChan: make(chan string),
findQueryChan: make(chan string),
openFileChan: make(chan string),
retryChan: make(chan string, 1), // Buffered channel
autosaveChan: make(chan struct{}),
browserEventChan: make(chan struct{}, 1),
browserRefreshChan: make(chan struct{}),
flushSession: make(chan struct{}, 1),
inspectChan: make(chan *inspectReq),
writeInFlight: make(map[string]int),
savePending: make(map[string]bool),
retryScheduled: make(map[string]time.Time),
workerPool: wp,
mockFS: mockFS,
done: make(chan struct{}),
flushSession: make(chan struct{}, 1),
inspectChan: make(chan *inspectReq),
writeInFlight: make(map[string]int),
savePending: make(map[string]bool),
retryScheduled: make(map[string]time.Time),
workerPool: wp,
mockFS: mockFS,
done: make(chan struct{}),
}
return TheLogic
}
@ -266,6 +271,16 @@ var TheLogic *Logic
func (l *Logic) Run() {
l.exitWg.Add(1)
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
// 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.
l.saveTimer = nil
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:
// Periodic browser-refresh tick. The timer goroutine only sent a
// token; the owner re-reads the current directory (bounded) and
// re-arms. No-op while the editor is on screen.
l.doBrowserRefresh()
// Slow backstop rescan tick (safety net for a dropped inotify event).
// The timer goroutine only sent a token; the owner re-reads the current
// directory (bounded) and re-arms. No-op while the editor is on screen.
l.browserRefreshTimer = nil
l.refreshBrowserDir()
l.armBrowserRefresh()
case p := <-l.state.pasteChan:
// Clipboard content arrived from the main goroutine: insert it
// (replacing any live selection, per the selection-aware edit rule).
@ -427,10 +449,15 @@ func (l *Logic) emitFrame() {
l.saveSessionIfChanged()
// While the browser is on screen, keep its directory fresh so external
// adds/removes/renames/mtime changes (incl. inbound syncs) appear live, in
// the current sort order. Arming here covers every entry onto the browser
// page (startup, back from the editor, sort toggle) and re-arms after each
// refresh tick; the timer goroutine only sends a token.
// the current sort order. Point the inotify watch at the current directory
// (created on first use; re-pointed on navigation, a no-op while the path is
// 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.browserWatcher == nil {
l.browserWatcher = newDirWatcher(l.browserEventChan)
}
l.browserWatcher.SetDir(l.state.Browser.CurrentPath)
l.armBrowserRefresh()
}
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
// while the browser page is on screen, so external changes (files added,
// removed, renamed, or mtime-changed — including inbound syncs) appear live, in
// the current sort order. One directory read per interval is a single ReadDir
// plus a per-entry stat; the manager's in-flight marker keeps it to one read at
// a time, so a slow read simply coalesces the next tick.
const browserRefreshInterval = time.Second
// browserBackstopInterval is how often the visible browser directory is
// re-read as a SLOW safety net while the browser page is on screen. The primary
// trigger is inotify (browserWatcher, see dirwatcher.go), which re-reads within
// ~tens of ms of a real change; this rescan only covers the rare event the FUSE
// inotify layer drops. One directory read per interval is a single ReadDir plus
// a per-entry stat; the manager's in-flight marker keeps it to one read at a
// 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:
// re-read the current directory (bounded to one in flight) and re-arm. It is a
// no-op while the editor is on screen — there is no list to keep fresh there.
func (l *Logic) doBrowserRefresh() {
l.browserRefreshTimer = nil
// refreshBrowserDir re-reads the current browser directory (bounded to one in
// flight by the manager). Triggered by either an inotify event
// (browserEventChan) or the slow backstop tick (browserRefreshChan). No-op
// while the editor is on screen — there is no list to keep fresh there.
func (l *Logic) refreshBrowserDir() {
if l.state.page != BrowserPage {
return
}
l.browserManager.Refresh(l.state.Browser.CurrentPath)
l.armBrowserRefresh()
}
// armBrowserRefresh starts (or leaves running) the periodic browser-refresh
// timer. Idempotent: at most one timer at a time. The timer goroutine only
// sends a token on browserRefreshChan; the owner does the re-read. The send is
// armBrowserRefresh starts (or leaves running) the slow backstop rescan timer.
// Idempotent: at most one timer at a time. The timer goroutine only sends a
// 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
// loop has exited (the token is then simply dropped). Owner-goroutine-only.
func (l *Logic) armBrowserRefresh() {
if l.browserRefreshTimer != nil {
return
}
l.browserRefreshTimer = time.AfterFunc(browserRefreshInterval, func() {
l.browserRefreshTimer = time.AfterFunc(browserBackstopInterval, func() {
select {
case l.browserRefreshChan <- struct{}{}:
default: