Files
kjol/tools/tsgo/internal/fswatch/inotify_linux.go
2026-07-09 16:50:43 -04:00

428 lines
14 KiB
Go

//go:build linux
package fswatch
import (
"errors"
"fmt"
"sync/atomic"
"unsafe"
"golang.org/x/sys/unix"
)
// ---------------------------------------------------------------------------
// inotify_linux.go: Linux inotify backend
//
// Uses the kernel's inotify(7) subsystem to watch directory trees. A single
// inotify instance serves all subscriptions for the process lifetime.
//
// ┌───────────────────────────────────────────────────────────┐
// │ inotifyBackend │
// │ │
// │ ┌───────────┐ poll(2) ┌─────────────────┐ │
// │ │ pipe[0] ├──────────────────────►│ │ │
// │ │ (wakeup) │ │ start() │ │
// │ └───────────┘ │ goroutine │ │
// │ ┌───────────┐ │ (event loop) │ │
// │ │ inotify ├──────────────────────►│ │ │
// │ │ fd │ └────────┬────────┘ │
// │ └───────────┘ │ │
// │ handleEvents() │
// │ │ │
// │ ▼ │
// │ ┌─────────────────────────┐ │
// │ │ subscriptions │ │
// │ │ map[wd] → []sub │ │
// │ │ sub.dirWatch.events │ │
// │ └─────────────────────────┘ │
// └───────────────────────────────────────────────────────────┘
//
// Goroutines and threading:
// - One long-lived goroutine (start), launched by watcherBase.run(). It
// owns the poll(2) loop and runs for the process lifetime. All event
// reading and dispatch (handleEvents, handleEvent, handleSubscription)
// execute on this goroutine, under b.mu.
// - subscribe/closeWatch run on the caller's goroutine under
// watcherBase.mu. The event loop acquires b.mu for watch map
// access, providing safe interleaving.
//
// Callback delivery:
// dirWatch.notify() posts to the shared process-wide debouncer. After a
// coalescing window (50 ms min / 500 ms max), the debouncer invokes all
// registered WatchCallbacks on its own dedicated goroutine; never on
// the caller's goroutine or the event-loop goroutine.
//
// WatchDirectory flow:
// 1. Walk the target directory (caller goroutine).
// 2. For every directory found, call inotify_add_watch to obtain a
// watch descriptor (wd). Map wd → inotifySubscription.
//
// Event dispatch (handleEvents → handleSubscription, on start goroutine):
// - IN_CREATE / IN_MOVED_TO → events.create (→ EventUpdate); if the new
// entry is a directory (IN_ISDIR), recursively walk and watch it.
// - IN_MODIFY → events.update.
// - IN_DELETE* / IN_MOVE* → events.remove; drop inotify subscriptions
// for the removed path and any descendants.
// - IN_Q_OVERFLOW → set ErrOverflow on every active dirWatch.
// After processing all buffered events, call dirWatch.notify() on each
// touched dirWatch to trigger the debouncer.
//
// Shutdown:
// Write a byte to pipe[1] → poll sees POLLIN on pipe[0] → loop exits →
// deferred closeFDs closes inotify fd, pipe fds, and signals endedSignal.
// ---------------------------------------------------------------------------
const (
inotifyMask = unix.IN_CREATE |
unix.IN_DELETE |
unix.IN_DELETE_SELF |
unix.IN_MODIFY |
unix.IN_MOVE_SELF |
unix.IN_MOVED_FROM |
unix.IN_MOVED_TO |
unix.IN_DONT_FOLLOW |
unix.IN_ONLYDIR |
unix.IN_EXCL_UNLINK
inotifyBufferSize = 8192
)
// inotifySubscription.
type inotifySubscription struct {
path string
watchPath string
dirWatch *dirWatch
wd int
}
// inotifyBackend.
type inotifyBackend struct {
watcherBase
pipeFDs [2]int
// pipeWriteFD shadows pipeFDs[1] as an atomic so shutdown (any goroutine)
// can safely race against the start goroutine's deferred closeFDs.
// Sentinel -1 once closed.
pipeWriteFD atomic.Int32
inotify int
subscriptions map[int][]*inotifySubscription // multimap<wd, sub>
endedSignal chan struct{}
// Persistent buffers reused across handleEvents calls. Only accessed
// from the start goroutine, so no synchronization needed.
readBuf []byte
watchersTouched map[*dirWatch]struct{}
}
func init() {
inotifyWatcher.factory = func() watcherImpl { return newInotifyBackend() }
}
func newInotifyBackend() *inotifyBackend {
b := &inotifyBackend{
pipeFDs: [2]int{-1, -1},
inotify: -1,
subscriptions: map[int][]*inotifySubscription{},
endedSignal: make(chan struct{}),
readBuf: make([]byte, inotifyBufferSize),
watchersTouched: make(map[*dirWatch]struct{}),
}
b.pipeWriteFD.Store(-1)
b.watcherBase.init(b)
return b
}
// start mirrors `inotifyBackend::start`.
func (b *inotifyBackend) start() error {
// Create a pipe so we can wake the poll(2) loop on shutdown.
if err := unix.Pipe2(b.pipeFDs[:], unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil {
return fmt.Errorf("unable to open pipe: %w", err)
}
b.pipeWriteFD.Store(int32(b.pipeFDs[1]))
defer func() {
b.closeFDs()
close(b.endedSignal)
}()
fd, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
if err != nil {
return fmt.Errorf("unable to initialize inotify: %w", err)
}
b.inotify = fd
pollfds := []unix.PollFd{
{Fd: int32(b.pipeFDs[0]), Events: unix.POLLIN},
{Fd: int32(b.inotify), Events: unix.POLLIN},
}
b.notifyStarted()
for {
_, err := unix.Poll(pollfds, 500)
if err != nil {
if errors.Is(err, unix.EINTR) {
continue
}
return fmt.Errorf("unable to poll: %w", err)
}
if pollfds[0].Revents != 0 {
break
}
if pollfds[1].Revents != 0 {
if err := b.handleEvents(); err != nil {
return err
}
}
}
return nil
}
// closeFDs runs in the start goroutine after the poll loop exits. Takes
// b.mu so the writes to b.inotify / b.pipeFDs synchronize-against the
// reads in closeWatch / subscribe (both of which run under b.mu).
func (b *inotifyBackend) closeFDs() {
b.mu.Lock()
defer b.mu.Unlock()
if b.pipeFDs[0] >= 0 {
_ = unix.Close(b.pipeFDs[0])
b.pipeFDs[0] = -1
}
if fd := b.pipeWriteFD.Swap(-1); fd >= 0 {
_ = unix.Close(int(fd))
}
b.pipeFDs[1] = -1
if b.inotify >= 0 {
_ = unix.Close(b.inotify)
b.inotify = -1
}
}
// shutdown is the equivalent of the destructor's pipe-write+wait.
// Called by removeSharedBackend when the last watch drops. Reads
// the pipe write fd via atomic so it's safe to race against the start
// goroutine's deferred closeFDs.
func (b *inotifyBackend) shutdown() {
fd := b.pipeWriteFD.Load()
if fd < 0 {
return
}
_, _ = unix.Write(int(fd), []byte{'X'})
<-b.endedSignal
}
// subscribe mirrors `inotifyBackend::subscribe`. Called via the watcherBase
// virtual dispatch under b.mu (so it's serialized against handleEvent).
func (b *inotifyBackend) subscribe(w *dirWatch) error {
if !w.recursive {
if _, err := b.watchDir(w, w.dir, w.physicalDir); err != nil {
return &dirWatchError{
err: fmt.Errorf("inotify_add_watch on '%s' failed: %w", w.dir, err),
dirWatch: w,
}
}
return nil
}
if err := walkDir(w.physicalDir, true, func(watchPath string, isDir bool) error {
if !isDir {
return nil
}
path := w.displayPath(watchPath)
if _, err := b.watchDir(w, path, watchPath); err != nil {
return &dirWatchError{
err: fmt.Errorf("inotify_add_watch on '%s' failed: %w", path, err),
dirWatch: w,
}
}
return nil
}); err != nil {
_ = b.closeWatch(w)
return err
}
return nil
}
// watchDir registers an inotify watch on path and records the resulting
// subscription. Returns the kernel watch descriptor on success.
func (b *inotifyBackend) watchDir(w *dirWatch, path string, watchPath string) (int, error) {
wd, err := unix.InotifyAddWatch(b.inotify, watchPath, inotifyMask)
if err != nil {
return 0, err
}
sub := &inotifySubscription{path: path, watchPath: watchPath, dirWatch: w, wd: wd}
b.subscriptions[wd] = append(b.subscriptions[wd], sub)
return wd, nil
}
// handleEvents mirrors `inotifyBackend::handleEvents`.
func (b *inotifyBackend) handleEvents() error {
buf := b.readBuf
watchersTouched := b.watchersTouched
for {
n, err := unix.Read(b.inotify, buf)
if err != nil {
if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) {
break
}
return fmt.Errorf("Error reading from inotify: %w", err)
}
if n == 0 {
break
}
// Walk the buffer.
for offset := 0; offset < n; {
ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[offset]))
recordSize := unix.SizeofInotifyEvent + int(ev.Len)
var name string
if ev.Len > 0 {
// Name is NUL-terminated; trim trailing zeros.
nameBytes := buf[offset+unix.SizeofInotifyEvent : offset+recordSize]
for i, c := range nameBytes {
if c == 0 {
nameBytes = nameBytes[:i]
break
}
}
name = string(nameBytes)
}
if ev.Mask&unix.IN_Q_OVERFLOW != 0 {
b.mu.Lock()
for _, subs := range b.subscriptions {
for _, sub := range subs {
sub.dirWatch.events.setError(ErrOverflow)
watchersTouched[sub.dirWatch] = struct{}{}
}
}
b.mu.Unlock()
offset += recordSize
continue
}
b.handleEvent(ev, name, watchersTouched)
offset += recordSize
}
}
for w := range watchersTouched {
w.notify()
}
clear(watchersTouched)
return nil
}
// handleEvent mirrors `inotifyBackend::handleEvent`.
func (b *inotifyBackend) handleEvent(ev *unix.InotifyEvent, name string, touched map[*dirWatch]struct{}) {
b.mu.Lock()
defer b.mu.Unlock()
// b.subscriptions[wd] holds at most one entry per *inotifySubscription
// pointer (watchDir always appends a fresh struct), so no dedup is
// necessary; the upstream C++ used an unordered_set keyed by
// shared_ptr identity but the equivalent Go invariant is structural.
for _, s := range b.subscriptions[int(ev.Wd)] {
if b.handleSubscription(ev, name, s) {
touched[s.dirWatch] = struct{}{}
}
}
}
// handleSubscription mirrors `inotifyBackend::handleSubscription`.
func (b *inotifyBackend) handleSubscription(ev *unix.InotifyEvent, name string, sub *inotifySubscription) bool {
w := sub.dirWatch
path := sub.path
watchPath := sub.watchPath
isDir := ev.Mask&unix.IN_ISDIR != 0
if name != "" {
path = path + "/" + name
watchPath = watchPath + "/" + name
}
switch {
case ev.Mask&(unix.IN_CREATE|unix.IN_MOVED_TO) != 0:
w.events.create(path)
if isDir && w.recursive {
_ = walkDir(watchPath, true, func(p string, pIsDir bool) error {
if !pIsDir {
return nil
}
_, _ = b.watchDir(w, w.displayPath(p), p)
return nil
})
}
case ev.Mask&unix.IN_MODIFY != 0:
w.events.update(path)
case ev.Mask&(unix.IN_DELETE|unix.IN_DELETE_SELF|unix.IN_MOVED_FROM|unix.IN_MOVE_SELF) != 0:
isSelfEvent := ev.Mask&(unix.IN_DELETE_SELF|unix.IN_MOVE_SELF) != 0
// Ignore delete/move self events unless this is the watch root.
if isSelfEvent && path != w.dir {
return false
}
// If deleted item is a dir, drop matching subscriptions.
// XXX: self events don't have IN_ISDIR set.
if isSelfEvent || isDir {
for wd, list := range b.subscriptions {
kept := list[:0]
for _, s := range list {
if s.path == path || (len(s.path) > len(path) && s.path[len(path)] == '/' && s.path[:len(path)] == path) {
continue
}
kept = append(kept, s)
}
if len(kept) == 0 {
_, _ = unix.InotifyRmWatch(b.inotify, uint32(wd))
delete(b.subscriptions, wd)
} else {
b.subscriptions[wd] = kept
}
}
}
w.events.remove(path)
// If the watched root itself is gone the kernel has already
// auto-removed every wd associated with this dirWatch and no
// further events will fire. Surface ErrWatchTerminated so the
// caller knows to clean up; the delete event above still
// flows through the same callback.
if isSelfEvent && path == w.dir {
w.events.setError(fmt.Errorf("%w: watched directory removed", ErrWatchTerminated))
}
}
return true
}
// closeWatch mirrors `inotifyBackend::closeWatch`. Iterates every wd that
// referenced w and removes the matching subscriptions. If a kernel
// InotifyRmWatch fails we keep processing remaining wds and return the
// first error encountered; bailing early would leave the internal state
// half-cleaned and the caller's dirWatch hanging off other wds.
func (b *inotifyBackend) closeWatch(w *dirWatch) error {
var firstErr error
for wd, list := range b.subscriptions {
kept := list[:0]
removedAny := false
for _, s := range list {
if s.dirWatch == w {
removedAny = true
continue
}
kept = append(kept, s)
}
if !removedAny {
continue
}
if len(kept) == 0 {
if _, err := unix.InotifyRmWatch(b.inotify, uint32(wd)); err != nil && firstErr == nil {
firstErr = &dirWatchError{
err: fmt.Errorf("unable to remove dirWatch: %w", err),
dirWatch: w,
}
}
delete(b.subscriptions, wd)
} else {
b.subscriptions[wd] = kept
}
}
return firstErr
}