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:
parent
16740b0de0
commit
6d3e070c2d
|
|
@ -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`)
|
||||||
|
|
||||||
|
|
|
||||||
98
internal/editor/dirwatcher.go
Normal file
98
internal/editor/dirwatcher.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -93,12 +93,16 @@ 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.
|
||||||
|
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)
|
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
|
debugCmdC chan string // one-shot debug commands from the cmd-file poller; nil = disabled
|
||||||
|
|
||||||
|
|
@ -189,6 +193,7 @@ func NewLogic(mfs pool.FileSystem, path string, openfunc func(string)) *Logic {
|
||||||
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),
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user