diff --git a/doc/architecture.md b/doc/architecture.md index 40999d4..447c4e2 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -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`) diff --git a/internal/editor/dirwatcher.go b/internal/editor/dirwatcher.go new file mode 100644 index 0000000..d96646e --- /dev/null +++ b/internal/editor/dirwatcher.go @@ -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 + } + } +} diff --git a/internal/editor/logic.go b/internal/editor/logic.go index 186708f..fd7e6da 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -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: