unix: solaris/illumos Event Ports ENOENT cleanup
When we receive ENOENT from port_dissociate,
we should clean up our map reference.
To do so safely, we need a place to track
the user cookies.
This work is in support of fsnotify/fsnotify#371
Change-Id: Iae53ccc508d533941ad19df9051ca046262095ef
Reviewed-on: https://go-review.googlesource.com/c/sys/+/380034
Trust: Tobias Klauser <tobias.klauser@gmail.com>
Run-TryBot: Tobias Klauser <tobias.klauser@gmail.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Ian Lance Taylor <iant@golang.org>
diff --git a/unix/syscall_internal_solaris_test.go b/unix/syscall_internal_solaris_test.go
new file mode 100644
index 0000000..715f304
--- /dev/null
+++ b/unix/syscall_internal_solaris_test.go
@@ -0,0 +1,179 @@
+// Copyright 2022 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.
+
+//go:build solaris
+// +build solaris
+
+package unix
+
+import (
+ "fmt"
+ "os"
+ "runtime"
+ "testing"
+)
+
+func (e *EventPort) checkInternals(t *testing.T, fds, paths, cookies, pending int) {
+ t.Helper()
+ p, err := e.Pending()
+ if err != nil {
+ t.Fatalf("failed to query how many events are pending")
+ }
+ if len(e.fds) != fds || len(e.paths) != paths || len(e.cookies) != cookies || p != pending {
+ format := "| fds: %d | paths: %d | cookies: %d | pending: %d |"
+ expected := fmt.Sprintf(format, fds, paths, cookies, pending)
+ got := fmt.Sprintf(format, len(e.fds), len(e.paths), len(e.cookies), p)
+ t.Errorf("Internal state mismatch\nfound: %s\nexpected: %s", got, expected)
+ }
+}
+
+// Regression test for DissociatePath returning ENOENT
+// This test is intended to create a linear worst
+// case scenario of events being associated and
+// fired but not consumed before additional
+// calls to dissociate and associate happen
+// This needs to be an internal test so that
+// we can validate the state of the private maps
+func TestEventPortDissociateAlreadyGone(t *testing.T) {
+ port, err := NewEventPort()
+ if err != nil {
+ t.Fatalf("failed to create an EventPort")
+ }
+ defer port.Close()
+ dir := t.TempDir()
+ tmpfile, err := os.CreateTemp(dir, "eventport")
+ if err != nil {
+ t.Fatalf("unable to create a tempfile: %v", err)
+ }
+ path := tmpfile.Name()
+ stat, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("unexpected failure to Stat file: %v", err)
+ }
+ err = port.AssociatePath(path, stat, FILE_MODIFIED, "cookie1")
+ if err != nil {
+ t.Fatalf("unexpected failure associating file: %v", err)
+ }
+ // We should have 1 path registered and 1 cookie in the jar
+ port.checkInternals(t, 0, 1, 1, 0)
+ // The path is associated, let's delete it.
+ err = os.Remove(path)
+ if err != nil {
+ t.Fatalf("unexpected failure deleting file: %v", err)
+ }
+ // The file has been deleted, some sort of pending event is probably
+ // queued in the kernel waiting for us to get it AND the kernel is
+ // no longer watching for events on it. BUT... Because we haven't
+ // consumed the event, this API thinks it's still watched:
+ watched := port.PathIsWatched(path)
+ if !watched {
+ t.Errorf("unexpected result from PathIsWatched")
+ }
+ // Ok, let's dissociate the file even before reading the event.
+ // Oh, ENOENT. I guess it's not associated any more
+ err = port.DissociatePath(path)
+ if err != ENOENT {
+ t.Errorf("unexpected result dissociating a seemingly associated path we know isn't: %v", err)
+ }
+ // As established by the return value above, this should clearly be false now:
+ // Narrator voice: in the first version of this API, it wasn't.
+ watched = port.PathIsWatched(path)
+ if watched {
+ t.Errorf("definitively unwatched file still in the map")
+ }
+ // We should have nothing registered, but 1 pending event corresponding
+ // to the cookie in the jar
+ port.checkInternals(t, 0, 0, 1, 1)
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0666)
+ if err != nil {
+ t.Fatalf("creating test file failed: %s", err)
+ }
+ err = f.Close()
+ if err != nil {
+ t.Fatalf("unexpected failure closing file: %v", err)
+ }
+ stat, err = os.Stat(path)
+ if err != nil {
+ t.Fatalf("unexpected failure to Stat file: %v", err)
+ }
+ c := "cookie2" // c is for cookie, that's good enough for me
+ err = port.AssociatePath(path, stat, FILE_MODIFIED, c)
+ if err != nil {
+ t.Errorf("unexpected failure associating file: %v", err)
+ }
+ // We should have 1 registered path and its cookie
+ // as well as a second cookie corresponding to the pending event
+ port.checkInternals(t, 0, 1, 2, 1)
+
+ // Fire another event
+ err = os.Remove(path)
+ if err != nil {
+ t.Fatalf("unexpected failure deleting file: %v", err)
+ }
+ port.checkInternals(t, 0, 1, 2, 2)
+ err = port.DissociatePath(path)
+ if err != ENOENT {
+ t.Errorf("unexpected result dissociating a seemingly associated path we know isn't: %v", err)
+ }
+ // By dissociating this path after deletion we ensure that the paths map is now empty
+ // If we're not careful we could trigger a nil pointer exception
+ port.checkInternals(t, 0, 0, 2, 2)
+
+ f, err = os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0666)
+ if err != nil {
+ t.Fatalf("creating test file failed: %s", err)
+ }
+ err = f.Close()
+ if err != nil {
+ t.Fatalf("unexpected failure closing file: %v", err)
+ }
+ stat, err = os.Stat(path)
+ if err != nil {
+ t.Fatalf("unexpected failure to Stat file: %v", err)
+ }
+ // Put a seemingly duplicate cookie in the jar to see if we can trigger an incorrect removal from the paths map
+ err = port.AssociatePath(path, stat, FILE_MODIFIED, c)
+ if err != nil {
+ t.Errorf("unexpected failure associating file: %v", err)
+ }
+ port.checkInternals(t, 0, 1, 3, 2)
+
+ // run the garbage collector so that if we messed up it should be painfully clear
+ runtime.GC()
+
+ // Before the fix, this would cause a nil pointer exception
+ e, err := port.GetOne(nil)
+ if err != nil {
+ t.Errorf("failed to get an event: %v", err)
+ }
+ port.checkInternals(t, 0, 1, 2, 1)
+ if e.Cookie != "cookie1" {
+ t.Errorf(`expected "cookie1", got "%v"`, e.Cookie)
+ }
+ // Make sure that a cookie of the same value doesn't cause removal from the paths map incorrectly
+ e, err = port.GetOne(nil)
+ if err != nil {
+ t.Errorf("failed to get an event: %v", err)
+ }
+ port.checkInternals(t, 0, 1, 1, 0)
+ if e.Cookie != "cookie2" {
+ t.Errorf(`expected "cookie2", got "%v"`, e.Cookie)
+ }
+
+ err = os.Remove(path)
+ if err != nil {
+ t.Fatalf("unexpected failure deleting file: %v", err)
+ }
+ // Event has fired, but until processed it should still be in the map
+ port.checkInternals(t, 0, 1, 1, 1)
+ e, err = port.GetOne(nil)
+ if err != nil {
+ t.Errorf("failed to get an event: %v", err)
+ }
+ if e.Cookie != "cookie2" {
+ t.Errorf(`expected "cookie2", got "%v"`, e.Cookie)
+ }
+ // The maps should be empty and there should be no pending events
+ port.checkInternals(t, 0, 0, 0, 0)
+}
diff --git a/unix/syscall_solaris.go b/unix/syscall_solaris.go
index 8c4e800..5c2003c 100644
--- a/unix/syscall_solaris.go
+++ b/unix/syscall_solaris.go
@@ -737,8 +737,20 @@
type EventPort struct {
port int
mu sync.Mutex
- fds map[uintptr]interface{}
+ fds map[uintptr]*fileObjCookie
paths map[string]*fileObjCookie
+ // The user cookie presents an interesting challenge from a memory management perspective.
+ // There are two paths by which we can discover that it is no longer in use:
+ // 1. The user calls port_dissociate before any events fire
+ // 2. An event fires and we return it to the user
+ // The tricky situation is if the event has fired in the kernel but
+ // the user hasn't requested/received it yet.
+ // If the user wants to port_dissociate before the event has been processed,
+ // we should handle things gracefully. To do so, we need to keep an extra
+ // reference to the cookie around until the event is processed
+ // thus the otherwise seemingly extraneous "cookies" map
+ // The key of this map is a pointer to the corresponding &fCookie.cookie
+ cookies map[*interface{}]*fileObjCookie
}
// PortEvent is an abstraction of the port_event C struct.
@@ -762,9 +774,10 @@
return nil, err
}
e := &EventPort{
- port: port,
- fds: make(map[uintptr]interface{}),
- paths: make(map[string]*fileObjCookie),
+ port: port,
+ fds: make(map[uintptr]*fileObjCookie),
+ paths: make(map[string]*fileObjCookie),
+ cookies: make(map[*interface{}]*fileObjCookie),
}
return e, nil
}
@@ -779,9 +792,13 @@
func (e *EventPort) Close() error {
e.mu.Lock()
defer e.mu.Unlock()
+ err := Close(e.port)
+ if err != nil {
+ return err
+ }
e.fds = nil
e.paths = nil
- return Close(e.port)
+ return nil
}
// PathIsWatched checks to see if path is associated with this EventPort.
@@ -818,6 +835,7 @@
return err
}
e.paths[path] = fCookie
+ e.cookies[&fCookie.cookie] = fCookie
return nil
}
@@ -830,11 +848,19 @@
return fmt.Errorf("%v is not associated with this Event Port", path)
}
_, err := port_dissociate(e.port, PORT_SOURCE_FILE, uintptr(unsafe.Pointer(f.fobj)))
- if err != nil {
+ // If the path is no longer associated with this event port (ENOENT)
+ // we should delete it from our map. We can still return ENOENT to the caller.
+ // But we need to save the cookie
+ if err != nil && err != ENOENT {
return err
}
+ if err == nil {
+ // dissociate was successful, safe to delete the cookie
+ fCookie := e.paths[path]
+ delete(e.cookies, &fCookie.cookie)
+ }
delete(e.paths, path)
- return nil
+ return err
}
// AssociateFd wraps calls to port_associate(3c) on file descriptors.
@@ -844,12 +870,13 @@
if _, found := e.fds[fd]; found {
return fmt.Errorf("%v is already associated with this Event Port", fd)
}
- pcookie := &cookie
- _, err := port_associate(e.port, PORT_SOURCE_FD, fd, events, (*byte)(unsafe.Pointer(pcookie)))
+ fCookie := &fileObjCookie{nil, cookie}
+ _, err := port_associate(e.port, PORT_SOURCE_FD, fd, events, (*byte)(unsafe.Pointer(&fCookie.cookie)))
if err != nil {
return err
}
- e.fds[fd] = pcookie
+ e.fds[fd] = fCookie
+ e.cookies[&fCookie.cookie] = fCookie
return nil
}
@@ -862,11 +889,16 @@
return fmt.Errorf("%v is not associated with this Event Port", fd)
}
_, err := port_dissociate(e.port, PORT_SOURCE_FD, fd)
- if err != nil {
+ if err != nil && err != ENOENT {
return err
}
+ if err == nil {
+ // dissociate was successful, safe to delete the cookie
+ fCookie := e.fds[fd]
+ delete(e.cookies, &fCookie.cookie)
+ }
delete(e.fds, fd)
- return nil
+ return err
}
func createFileObj(name string, stat os.FileInfo) (*fileObj, error) {
@@ -894,26 +926,48 @@
return nil, err
}
p := new(PortEvent)
- p.Events = pe.Events
- p.Source = pe.Source
e.mu.Lock()
defer e.mu.Unlock()
- switch pe.Source {
- case PORT_SOURCE_FD:
- p.Fd = uintptr(pe.Object)
- cookie := (*interface{})(unsafe.Pointer(pe.User))
- p.Cookie = *cookie
- delete(e.fds, p.Fd)
- case PORT_SOURCE_FILE:
- p.fobj = (*fileObj)(unsafe.Pointer(uintptr(pe.Object)))
- p.Path = BytePtrToString((*byte)(unsafe.Pointer(p.fobj.Name)))
- cookie := (*interface{})(unsafe.Pointer(pe.User))
- p.Cookie = *cookie
- delete(e.paths, p.Path)
- }
+ e.peIntToExt(pe, p)
return p, nil
}
+// peIntToExt converts a cgo portEvent struct into the friendlier PortEvent
+// NOTE: Always call this function while holding the e.mu mutex
+func (e *EventPort) peIntToExt(peInt *portEvent, peExt *PortEvent) {
+ peExt.Events = peInt.Events
+ peExt.Source = peInt.Source
+ cookie := (*interface{})(unsafe.Pointer(peInt.User))
+ peExt.Cookie = *cookie
+ switch peInt.Source {
+ case PORT_SOURCE_FD:
+ delete(e.cookies, cookie)
+ peExt.Fd = uintptr(peInt.Object)
+ // Only remove the fds entry if it exists and this cookie matches
+ if fobj, ok := e.fds[peExt.Fd]; ok {
+ if &fobj.cookie == cookie {
+ delete(e.fds, peExt.Fd)
+ }
+ }
+ case PORT_SOURCE_FILE:
+ if fCookie, ok := e.cookies[cookie]; ok && uintptr(unsafe.Pointer(fCookie.fobj)) == uintptr(peInt.Object) {
+ // Use our stashed reference rather than using unsafe on what we got back
+ // the unsafe version would be (*fileObj)(unsafe.Pointer(uintptr(peInt.Object)))
+ peExt.fobj = fCookie.fobj
+ } else {
+ panic("mismanaged memory")
+ }
+ delete(e.cookies, cookie)
+ peExt.Path = BytePtrToString((*byte)(unsafe.Pointer(peExt.fobj.Name)))
+ // Only remove the paths entry if it exists and this cookie matches
+ if fobj, ok := e.paths[peExt.Path]; ok {
+ if &fobj.cookie == cookie {
+ delete(e.paths, peExt.Path)
+ }
+ }
+ }
+}
+
// Pending wraps port_getn(3c) and returns how many events are pending.
func (e *EventPort) Pending() (int, error) {
var n uint32 = 0
@@ -944,21 +998,7 @@
e.mu.Lock()
defer e.mu.Unlock()
for i := 0; i < int(got); i++ {
- s[i].Events = ps[i].Events
- s[i].Source = ps[i].Source
- switch ps[i].Source {
- case PORT_SOURCE_FD:
- s[i].Fd = uintptr(ps[i].Object)
- cookie := (*interface{})(unsafe.Pointer(ps[i].User))
- s[i].Cookie = *cookie
- delete(e.fds, s[i].Fd)
- case PORT_SOURCE_FILE:
- s[i].fobj = (*fileObj)(unsafe.Pointer(uintptr(ps[i].Object)))
- s[i].Path = BytePtrToString((*byte)(unsafe.Pointer(s[i].fobj.Name)))
- cookie := (*interface{})(unsafe.Pointer(ps[i].User))
- s[i].Cookie = *cookie
- delete(e.paths, s[i].Path)
- }
+ e.peIntToExt(&ps[i], &s[i])
}
return int(got), err
}