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
|
||||
`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`)
|
||||
|
||||
|
|
|
|||
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{}
|
||||
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
|
||||
// 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
|
||||
|
||||
|
|
@ -189,6 +193,7 @@ func NewLogic(mfs pool.FileSystem, path string, openfunc func(string)) *Logic {
|
|||
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),
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user