carlos / platform Public

stopevidence: a kcmp ESRCH skips a thread only once its task directory is gone, and is an incomplete scan while the task is still present

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev ef75033a4dff310a4155d6e48a290a62f7cde6dc parent e3e9e0c
2 files changed, +69 −5
  • internal/stopevidence/writers_linux.go +15 −5
  • internal/stopevidence/writers_linux_test.go +54 −0
diff --git a/internal/stopevidence/writers_linux.go b/internal/stopevidence/writers_linux.go
index c59068794..dbaf5167a 100644
--- a/internal/stopevidence/writers_linux.go
+++ b/internal/stopevidence/writers_linux.go
@@ -127,8 +127,9 @@ func scanProcess(ctx context.Context, root string, pid int, paths []string) ([]D
// database at the same number. Any other thread's descriptor is reported
// with FD -1, so no allow-list can exempt it. A comparison that cannot be
// made (ENOSYS, EPERM, anything but a vanished task) is ErrIncomplete. A
-// process or thread that vanished mid-scan is skipped; an unreadable
-// table is ErrIncomplete.
+// process or thread that vanished mid-scan is skipped, and a kcmp ESRCH
+// counts as vanished only once the task directory is gone; an unreadable
+// table, or an ESRCH for a task still present, is ErrIncomplete.
func scanOne(ctx context.Context, root string, pid int, files fileSet, ops fdOps, cmp kcmpFunc) ([]Descriptor, error) {
pidDir := filepath.Join(root, strconv.Itoa(pid))
tasks, err := readProcDir(filepath.Join(pidDir, "task"), pidDir, ops)
@@ -189,7 +190,16 @@ func scanOne(ctx context.Context, root string, pid int, files fileSet, ops fdOps
return nil, fmt.Errorf("%w: pid %d tid %d fd %d: kcmp: %w", ErrIncomplete, pid, tid, n, err)
}
if gone {
- break descriptors // the thread exited mid-scan
+ // kcmp's ESRCH alone is no evidence the thread
+ // exited: only its task directory being gone is.
+ _, lerr := ops.lstat(taskDir)
+ if procGone(lerr) {
+ break descriptors // the thread exited mid-scan
+ }
+ if lerr == nil {
+ lerr = errors.New("the task is still present")
+ }
+ return nil, fmt.Errorf("%w: pid %d tid %d fd %d: kcmp: %w: %w", ErrIncomplete, pid, tid, n, unix.ESRCH, lerr)
}
if !ok {
d.FD = -1 // not provably the leader's descriptor
@@ -207,8 +217,8 @@ func scanOne(ctx context.Context, root string, pid int, files fileSet, ops fdOps
// leadersDescriptor is the kernel's evidence that thread tid's descriptor
// n is the leader's own: the same table (KCMP_FILES, asked once per thread
// and kept in *shared) and the same open file description (KCMP_FILE).
-// sameName is the leader showing n on the same path. gone reports a task
-// that exited (ESRCH); EBADF (n closed in one of them since its listing)
+// sameName is the leader showing n on the same path. gone reports kcmp's
+// ESRCH, which the caller confirms against the task directory; EBADF (n closed in one of them since its listing)
// is "not the leader's"; any other error is returned.
func leadersDescriptor(cmp kcmpFunc, pid, tid, n int, sameName bool, shared *int) (ok, gone bool, err error) {
if !sameName {
diff --git a/internal/stopevidence/writers_linux_test.go b/internal/stopevidence/writers_linux_test.go
index eb242af4b..00312ce59 100644
--- a/internal/stopevidence/writers_linux_test.go
+++ b/internal/stopevidence/writers_linux_test.go
@@ -237,6 +237,60 @@ func TestWritersStoppedRefusesAnyOtherHolder(t *testing.T) {
}
})
}
+ // Final review Important 1: kcmp's ESRCH is a vanished thread only once
+ // its task directory is gone. A thread still listed keeps its holders.
+ for _, typ := range []int{kcmpFiles, kcmpFile} {
+ t.Run("kcmp ESRCH for a thread still present: type "+strconv.Itoa(typ), func(t *testing.T) {
+ root, db := writersProc(t)
+ prior := kcmpFor
+ kcmpFor = func(string) kcmpFunc {
+ return func(pid, tid, got int, a, b uintptr) (int, error) {
+ if got == typ {
+ return 0, unix.ESRCH
+ }
+ return 0, nil
+ }
+ }
+ defer func() { kcmpFor = prior }()
+ if _, err := WritersStopped(t.Context(), writersInputs(root, db)); !errors.Is(err, ErrIncomplete) {
+ t.Fatalf("WritersStopped: %v", err)
+ }
+ if _, err := ProcessDescriptors(t.Context(), root, 20, []string{db}); !errors.Is(err, ErrIncomplete) {
+ t.Fatalf("own descriptors: %v", err)
+ }
+ })
+ t.Run("kcmp ESRCH for a thread that exited: type "+strconv.Itoa(typ), func(t *testing.T) {
+ root, db := writersProc(t)
+ // Thread 8 holds the database on a descriptor the leader does
+ // not show, then exits: it is skipped, not a holder.
+ dir := filepath.Join(root, "20", "task", "8", "fd")
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ for fd, target := range map[int]string{5: db, 6: db + "-wal"} {
+ if err := os.Symlink(target, filepath.Join(dir, strconv.Itoa(fd))); err != nil {
+ t.Fatal(err)
+ }
+ }
+ prior := kcmpFor
+ kcmpFor = func(r string) kcmpFunc {
+ real := fixtureKcmp(r)
+ return func(pid, tid, got int, a, b uintptr) (int, error) {
+ if tid == 8 && got == typ {
+ if err := os.RemoveAll(filepath.Join(root, "20", "task", "8")); err != nil {
+ t.Error(err)
+ }
+ return 0, unix.ESRCH
+ }
+ return real(pid, tid, got, a, b)
+ }
+ }
+ defer func() { kcmpFor = prior }()
+ if _, err := WritersStopped(t.Context(), writersInputs(root, db)); err != nil {
+ t.Fatalf("WritersStopped: %v", err)
+ }
+ })
+ }
t.Run("an unreadable descriptor directory", func(t *testing.T) {
root, db := writersProc(t)
if _, err := WritersStopped(t.Context(), writersInputs(filepath.Join(root, "missing"), db)); !errors.Is(err, ErrIncomplete) {