]> go.fuhry.dev Git - fsnotify.git/commitdiff
Initial commit
authorChris Howey <chris@howey.me>
Fri, 14 Oct 2011 21:52:29 +0000 (14:52 -0700)
committerChris Howey <chris@howey.me>
Fri, 14 Oct 2011 21:52:29 +0000 (14:52 -0700)
Makefile [new file with mode: 0644]
fsnotify_bsd.go [new file with mode: 0644]
fsnotify_linux.go [new file with mode: 0644]
fsnotify_test.go [new file with mode: 0644]

diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..c6e89e8
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,19 @@
+include $(GOROOT)/src/Make.inc
+
+TARG=exp/fsnotify
+
+GOFILES_linux=\
+       fsnotify_linux.go\
+
+GOFILES_freebsd=\
+       fsnotify_bsd.go\
+
+GOFILES_openbsd=\
+       fsnotify_bsd.go\
+
+GOFILES_darwin=\
+       fsnotify_bsd.go\
+
+GOFILES+=$(GOFILES_$(GOOS))
+
+include $(GOROOT)/src/Make.pkg
diff --git a/fsnotify_bsd.go b/fsnotify_bsd.go
new file mode 100644 (file)
index 0000000..54e7fa9
--- /dev/null
@@ -0,0 +1,270 @@
+// Copyright 2010 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+/*
+Package fsnotify implements filesystem notification.
+
+Example:
+    watcher, err := fsnotify.NewWatcher()
+    if err != nil {
+        log.Fatal(err)
+    }
+    err = watcher.Watch("/tmp")
+    if err != nil {
+        log.Fatal(err)
+    }
+    for {
+        select {
+        case ev := <-watcher.Event:
+            log.Println("event:", ev)
+        case err := <-watcher.Error:
+            log.Println("error:", err)
+        }
+    }
+
+*/
+package fsnotify
+
+import (
+       "fmt"
+       "os"
+       "syscall"
+)
+
+type FileEvent struct {
+       mask uint32 // Mask of events
+       Name string // File name (optional)
+}
+
+// IsDelete reports whether the FileEvent was triggerd by a delete
+func (e *FileEvent) IsDelete() bool { return (e.mask & NOTE_DELETE) == NOTE_DELETE }
+
+// IsModify reports whether the FileEvent was triggerd by a file modification
+func (e *FileEvent) IsModify() bool { return (e.mask & NOTE_WRITE) == NOTE_WRITE }
+
+// IsAttribute reports whether the FileEvent was triggerd by a change of attributes
+func (e *FileEvent) IsAttribute() bool { return (e.mask & NOTE_ATTRIB) == NOTE_ATTRIB }
+
+// IsRename reports whether the FileEvent was triggerd by a change name
+func (e *FileEvent) IsRename() bool { return (e.mask & NOTE_RENAME) == NOTE_RENAME }
+
+type Watcher struct {
+       kq       int                 // File descriptor (as returned by the kqueue() syscall)
+       watches  map[string]int      // Map of watched file diescriptors (key: path)
+       paths    map[int]string      // Map of watched paths (key: watch descriptor)
+       Error    chan os.Error       // Errors are sent on this channel
+       Event    chan *FileEvent         // Events are returned on this channel
+       done     chan bool           // Channel for sending a "quit message" to the reader goroutine
+       isClosed bool                // Set to true when Close() is first called
+       kbuf     [1]syscall.Kevent_t // An event buffer for Add/Remove watch
+}
+
+// NewWatcher creates and returns a new kevent instance using kqueue(2)
+func NewWatcher() (*Watcher, os.Error) {
+       fd, errno := syscall.Kqueue()
+       if fd == -1 {
+               return nil, os.NewSyscallError("kqueue", errno)
+       }
+       w := &Watcher{
+               kq:      fd,
+               watches: make(map[string]int),
+               paths:   make(map[int]string),
+               Event:   make(chan *FileEvent),
+               Error:   make(chan os.Error),
+               done:    make(chan bool, 1),
+       }
+
+       go w.readEvents()
+       return w, nil
+}
+
+// Close closes a kevent watcher instance
+// It sends a message to the reader goroutine to quit and removes all watches
+// associated with the kevent instance
+func (w *Watcher) Close() os.Error {
+       if w.isClosed {
+               return nil
+       }
+       w.isClosed = true
+
+       // Send "quit" message to the reader goroutine
+       w.done <- true
+       for path := range w.watches {
+               w.RemoveWatch(path)
+       }
+
+       return nil
+}
+
+// AddWatch adds path to the watched file set.
+// The flags are interpreted as described in kevent(2).
+func (w *Watcher) addWatch(path string, flags uint32) os.Error {
+       if w.isClosed {
+               return os.NewError("kevent instance already closed")
+       }
+
+       watchEntry := &w.kbuf[0]
+       watchEntry.Fflags = flags
+
+       watchfd, found := w.watches[path]
+       if !found {
+               fd, errno := syscall.Open(path, syscall.O_NONBLOCK|syscall.O_RDONLY, 0700)
+               if fd == -1 {
+                       return &os.PathError{"kevent_add_watch", path, os.Errno(errno)}
+               }
+        watchfd = fd
+
+               w.watches[path] = watchfd
+               w.paths[watchfd] = path
+       }
+       syscall.SetKevent(watchEntry, watchfd, syscall.EVFILT_VNODE, syscall.EV_ADD|syscall.EV_CLEAR)
+
+       wd, errno := syscall.Kevent(w.kq, w.kbuf[:], nil, nil)
+       if wd == -1 {
+               return &os.PathError{"kevent_add_watch", path, os.Errno(errno)}
+       } else if (watchEntry.Flags & syscall.EV_ERROR) == syscall.EV_ERROR {
+               return &os.PathError{"kevent_add_watch", path, os.Errno(int(watchEntry.Data))}
+       }
+
+       return nil
+}
+
+// Watch adds path to the watched file set, watching all events.
+func (w *Watcher) Watch(path string) os.Error {
+       return w.addWatch(path, NOTE_ALLEVENTS)
+}
+
+// RemoveWatch removes path from the watched file set.
+func (w *Watcher) RemoveWatch(path string) os.Error {
+       watchfd, ok := w.watches[path]
+       if !ok {
+               return os.NewError(fmt.Sprintf("can't remove non-existent kevent watch for: %s", path))
+       }
+       syscall.Close(watchfd)
+       watchEntry := &w.kbuf[0]
+       syscall.SetKevent(watchEntry, w.watches[path], syscall.EVFILT_VNODE, syscall.EV_DELETE)
+       success, errno := syscall.Kevent(w.kq, w.kbuf[:], nil, nil)
+       if success == -1 {
+               return os.NewSyscallError("kevent_rm_watch", errno)
+       } else if (watchEntry.Flags & syscall.EV_ERROR) == syscall.EV_ERROR {
+               return os.NewSyscallError("kevent_rm_watch", int(watchEntry.Data))
+       }
+       w.watches[path] = 0, false
+       return nil
+}
+
+// readEvents reads from the kqueue file descriptor, converts the
+// received events into Event objects and sends them via the Event channel
+func (w *Watcher) readEvents() {
+       var (
+               eventbuf [10]syscall.Kevent_t // Event buffer
+               events   []syscall.Kevent_t   // Received events
+               twait    *syscall.Timespec    // Time to block waiting for events
+               n        int                  // Number of events returned from kevent
+               errno    int                  // Syscall errno
+       )
+       events = eventbuf[0:0]
+       twait = new(syscall.Timespec)
+       *twait = syscall.NsecToTimespec(keventWaitTime)
+
+       for {
+               if len(events) == 0 {
+                       n, errno = syscall.Kevent(w.kq, nil, eventbuf[:], twait)
+                   events = eventbuf[0:n]
+               }
+               // See if there is a message on the "done" channel
+               var done bool
+               select {
+               case done = <-w.done:
+               default:
+               }
+
+               // If "done" message is received
+               if done {
+                       errno := syscall.Close(w.kq)
+                       if errno == -1 {
+                               w.Error <- os.NewSyscallError("close", errno)
+                       }
+                       close(w.Event)
+                       close(w.Error)
+                       return
+               }
+               if n < 0 {
+                       w.Error <- os.NewSyscallError("kevent", errno)
+                       continue
+               }
+
+               // Timeout, no big deal
+               if n == 0 {
+                       continue
+               }
+
+               // Flush the events we recieved to the events channel
+               for len(events) > 0 {
+                       fileEvent := new(FileEvent)
+                       watchEvent := &events[0]
+                       fileEvent.mask = uint32(watchEvent.Fflags)
+                       fileEvent.Name = w.paths[int(watchEvent.Ident)]
+
+                       // Send the event on the events channel
+                       w.Event <- fileEvent
+
+                       // Move to next event
+                       events = events[1:]
+               }
+       }
+}
+
+// String formats the event e in the form
+// "filename: 0xEventMask = NOTE_EXTEND|NOTE_ATTRIB|..."
+func (e *FileEvent) String() string {
+       var events string = ""
+
+       m := e.mask
+       for _, b := range eventBits {
+               if m&b.Value != 0 {
+                       m &^= b.Value
+                       events += "|" + b.Name
+               }
+       }
+
+       if m != 0 {
+               events += fmt.Sprintf("|%#x", m)
+       }
+       if len(events) > 0 {
+               events = " == " + events[1:]
+       }
+
+       return fmt.Sprintf("%q: %#x%s", e.Name, e.mask, events)
+}
+
+const (
+       // Flags (from <sys/event.h>)
+       NOTE_DELETE = 0x0001 /* vnode was removed */
+       NOTE_WRITE  = 0x0002 /* data contents changed */
+       NOTE_EXTEND = 0x0004 /* size increased */
+       NOTE_ATTRIB = 0x0008 /* attributes changed */
+       NOTE_LINK   = 0x0010 /* link count changed */
+       NOTE_RENAME = 0x0020 /* vnode was renamed */
+       NOTE_REVOKE = 0x0040 /* vnode access was revoked */
+
+       // Watch all events
+       NOTE_ALLEVENTS = NOTE_DELETE | NOTE_WRITE | NOTE_ATTRIB | NOTE_RENAME
+
+       // Block for 100 ms on each call to kevent
+       keventWaitTime = 100e6
+)
+
+var eventBits = []struct {
+       Value uint32
+       Name  string
+}{
+       {NOTE_DELETE, "NOTE_DELETE"},
+       {NOTE_WRITE, "NOTE_WRITE"},
+       {NOTE_EXTEND, "NOTE_EXTEND"},
+       {NOTE_ATTRIB, "NOTE_ATTRIB"},
+       {NOTE_LINK, "NOTE_LINK"},
+       {NOTE_RENAME, "NOTE_RENAME"},
+       {NOTE_REVOKE, "NOTE_REVOKE"},
+}
diff --git a/fsnotify_linux.go b/fsnotify_linux.go
new file mode 100644 (file)
index 0000000..99fa516
--- /dev/null
@@ -0,0 +1,288 @@
+// Copyright 2010 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+/*
+Package inotify implements a wrapper for the Linux inotify system.
+
+Example:
+    watcher, err := inotify.NewWatcher()
+    if err != nil {
+        log.Fatal(err)
+    }
+    err = watcher.Watch("/tmp")
+    if err != nil {
+        log.Fatal(err)
+    }
+    for {
+        select {
+        case ev := <-watcher.Event:
+            log.Println("event:", ev)
+        case err := <-watcher.Error:
+            log.Println("error:", err)
+        }
+    }
+
+*/
+package inotify
+
+import (
+       "fmt"
+       "os"
+       "strings"
+       "syscall"
+       "unsafe"
+)
+
+type Event struct {
+       Mask   uint32 // Mask of events
+       Cookie uint32 // Unique cookie associating related events (for rename(2))
+       Name   string // File name (optional)
+}
+
+type watch struct {
+       wd    uint32 // Watch descriptor (as returned by the inotify_add_watch() syscall)
+       flags uint32 // inotify flags of this watch (see inotify(7) for the list of valid flags)
+}
+
+type Watcher struct {
+       fd       int               // File descriptor (as returned by the inotify_init() syscall)
+       watches  map[string]*watch // Map of inotify watches (key: path)
+       paths    map[int]string    // Map of watched paths (key: watch descriptor)
+       Error    chan os.Error     // Errors are sent on this channel
+       Event    chan *Event       // Events are returned on this channel
+       done     chan bool         // Channel for sending a "quit message" to the reader goroutine
+       isClosed bool              // Set to true when Close() is first called
+}
+
+// NewWatcher creates and returns a new inotify instance using inotify_init(2)
+func NewWatcher() (*Watcher, os.Error) {
+       fd, errno := syscall.InotifyInit()
+       if fd == -1 {
+               return nil, os.NewSyscallError("inotify_init", errno)
+       }
+       w := &Watcher{
+               fd:      fd,
+               watches: make(map[string]*watch),
+               paths:   make(map[int]string),
+               Event:   make(chan *Event),
+               Error:   make(chan os.Error),
+               done:    make(chan bool, 1),
+       }
+
+       go w.readEvents()
+       return w, nil
+}
+
+// Close closes an inotify watcher instance
+// It sends a message to the reader goroutine to quit and removes all watches
+// associated with the inotify instance
+func (w *Watcher) Close() os.Error {
+       if w.isClosed {
+               return nil
+       }
+       w.isClosed = true
+
+       // Send "quit" message to the reader goroutine
+       w.done <- true
+       for path := range w.watches {
+               w.RemoveWatch(path)
+       }
+
+       return nil
+}
+
+// AddWatch adds path to the watched file set.
+// The flags are interpreted as described in inotify_add_watch(2).
+func (w *Watcher) AddWatch(path string, flags uint32) os.Error {
+       if w.isClosed {
+               return os.NewError("inotify instance already closed")
+       }
+
+       watchEntry, found := w.watches[path]
+       if found {
+               watchEntry.flags |= flags
+               flags |= syscall.IN_MASK_ADD
+       }
+       wd, errno := syscall.InotifyAddWatch(w.fd, path, flags)
+       if wd == -1 {
+               return &os.PathError{"inotify_add_watch", path, os.Errno(errno)}
+       }
+
+       if !found {
+               w.watches[path] = &watch{wd: uint32(wd), flags: flags}
+               w.paths[wd] = path
+       }
+       return nil
+}
+
+// Watch adds path to the watched file set, watching all events.
+func (w *Watcher) Watch(path string) os.Error {
+       return w.AddWatch(path, IN_ALL_EVENTS)
+}
+
+// RemoveWatch removes path from the watched file set.
+func (w *Watcher) RemoveWatch(path string) os.Error {
+       watch, ok := w.watches[path]
+       if !ok {
+               return os.NewError(fmt.Sprintf("can't remove non-existent inotify watch for: %s", path))
+       }
+       success, errno := syscall.InotifyRmWatch(w.fd, watch.wd)
+       if success == -1 {
+               return os.NewSyscallError("inotify_rm_watch", errno)
+       }
+       w.watches[path] = nil, false
+       return nil
+}
+
+// readEvents reads from the inotify file descriptor, converts the
+// received events into Event objects and sends them via the Event channel
+func (w *Watcher) readEvents() {
+       var (
+               buf   [syscall.SizeofInotifyEvent * 4096]byte // Buffer for a maximum of 4096 raw events
+               n     int                                     // Number of bytes read with read()
+               errno int                                     // Syscall errno
+       )
+
+       for {
+               n, errno = syscall.Read(w.fd, buf[0:])
+               // See if there is a message on the "done" channel
+               var done bool
+               select {
+               case done = <-w.done:
+               default:
+               }
+
+               // If EOF or a "done" message is received
+               if n == 0 || done {
+                       errno := syscall.Close(w.fd)
+                       if errno == -1 {
+                               w.Error <- os.NewSyscallError("close", errno)
+                       }
+                       close(w.Event)
+                       close(w.Error)
+                       return
+               }
+               if n < 0 {
+                       w.Error <- os.NewSyscallError("read", errno)
+                       continue
+               }
+               if n < syscall.SizeofInotifyEvent {
+                       w.Error <- os.NewError("inotify: short read in readEvents()")
+                       continue
+               }
+
+               var offset uint32 = 0
+               // We don't know how many events we just read into the buffer
+               // While the offset points to at least one whole event...
+               for offset <= uint32(n-syscall.SizeofInotifyEvent) {
+                       // Point "raw" to the event in the buffer
+                       raw := (*syscall.InotifyEvent)(unsafe.Pointer(&buf[offset]))
+                       event := new(Event)
+                       event.Mask = uint32(raw.Mask)
+                       event.Cookie = uint32(raw.Cookie)
+                       nameLen := uint32(raw.Len)
+                       // If the event happened to the watched directory or the watched file, the kernel
+                       // doesn't append the filename to the event, but we would like to always fill the
+                       // the "Name" field with a valid filename. We retrieve the path of the watch from
+                       // the "paths" map.
+                       event.Name = w.paths[int(raw.Wd)]
+                       if nameLen > 0 {
+                               // Point "bytes" at the first byte of the filename
+                               bytes := (*[syscall.PathMax]byte)(unsafe.Pointer(&buf[offset+syscall.SizeofInotifyEvent]))
+                               // The filename is padded with NUL bytes. TrimRight() gets rid of those.
+                               event.Name += "/" + strings.TrimRight(string(bytes[0:nameLen]), "\000")
+                       }
+                       // Send the event on the events channel
+                       w.Event <- event
+
+                       // Move to the next event in the buffer
+                       offset += syscall.SizeofInotifyEvent + nameLen
+               }
+       }
+}
+
+// String formats the event e in the form
+// "filename: 0xEventMask = IN_ACCESS|IN_ATTRIB_|..."
+func (e *Event) String() string {
+       var events string = ""
+
+       m := e.Mask
+       for _, b := range eventBits {
+               if m&b.Value != 0 {
+                       m &^= b.Value
+                       events += "|" + b.Name
+               }
+       }
+
+       if m != 0 {
+               events += fmt.Sprintf("|%#x", m)
+       }
+       if len(events) > 0 {
+               events = " == " + events[1:]
+       }
+
+       return fmt.Sprintf("%q: %#x%s", e.Name, e.Mask, events)
+}
+
+const (
+       // Options for inotify_init() are not exported
+       // IN_CLOEXEC    uint32 = syscall.IN_CLOEXEC
+       // IN_NONBLOCK   uint32 = syscall.IN_NONBLOCK
+
+       // Options for AddWatch
+       IN_DONT_FOLLOW uint32 = syscall.IN_DONT_FOLLOW
+       IN_ONESHOT     uint32 = syscall.IN_ONESHOT
+       IN_ONLYDIR     uint32 = syscall.IN_ONLYDIR
+
+       // The "IN_MASK_ADD" option is not exported, as AddWatch
+       // adds it automatically, if there is already a watch for the given path
+       // IN_MASK_ADD      uint32 = syscall.IN_MASK_ADD
+
+       // Events
+       IN_ACCESS        uint32 = syscall.IN_ACCESS
+       IN_ALL_EVENTS    uint32 = syscall.IN_ALL_EVENTS
+       IN_ATTRIB        uint32 = syscall.IN_ATTRIB
+       IN_CLOSE         uint32 = syscall.IN_CLOSE
+       IN_CLOSE_NOWRITE uint32 = syscall.IN_CLOSE_NOWRITE
+       IN_CLOSE_WRITE   uint32 = syscall.IN_CLOSE_WRITE
+       IN_CREATE        uint32 = syscall.IN_CREATE
+       IN_DELETE        uint32 = syscall.IN_DELETE
+       IN_DELETE_SELF   uint32 = syscall.IN_DELETE_SELF
+       IN_MODIFY        uint32 = syscall.IN_MODIFY
+       IN_MOVE          uint32 = syscall.IN_MOVE
+       IN_MOVED_FROM    uint32 = syscall.IN_MOVED_FROM
+       IN_MOVED_TO      uint32 = syscall.IN_MOVED_TO
+       IN_MOVE_SELF     uint32 = syscall.IN_MOVE_SELF
+       IN_OPEN          uint32 = syscall.IN_OPEN
+
+       // Special events
+       IN_ISDIR      uint32 = syscall.IN_ISDIR
+       IN_IGNORED    uint32 = syscall.IN_IGNORED
+       IN_Q_OVERFLOW uint32 = syscall.IN_Q_OVERFLOW
+       IN_UNMOUNT    uint32 = syscall.IN_UNMOUNT
+)
+
+var eventBits = []struct {
+       Value uint32
+       Name  string
+}{
+       {IN_ACCESS, "IN_ACCESS"},
+       {IN_ATTRIB, "IN_ATTRIB"},
+       {IN_CLOSE, "IN_CLOSE"},
+       {IN_CLOSE_NOWRITE, "IN_CLOSE_NOWRITE"},
+       {IN_CLOSE_WRITE, "IN_CLOSE_WRITE"},
+       {IN_CREATE, "IN_CREATE"},
+       {IN_DELETE, "IN_DELETE"},
+       {IN_DELETE_SELF, "IN_DELETE_SELF"},
+       {IN_MODIFY, "IN_MODIFY"},
+       {IN_MOVE, "IN_MOVE"},
+       {IN_MOVED_FROM, "IN_MOVED_FROM"},
+       {IN_MOVED_TO, "IN_MOVED_TO"},
+       {IN_MOVE_SELF, "IN_MOVE_SELF"},
+       {IN_OPEN, "IN_OPEN"},
+       {IN_ISDIR, "IN_ISDIR"},
+       {IN_IGNORED, "IN_IGNORED"},
+       {IN_Q_OVERFLOW, "IN_Q_OVERFLOW"},
+       {IN_UNMOUNT, "IN_UNMOUNT"},
+}
diff --git a/fsnotify_test.go b/fsnotify_test.go
new file mode 100644 (file)
index 0000000..bc6ffe4
--- /dev/null
@@ -0,0 +1,112 @@
+// Copyright 2010 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package fsnotify
+
+import (
+       "os"
+       "time"
+       "testing"
+)
+
+func TestFsnotifyEvents(t *testing.T) {
+       // Create an fsnotify watcher instance and initialize it
+       watcher, err := NewWatcher()
+       if err != nil {
+               t.Fatalf("NewWatcher() failed: %s", err)
+       }
+
+    const testDir string = "_test"
+
+       // Add a watch for testDir
+       err = watcher.Watch(testDir)
+       if err != nil {
+               t.Fatalf("Watcher.Watch() failed: %s", err)
+       }
+
+       // Receive errors on the error channel on a separate goroutine
+       go func() {
+               for err := range watcher.Error {
+                       t.Fatalf("error received: %s", err)
+               }
+       }()
+
+       const testFile string = "_test/TestFsnotifyEvents.testfile"
+
+       // Receive events on the event channel on a separate goroutine
+       eventstream := watcher.Event
+       var eventsReceived = 0
+       done := make(chan bool)
+       go func() {
+               for event := range eventstream {
+                       // Only count relevant events
+                       if event.Name == testDir || event.Name == testFile {
+                               eventsReceived++
+                               t.Logf("event received: %s", event)
+                       } else {
+                               t.Logf("unexpected event received: %s", event)
+                       }
+               }
+               done <- true
+       }()
+
+       // Create a file
+       // This should add at least one event to the fsnotify event queue
+    var f *os.File
+       f, err = os.OpenFile(testFile, os.O_WRONLY|os.O_CREATE, 0666)
+       if err != nil {
+               t.Fatalf("creating test file failed: %s", err)
+       }
+    f.Sync()
+
+       // Add a watch for testFile
+       err = watcher.Watch(testFile)
+       if err != nil {
+               t.Fatalf("Watcher.Watch() failed: %s", err)
+       }
+
+    f.WriteString("data")
+    f.Sync()
+    f.Close()
+
+    os.Remove(testFile)
+
+       // We expect this event to be received almost immediately, but let's wait 500 ms to be sure
+       time.Sleep(500e6) // 500 ms
+       if eventsReceived == 0 {
+               t.Fatal("fsnotify event hasn't been received after 500 ms")
+       }
+
+       // Try closing the fsnotify instance
+       t.Log("calling Close()")
+       watcher.Close()
+       t.Log("waiting for the event channel to become closed...")
+       select {
+       case <-done:
+               t.Log("event channel closed")
+       case <-time.After(1e9):
+               t.Fatal("event stream was not closed after 1 second")
+       }
+}
+
+func TestFsnotifyClose(t *testing.T) {
+       watcher, _ := NewWatcher()
+       watcher.Close()
+
+       done := false
+       go func() {
+               watcher.Close()
+               done = true
+       }()
+
+       time.Sleep(50e6) // 50 ms
+       if !done {
+               t.Fatal("double Close() test failed: second Close() call didn't return")
+       }
+
+       err := watcher.Watch("_test")
+       if err == nil {
+               t.Fatal("expected error on Watch() after Close(), got nil")
+       }
+}