Skip to content

Commit

Permalink
inotify, windows: track renames
Browse files Browse the repository at this point in the history
Add Event.RenamedFrom to track event renames; this is sent on a
subsequent Create event; for example:

	Event{Op: Rename, Name: "/tmp/file"}
	Event{Op: Create, Name: "/tmp/renamed", RenamedFrom: "/tmp/file"}

For now this is unexported as renamedFrom because the kqueue and FEN
backends are not yet implemented.
  • Loading branch information
arp242 committed Apr 30, 2024
1 parent f04cd68 commit 5ececd7
Show file tree
Hide file tree
Showing 16 changed files with 214 additions and 95 deletions.
51 changes: 48 additions & 3 deletions backend_inotify.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,24 @@ type Watcher struct {
done chan struct{} // Channel for sending a "quit message" to the reader goroutine
doneMu sync.Mutex
doneResp chan struct{} // Channel to respond to Close

// Store rename cookies in an array, with the index wrapping to 0. Almost
// all of the time what we get is a MOVED_FROM to set the cookie and the
// next event will be MOVED_TO to read it. However, in some cases this won't
// be the case, as described in inotify(7), and we may get other events
// (including MOVED ones).
//
// The second issue is that moving a file outside the watched directory will
// trigger a MOVED_FROM to set the cookie, but we never see the MOVED_TO to
// read and delete it. So just storing it in a map would leak memory.
//
// Doing it like this gives us a simple LRU-cache that won't allocate. Ten
// items should be more than enough for our purpose, and a loop over such a
// short array is faster than a map access anyway (not that it hugely
// matters since we're talking about hundreds of ns at the most, but still).
cookies [10]koekje
cookieIndex uint8
cookiesMu sync.Mutex
}

type (
Expand All @@ -149,6 +167,10 @@ type (
flags uint32 // inotify flags of this watch (see inotify(7) for the list of valid flags)
path string // Watch path.
}
koekje struct {
cookie uint32
path string
}
)

func newWatches() *watches {
Expand Down Expand Up @@ -547,7 +569,7 @@ func (w *Watcher) readEvents() {
}

if debug {
internal.Debug(name, raw.Mask)
internal.Debug(name, raw.Mask, raw.Cookie)
}

// inotify will automatically remove the watch on deletes; just need
Expand Down Expand Up @@ -579,7 +601,7 @@ func (w *Watcher) readEvents() {

/// Send the events that are not ignored on the events channel
if !skip {
if !w.sendEvent(w.newEvent(name, mask)) {
if !w.sendEvent(w.newEvent(name, mask, raw.Cookie)) {
return
}
}
Expand All @@ -591,7 +613,7 @@ func (w *Watcher) readEvents() {
}

// newEvent returns an platform-independent Event based on an inotify mask.
func (w *Watcher) newEvent(name string, mask uint32) Event {
func (w *Watcher) newEvent(name string, mask, cookie uint32) Event {
e := Event{Name: name}
if mask&unix.IN_CREATE == unix.IN_CREATE || mask&unix.IN_MOVED_TO == unix.IN_MOVED_TO {
e.Op |= Create
Expand All @@ -608,5 +630,28 @@ func (w *Watcher) newEvent(name string, mask uint32) Event {
if mask&unix.IN_ATTRIB == unix.IN_ATTRIB {
e.Op |= Chmod
}

if cookie != 0 {
if mask&unix.IN_MOVED_FROM == unix.IN_MOVED_FROM {
w.cookiesMu.Lock()
w.cookies[w.cookieIndex] = koekje{cookie: cookie, path: e.Name}
w.cookieIndex++
if w.cookieIndex > 9 {
w.cookieIndex = 0
}
w.cookiesMu.Unlock()
} else if mask&unix.IN_MOVED_TO == unix.IN_MOVED_TO {
w.cookiesMu.Lock()
var prev string
for _, c := range w.cookies {
if c.cookie == cookie {
prev = c.path
break
}
}
w.cookiesMu.Unlock()
e.renamedFrom = prev
}
}
return e
}
40 changes: 22 additions & 18 deletions backend_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,13 @@ func (w *Watcher) isClosed() bool {
return w.closed
}

func (w *Watcher) sendEvent(name string, mask uint64) bool {
func (w *Watcher) sendEvent(name, renameFrom string, mask uint64) bool {
if mask == 0 {
return false
}

event := w.newEvent(name, uint32(mask))
event.renamedFrom = renameFrom
select {
case ch := <-w.quit:
w.quit <- ch
Expand Down Expand Up @@ -275,7 +276,7 @@ func (w *Watcher) AddWith(name string, opts ...addOpt) error {
}
if debug {
fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s AddWith(%q)\n",
time.Now().Format("15:04:05.000000000"), name)
time.Now().Format("15:04:05.000000000"), filepath.ToSlash(name))
}

with := getOptions(opts...)
Expand Down Expand Up @@ -311,7 +312,7 @@ func (w *Watcher) Remove(name string) error {
}
if debug {
fmt.Fprintf(os.Stderr, "FSNOTIFY_DEBUG: %s Remove(%q)\n",
time.Now().Format("15:04:05.000000000"), name)
time.Now().Format("15:04:05.000000000"), filepath.ToSlash(name))
}

in := &input{
Expand Down Expand Up @@ -577,11 +578,11 @@ func (w *Watcher) remWatch(pathname string) error {
return fmt.Errorf("%w: %s", ErrNonExistentWatch, pathname)
}
if pathname == dir {
w.sendEvent(watch.path, watch.mask&sysFSIGNORED)
w.sendEvent(watch.path, "", watch.mask&sysFSIGNORED)
watch.mask = 0
} else {
name := filepath.Base(pathname)
w.sendEvent(filepath.Join(watch.path, name), watch.names[name]&sysFSIGNORED)
w.sendEvent(filepath.Join(watch.path, name), "", watch.names[name]&sysFSIGNORED)
delete(watch.names, name)
}

Expand All @@ -592,13 +593,13 @@ func (w *Watcher) remWatch(pathname string) error {
func (w *Watcher) deleteWatch(watch *watch) {
for name, mask := range watch.names {
if mask&provisional == 0 {
w.sendEvent(filepath.Join(watch.path, name), mask&sysFSIGNORED)
w.sendEvent(filepath.Join(watch.path, name), "", mask&sysFSIGNORED)
}
delete(watch.names, name)
}
if watch.mask != 0 {
if watch.mask&provisional == 0 {
w.sendEvent(watch.path, watch.mask&sysFSIGNORED)
w.sendEvent(watch.path, "", watch.mask&sysFSIGNORED)
}
watch.mask = 0
}
Expand Down Expand Up @@ -635,7 +636,7 @@ func (w *Watcher) startRead(watch *watch) error {
err := os.NewSyscallError("ReadDirectoryChanges", rdErr)
if rdErr == windows.ERROR_ACCESS_DENIED && watch.mask&provisional == 0 {
// Watched directory was probably removed
w.sendEvent(watch.path, watch.mask&sysFSDELETESELF)
w.sendEvent(watch.path, "", watch.mask&sysFSDELETESELF)
err = nil
}
w.deleteWatch(watch)
Expand Down Expand Up @@ -711,7 +712,7 @@ func (w *Watcher) readEvents() {
}
case windows.ERROR_ACCESS_DENIED:
// Watched directory was probably removed
w.sendEvent(watch.path, watch.mask&sysFSDELETESELF)
w.sendEvent(watch.path, "", watch.mask&sysFSDELETESELF)
w.deleteWatch(watch)
w.startRead(watch)
continue
Expand Down Expand Up @@ -776,21 +777,25 @@ func (w *Watcher) readEvents() {
}
}

sendNameEvent := func() {
w.sendEvent(fullname, watch.names[name]&mask)
}
if raw.Action != windows.FILE_ACTION_RENAMED_NEW_NAME {
sendNameEvent()
w.sendEvent(fullname, "", watch.names[name]&mask)
}
if raw.Action == windows.FILE_ACTION_REMOVED {
w.sendEvent(fullname, watch.names[name]&sysFSIGNORED)
w.sendEvent(fullname, "", watch.names[name]&sysFSIGNORED)
delete(watch.names, name)
}

w.sendEvent(fullname, watch.mask&w.toFSnotifyFlags(raw.Action))
// XXX
if watch.rename != "" && raw.Action == windows.FILE_ACTION_RENAMED_NEW_NAME {
r := filepath.Join(watch.path, watch.rename)
w.sendEvent(fullname, r, watch.mask&w.toFSnotifyFlags(raw.Action))
} else {
w.sendEvent(fullname, "", watch.mask&w.toFSnotifyFlags(raw.Action))
}

if raw.Action == windows.FILE_ACTION_RENAMED_NEW_NAME {
fullname = filepath.Join(watch.path, watch.rename)
sendNameEvent()
w.sendEvent(fullname, "", watch.names[name]&mask)
}

// Move to the next event in the buffer
Expand All @@ -802,8 +807,7 @@ func (w *Watcher) readEvents() {
// Error!
if offset >= n {
//lint:ignore ST1005 Windows should be capitalized
w.sendError(errors.New(
"Windows system assumed buffer larger than it is, events have likely been missed"))
w.sendError(errors.New("Windows system assumed buffer larger than it is, events have likely been missed"))
break
}
}
Expand Down
13 changes: 13 additions & 0 deletions fsnotify.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ type Event struct {
// This is a bitmask and some systems may send multiple operations at once.
// Use the Event.Has() method instead of comparing with ==.
Op Op

// Create events will have this set to the old path if it's a rename; this
// only works when both the source and destination are watched. It's not
// reliable when watching individual files, only directories.
//
// For example "mv /tmp/file /tmp/rename" will emit:
//
// Event{Op: Rename, Name: "/tmp/file"}
// Event{Op: Create, Name: "/tmp/rename", RenamedFrom: "/tmp/file"}
renamedFrom string
}

// Op describes a set of file operations.
Expand Down Expand Up @@ -128,6 +138,9 @@ func (e Event) Has(op Op) bool { return e.Op.Has(op) }

// String returns a string representation of the event with their path.
func (e Event) String() string {
if e.renamedFrom != "" {
return fmt.Sprintf("%-13s %q ← %q", e.Op.String(), e.Name, e.renamedFrom)
}
return fmt.Sprintf("%-13s %q", e.Op.String(), e.Name)
}

Expand Down
10 changes: 5 additions & 5 deletions fsnotify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -621,15 +621,15 @@ func TestEventString(t *testing.T) {
want string
}{
{Event{}, `[no events] ""`},
{Event{"/file", 0}, `[no events] "/file"`},
{Event{Name: "/file", Op: 0}, `[no events] "/file"`},

{Event{"/file", Chmod | Create},
{Event{Name: "/file", Op: Chmod | Create},
`CREATE|CHMOD "/file"`},
{Event{"/file", Rename},
{Event{Name: "/file", Op: Rename},
`RENAME "/file"`},
{Event{"/file", Remove},
{Event{Name: "/file", Op: Remove},
`REMOVE "/file"`},
{Event{"/file", Write | Chmod},
{Event{Name: "/file", Op: Write | Chmod},
`WRITE|CHMOD "/file"`},
}

Expand Down
61 changes: 35 additions & 26 deletions helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,11 @@ func (e Events) String() string {
if i > 0 {
b.WriteString("\n")
}
fmt.Fprintf(b, "%-20s %q", ee.Op.String(), filepath.ToSlash(ee.Name))
if ee.renamedFrom != "" {
fmt.Fprintf(b, "%-8s %s ← %s", ee.Op.String(), filepath.ToSlash(ee.Name), filepath.ToSlash(ee.renamedFrom))
} else {
fmt.Fprintf(b, "%-8s %s", ee.Op.String(), filepath.ToSlash(ee.Name))
}
}
return b.String()
}
Expand All @@ -452,6 +456,11 @@ func (e Events) TrimPrefix(prefix string) Events {
} else {
e[i].Name = strings.TrimPrefix(e[i].Name, prefix)
}
if e[i].renamedFrom == prefix {
e[i].renamedFrom = "/"
} else {
e[i].renamedFrom = strings.TrimPrefix(e[i].renamedFrom, prefix)
}
}
return e
}
Expand Down Expand Up @@ -507,44 +516,44 @@ func newEvents(t *testing.T, s string) Events {
}

fields := strings.Fields(line)
if len(fields) < 2 {
if strings.ToUpper(fields[0]) == "EMPTY" || strings.ToLower(fields[0]) == "no-events" {
if len(fields) != 2 && len(fields) != 4 {
if strings.ToLower(fields[0]) == "empty" || strings.ToLower(fields[0]) == "no-events" {
for _, g := range groups {
events[g] = Events{}
}
continue
}

t.Fatalf("newEvents: line %d has less than 2 fields: %s", no, line)
t.Fatalf("newEvents: line %d: needs 2 or 4 fields: %s", no+1, line)
}

path := strings.Trim(fields[len(fields)-1], `"`)

var op Op
for _, e := range fields[:len(fields)-1] {
if e == "|" {
continue
for _, ee := range strings.Split(fields[0], "|") {
switch strings.ToUpper(ee) {
case "CREATE":
op |= Create
case "WRITE":
op |= Write
case "REMOVE":
op |= Remove
case "RENAME":
op |= Rename
case "CHMOD":
op |= Chmod
default:
t.Fatalf("newEvents: line %d has unknown event %q: %s", no+1, ee, line)
}
for _, ee := range strings.Split(e, "|") {
switch strings.ToUpper(ee) {
case "CREATE":
op |= Create
case "WRITE":
op |= Write
case "REMOVE":
op |= Remove
case "RENAME":
op |= Rename
case "CHMOD":
op |= Chmod
default:
t.Fatalf("newEvents: line %d has unknown event %q: %s", no, ee, line)
}
}

var from string
if len(fields) > 2 {
if fields[2] != "←" {
t.Fatalf("newEvents: line %d: invalid format: %s", no+1, line)
}
from = strings.Trim(fields[3], `"`)
}

for _, g := range groups {
events[g] = append(events[g], Event{Name: path, Op: op})
events[g] = append(events[g], Event{Name: strings.Trim(fields[1], `"`), renamedFrom: from, Op: op})
}
}

Expand Down

0 comments on commit 5ececd7

Please sign in to comment.