fix/fdholders-inode
Squashed from fix/fdholders-inode into main.
2 files changed,
+89
−2
internal/stopevidence/fds_linux.go+44 −2internal/stopevidence/fds_linux_test.go+45 −0
CI passed — run details
diff --git a/internal/stopevidence/fds_linux.go b/internal/stopevidence/fds_linux.go| index ce7df03fd..91624fdef 100644 |
| --- a/internal/stopevidence/fds_linux.go |
| +++ b/internal/stopevidence/fds_linux.go |
| @@ -37,6 +37,23 @@ func fdHolders(ctx context.Context, procRoot string, paths []string, ops fdOps) |
| } |
| wanted[filepath.Clean(path)] = true |
| } |
| + // The same file reached by another name (a hard link, another mount of |
| + // the file system) is the same inode: match on device and inode too. |
| + inodes := map[fileID]bool{} |
| + for path := range wanted { |
| + fi, err := ops.stat(path) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + if err != nil { |
| + return nil, fmt.Errorf("%w: %s: %w", ErrIncomplete, path, err) |
| + } |
| + if id, ok := idOf(fi); ok { |
| + inodes[id] = true |
| + } else { |
| + return nil, fmt.Errorf("%w: %s: no inode", ErrIncomplete, path) |
| + } |
| + } |
| if err := ctx.Err(); err != nil { |
| return nil, err |
| } |
| @@ -84,10 +101,25 @@ func fdHolders(ctx context.Context, procRoot string, paths []string, ops fdOps) |
| if err != nil { |
| return nil, fmt.Errorf("%w: pid %d tid %d fd %s: %w", ErrIncomplete, pid, tid, fd.Name(), err) |
| } |
| - if !filepath.IsAbs(target) { |
| + if filepath.IsAbs(target) && (wanted[filepath.Clean(target)] || wanted[filepath.Clean(strings.TrimSuffix(target, " (deleted)"))]) { |
| + found = true |
| + continue |
| + } |
| + if len(inodes) == 0 || !filepath.IsAbs(target) { |
| + continue // sockets, pipes and anon inodes name no file |
| + } |
| + // Stat through the fd link reaches the open file itself, whatever |
| + // name it was opened by. An unreadable entry fails the whole scan. |
| + fi, err := ops.stat(filepath.Join(fdDir, fd.Name())) |
| + if procGone(err) { |
| continue |
| } |
| - if wanted[filepath.Clean(target)] || wanted[filepath.Clean(strings.TrimSuffix(target, " (deleted)"))] { |
| + if err != nil { |
| + return nil, fmt.Errorf("%w: pid %d tid %d fd %s: %w", ErrIncomplete, pid, tid, fd.Name(), err) |
| + } |
| + if id, ok := idOf(fi); !ok { |
| + return nil, fmt.Errorf("%w: pid %d tid %d fd %s: no inode", ErrIncomplete, pid, tid, fd.Name()) |
| + } else if inodes[id] { |
| found = true |
| } |
| } |
| @@ -122,3 +154,13 @@ func readProcDir(path, owner string, ops fdOps) ([]os.DirEntry, error) { |
| } |
| return entries, err |
| } |
| + |
| +type fileID struct{ dev, ino uint64 } |
| + |
| +func idOf(fi os.FileInfo) (fileID, bool) { |
| + st, ok := fi.Sys().(*syscall.Stat_t) |
| + if !ok { |
| + return fileID{}, false |
| + } |
| + return fileID{dev: uint64(st.Dev), ino: st.Ino}, true |
| +} |
diff --git a/internal/stopevidence/fds_linux_test.go b/internal/stopevidence/fds_linux_test.go| index 9bd1f183e..8a89eafa6 100644 |
| --- a/internal/stopevidence/fds_linux_test.go |
| +++ b/internal/stopevidence/fds_linux_test.go |
| @@ -265,3 +265,48 @@ func TestFDHoldersUnsharedThread(t *testing.T) { |
| t.Fatalf("unshared thread holder: %v %v", got, err) |
| } |
| } |
| + |
| +// A writer holding the database through another name (a hard link, another |
| +// mount of the same file system) holds the same inode; the scan must see it. |
| +func TestFDHoldersMatchesTheInodeNotOnlyThePath(t *testing.T) { |
| + root, dir := t.TempDir(), t.TempDir() |
| + db, alias := filepath.Join(dir, "data.db"), filepath.Join(dir, "alias.db") |
| + if err := os.WriteFile(db, []byte("db"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Link(db, alias); err != nil { |
| + t.Fatal(err) |
| + } |
| + fd := filepath.Join(root, "40", "task", "40", "fd") |
| + if err := os.MkdirAll(fd, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(alias, filepath.Join(fd, "0")); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := FDHolders(root, []string{db, db + "-wal"}) |
| + if err != nil || !reflect.DeepEqual(got, []int{40}) { |
| + t.Fatalf("hard-linked holder missed: %v %v", got, err) |
| + } |
| + ops := realFDOps() |
| + stat := ops.stat |
| + ops.stat = func(p string) (os.FileInfo, error) { |
| + if p == filepath.Join(fd, "0") { |
| + return nil, syscall.EACCES |
| + } |
| + return stat(p) |
| + } |
| + if got, err := fdHolders(t.Context(), root, []string{db}, ops); !errors.Is(err, ErrIncomplete) || got != nil { |
| + t.Fatalf("unreadable fd counted as not holding: %v %v", got, err) |
| + } |
| + ops = realFDOps() |
| + ops.stat = func(p string) (os.FileInfo, error) { |
| + if p == db { |
| + return nil, syscall.EACCES |
| + } |
| + return stat(p) |
| + } |
| + if got, err := fdHolders(t.Context(), root, []string{db}, ops); !errors.Is(err, ErrIncomplete) || got != nil { |
| + t.Fatalf("unreadable wanted path accepted: %v %v", got, err) |
| + } |
| +} |