carlos / platform Public

stopevidence: FDHolders matches the open file's device and inode, not only its path

A writer holding the database through a hard link or another mount path
named a different pathname and was missed, so stop evidence could report no
holders while a writer remained. Each candidate fd is now also stat'd through
its /proc link and compared by (dev, ino) with the wanted files; an unreadable
fd or wanted path fails the scan as incomplete. Reported by Astra via the
moves session.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5cdd38c8431ac7755f511d084eb1e4267ad377c7 parent cfdb4e1
2 files changed, +89 −2
  • internal/stopevidence/fds_linux.go +44 −2
  • internal/stopevidence/fds_linux_test.go +45 −0

CI failed — 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)
+ }
+}