From 9b257496dfe535d83b99884d07d321bf2cd6e10f Mon Sep 17 00:00:00 2001 From: Rakshak05 Date: Mon, 8 Jun 2026 14:16:48 +0530 Subject: [PATCH 1/3] Fix for Issue #115 --- internal/collector/fd.go | 31 ++++++- internal/collector/proc_fd.go | 60 ++++++++++++ internal/collector/proc_fd_test.go | 144 +++++++++++++++++++++++++++++ internal/collector/signals.go | 9 ++ internal/doctor/eta.go | 31 +++++++ internal/doctor/eta_test.go | 73 +++++++++++++++ internal/doctor/predict.go | 25 +++-- internal/doctor/predict_test.go | 117 +++++++++++++++++++++++ internal/doctor/rules.go | 32 +++++-- internal/doctor/rules_test.go | 101 +++++++++++++++----- 10 files changed, 580 insertions(+), 43 deletions(-) create mode 100644 internal/collector/proc_fd.go create mode 100644 internal/collector/proc_fd_test.go create mode 100644 internal/doctor/predict_test.go diff --git a/internal/collector/fd.go b/internal/collector/fd.go index faf8c63..cca60d5 100644 --- a/internal/collector/fd.go +++ b/internal/collector/fd.go @@ -188,11 +188,32 @@ func (c *FDCollector) Snapshot() any { entries = entries[:MaxFDEntriesPerSnapshot] } + // Populate CurrentFDs and FDLimit for the top-N entries (capped to avoid + // /proc overhead on systems with thousands of processes). + const procReadCap = 5 + for i := range entries { + if i >= procReadCap { + break + } + if n, err := CountProcFDs(entries[i].PID); err == nil { + entries[i].CurrentFDs = n + } + if lim, err := ReadProcFDLimit(entries[i].PID); err == nil && lim > 0 { + entries[i].FDLimit = lim + } + } + + topCurrentFDs := 0 + if len(entries) > 0 { + topCurrentFDs = entries[0].CurrentFDs + } + return &FDSnapshot{ - Entries: entries, - TotalOpens: totalOpens, - TotalCloses: totalCloses, - NetDelta: netDelta, - GrowthRate: growthRate, + Entries: entries, + TotalOpens: totalOpens, + TotalCloses: totalCloses, + NetDelta: netDelta, + GrowthRate: growthRate, + TopPIDCurrentFDs: topCurrentFDs, } } diff --git a/internal/collector/proc_fd.go b/internal/collector/proc_fd.go new file mode 100644 index 0000000..d07e87c --- /dev/null +++ b/internal/collector/proc_fd.go @@ -0,0 +1,60 @@ +// Copyright 2026 Optiqor contributors +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +var procDir = "/proc" + +// CountProcFDs counts open file descriptors for pid by listing /proc//fd. +// Returns (count, nil) on success; (0, err) if the directory is unreadable +// (process exited, permission denied, etc.). Callers should treat 0 as +// "unknown" rather than "zero open fds". +func CountProcFDs(pid uint32) (int, error) { + dir := fmt.Sprintf("%s/%d/fd", procDir, pid) + f, err := os.Open(dir) + if err != nil { + return 0, err + } + defer f.Close() + // Readdirnames is cheaper than Readdir — no stat per entry. + names, err := f.Readdirnames(-1) + if err != nil { + return 0, err + } + return len(names), nil +} + +// ReadProcFDLimit reads the soft RLIMIT_NOFILE for pid from +// /proc//limits. Returns (limit, nil) on success; (0, err) on failure. +func ReadProcFDLimit(pid uint32) (int, error) { + path := fmt.Sprintf("%s/%d/limits", procDir, pid) + f, err := os.Open(path) + if err != nil { + return 0, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "Max open files") { + continue + } + // Format: "Max open files 65536 65536 files" + fields := strings.Fields(line) + // fields[3] is the soft limit; "unlimited" maps to 0 (treat as unknown). + if len(fields) < 4 || fields[3] == "unlimited" { + return 0, nil + } + return strconv.Atoi(fields[3]) + } + return 0, fmt.Errorf("RLIMIT_NOFILE not found in %s", path) +} diff --git a/internal/collector/proc_fd_test.go b/internal/collector/proc_fd_test.go new file mode 100644 index 0000000..8cf0691 --- /dev/null +++ b/internal/collector/proc_fd_test.go @@ -0,0 +1,144 @@ +// Copyright 2026 Optiqor contributors +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCountProcFDs(t *testing.T) { + // Override procDir + oldProcDir := procDir + tmpDir, err := os.MkdirTemp("", "kerno-proc-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + procDir = tmpDir + defer func() { procDir = oldProcDir }() + + var pid uint32 = 1234 + fdPath := filepath.Join(tmpDir, "1234", "fd") + + // Test case 1: Directory doesn't exist (unknown/process exited) + count, err := CountProcFDs(pid) + if err == nil { + t.Errorf("expected error for non-existent fd directory, got nil") + } + if count != 0 { + t.Errorf("expected count to be 0 on error, got %d", count) + } + + // Create the fd directory + if err := os.MkdirAll(fdPath, 0755); err != nil { + t.Fatalf("failed to create mock fd dir: %v", err) + } + + // Test case 2: Empty directory + count, err = CountProcFDs(pid) + if err != nil { + t.Errorf("unexpected error for empty fd directory: %v", err) + } + if count != 0 { + t.Errorf("expected count to be 0 for empty directory, got %d", count) + } + + // Create mock fd files + files := []string{"0", "1", "2", "3", "4"} + for _, fname := range files { + if err := os.WriteFile(filepath.Join(fdPath, fname), []byte(""), 0644); err != nil { + t.Fatalf("failed to write mock fd file %s: %v", fname, err) + } + } + + // Test case 3: 5 open FDs + count, err = CountProcFDs(pid) + if err != nil { + t.Errorf("unexpected error counting fds: %v", err) + } + if count != len(files) { + t.Errorf("expected count to be %d, got %d", len(files), count) + } +} + +func TestReadProcFDLimit(t *testing.T) { + // Override procDir + oldProcDir := procDir + tmpDir, err := os.MkdirTemp("", "kerno-proc-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + procDir = tmpDir + defer func() { procDir = oldProcDir }() + + var pid uint32 = 1234 + limitsDir := filepath.Join(tmpDir, "1234") + if err := os.MkdirAll(limitsDir, 0755); err != nil { + t.Fatalf("failed to create limits dir: %v", err) + } + limitsPath := filepath.Join(limitsDir, "limits") + + // Test case 1: Missing limits file + lim, err := ReadProcFDLimit(pid) + if err == nil { + t.Errorf("expected error for missing limits file, got nil") + } + if lim != 0 { + t.Errorf("expected limit to be 0 on error, got %d", lim) + } + + // Test case 2: Valid limits file + mockLimits := `Limit Soft Limit Hard Limit Units +Max cpu time unlimited unlimited seconds +Max open files 65536 65536 files +Max locked memory 8388608 8388608 bytes +` + if err := os.WriteFile(limitsPath, []byte(mockLimits), 0644); err != nil { + t.Fatalf("failed to write mock limits: %v", err) + } + + lim, err = ReadProcFDLimit(pid) + if err != nil { + t.Errorf("unexpected error reading limits: %v", err) + } + if lim != 65536 { + t.Errorf("expected limit to be 65536, got %d", lim) + } + + // Test case 3: Limits file with "unlimited" + mockLimitsUnlimited := `Limit Soft Limit Hard Limit Units +Max cpu time unlimited unlimited seconds +Max open files unlimited unlimited files +` + if err := os.WriteFile(limitsPath, []byte(mockLimitsUnlimited), 0644); err != nil { + t.Fatalf("failed to write mock limits: %v", err) + } + + lim, err = ReadProcFDLimit(pid) + if err != nil { + t.Errorf("unexpected error reading limits: %v", err) + } + if lim != 0 { + t.Errorf("expected limit to be 0 (unknown) for unlimited, got %d", lim) + } + + // Test case 4: Limits file with no Max open files line + mockLimitsMissingLine := `Limit Soft Limit Hard Limit Units +Max cpu time unlimited unlimited seconds +` + if err := os.WriteFile(limitsPath, []byte(mockLimitsMissingLine), 0644); err != nil { + t.Fatalf("failed to write mock limits: %v", err) + } + + lim, err = ReadProcFDLimit(pid) + if err == nil { + t.Errorf("expected error when Max open files is missing, got nil") + } + if lim != 0 { + t.Errorf("expected limit to be 0, got %d", lim) + } +} diff --git a/internal/collector/signals.go b/internal/collector/signals.go index 5ca624e..b04e14c 100644 --- a/internal/collector/signals.go +++ b/internal/collector/signals.go @@ -173,6 +173,10 @@ type FDSnapshot struct { TotalCloses uint64 `json:"totalCloses"` NetDelta int64 `json:"netDelta"` // opens - closes GrowthRate float64 `json:"growthRate"` // FDs per second + + // TopPIDCurrentFDs is the actual open-fd count of the top leaker, read from /proc//fd. + // 0 means it was not available (process exited, permission denied, etc.). + TopPIDCurrentFDs int `json:"topPidCurrentFDs,omitempty"` } // FDEntry represents FD stats for one process. @@ -183,6 +187,11 @@ type FDEntry struct { Closes uint64 `json:"closes"` NetDelta int64 `json:"netDelta"` GrowthRate float64 `json:"growthRate"` // FDs per second + + // CurrentFDs is the live fd count read from /proc//fd at snapshot time. 0 = unavailable. + CurrentFDs int `json:"currentFDs,omitempty"` + // FDLimit is the soft RLIMIT_NOFILE for this PID read from /proc//limits. 0 = unavailable. + FDLimit int `json:"fdLimit,omitempty"` } // ─── Cgroup Memory Snapshot ────────────────────────────────────────────────── diff --git a/internal/doctor/eta.go b/internal/doctor/eta.go index 9e19a8e..2bfb09e 100644 --- a/internal/doctor/eta.go +++ b/internal/doctor/eta.go @@ -24,3 +24,34 @@ func etaDuration(etaSecs float64) (time.Duration, bool) { return eta, true } + +// fdHeadroom returns (remaining, limit, exact) where remaining is the number +// of file descriptors before the process hits its limit, limit is the value +// used as the ceiling, and exact is true when remaining was derived from a +// live /proc read rather than a window delta. +// +// Priority order: +// 1. entry.CurrentFDs + entry.FDLimit — both available: most accurate +// 2. entry.CurrentFDs + default limit — live count, assumed limit +// 3. entry.NetDelta + entry.FDLimit — window delta, known limit (rare) +// 4. entry.NetDelta + default limit — worst case: window delta only +// +// If remaining <= 0, returns (1, limit, exact) so callers don't divide by zero. +func fdHeadroom(currentFDs, netDelta int64, fdLimit int) (remaining float64, limit float64, exact bool) { + const defaultLimit = 65536.0 + limit = defaultLimit + if fdLimit > 0 { + limit = float64(fdLimit) + } + if currentFDs > 0 { + remaining = limit - float64(currentFDs) + exact = true + } else { + remaining = limit - float64(netDelta) + exact = false + } + if remaining <= 0 { + remaining = 1 + } + return +} diff --git a/internal/doctor/eta_test.go b/internal/doctor/eta_test.go index 530ca1f..dd0cc4f 100644 --- a/internal/doctor/eta_test.go +++ b/internal/doctor/eta_test.go @@ -50,3 +50,76 @@ func TestETADurationAcceptsCeiling(t *testing.T) { t.Fatalf("expected %s, got %s", maxMeaningfulETA, eta) } } + +func TestFDHeadroom(t *testing.T) { + tests := []struct { + name string + currentFDs int64 + netDelta int64 + fdLimit int + expectedRemain float64 + expectedLimit float64 + expectedExact bool + }{ + { + name: "Case 1: Both currentFDs and fdLimit set", + currentFDs: 64000, + netDelta: 100, + fdLimit: 70000, + expectedRemain: 6000, + expectedLimit: 70000, + expectedExact: true, + }, + { + name: "Case 2: currentFDs set, fdLimit = 0 (defaults to 65536)", + currentFDs: 64000, + netDelta: 100, + fdLimit: 0, + expectedRemain: 1536, + expectedLimit: 65536, + expectedExact: true, + }, + { + name: "Case 3: currentFDs = 0, fdLimit set (falls back to netDelta with known limit)", + currentFDs: 0, + netDelta: 500, + fdLimit: 80000, + expectedRemain: 79500, + expectedLimit: 80000, + expectedExact: false, + }, + { + name: "Case 4: Neither set (falls back to netDelta with 65536 default limit)", + currentFDs: 0, + netDelta: 500, + fdLimit: 0, + expectedRemain: 65036, + expectedLimit: 65536, + expectedExact: false, + }, + { + name: "Case 5: remaining <= 0 (returns remaining = 1)", + currentFDs: 66000, + netDelta: 100, + fdLimit: 65536, + expectedRemain: 1, + expectedLimit: 65536, + expectedExact: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + remain, limit, exact := fdHeadroom(tt.currentFDs, tt.netDelta, tt.fdLimit) + if remain != tt.expectedRemain { + t.Errorf("expected remaining=%v, got %v", tt.expectedRemain, remain) + } + if limit != tt.expectedLimit { + t.Errorf("expected limit=%v, got %v", tt.expectedLimit, limit) + } + if exact != tt.expectedExact { + t.Errorf("expected exact=%v, got %v", tt.expectedExact, exact) + } + }) + } +} diff --git a/internal/doctor/predict.go b/internal/doctor/predict.go index a4068d0..474b953 100644 --- a/internal/doctor/predict.go +++ b/internal/doctor/predict.go @@ -97,16 +97,23 @@ func predictFDExhaustion(snapshots []*collector.Signals) []Prediction { return nil } - // Assume 65536 ulimit, estimate current count from latest snapshot. + // Estimate current count and limit from latest snapshot using headroom helper. latest := snapshots[len(snapshots)-1] if latest.FD == nil { return nil } - remaining := 65536.0 - float64(latest.FD.NetDelta) - if remaining <= 0 { - remaining = 1 // About to exhaust. + var currentFDs, netDelta int64 + var fdLimit int + if len(latest.FD.Entries) > 0 { + top := latest.FD.Entries[0] + currentFDs = int64(top.CurrentFDs) + netDelta = top.NetDelta + fdLimit = top.FDLimit + } else { + netDelta = latest.FD.NetDelta } + remaining, limit, exact := fdHeadroom(currentFDs, netDelta, fdLimit) etaSecs := remaining / avgRate eta, ok := etaDuration(etaSecs) @@ -117,14 +124,20 @@ func predictFDExhaustion(snapshots []*collector.Signals) []Prediction { // Confidence based on consistency of growth rate. confidence := rateConsistency(rates) + limitStr := fmt.Sprintf("ulimit %d", int(limit)) + currentVal := fmt.Sprintf("growth %.1f FDs/sec, %d open fds", avgRate, int(currentFDs)) + if !exact { + currentVal = fmt.Sprintf("growth %.1f FDs/sec, net delta %d (live count unavailable)", avgRate, latest.FD.NetDelta) + } + return []Prediction{{ Title: "File Descriptor Exhaustion", Signal: "fd", TimeToImpact: eta, Confidence: confidence, - CurrentValue: fmt.Sprintf("growth %.1f FDs/sec, net delta %d", avgRate, latest.FD.NetDelta), + CurrentValue: currentVal, TrendRate: fmt.Sprintf("+%.1f FDs/sec", avgRate), - Limit: "ulimit 65536", + Limit: limitStr, Fix: []string{"Find the leaking process: lsof -p | wc -l", "Check for unclosed connections/files", "Increase ulimit temporarily: ulimit -n 131072"}, }} } diff --git a/internal/doctor/predict_test.go b/internal/doctor/predict_test.go new file mode 100644 index 0000000..cb82b9d --- /dev/null +++ b/internal/doctor/predict_test.go @@ -0,0 +1,117 @@ +// Copyright 2026 Optiqor contributors +// SPDX-License-Identifier: Apache-2.0 + +package doctor + +import ( + "strings" + "testing" + "time" + + "github.com/optiqor/kerno/internal/collector" +) + +func TestPredict_FDExhaustion(t *testing.T) { + now := time.Now() + + t.Run("Accurate", func(t *testing.T) { + snapshots := []*collector.Signals{ + { + Timestamp: now, + FD: &collector.FDSnapshot{ + GrowthRate: 10.0, + }, + }, + { + Timestamp: now.Add(10 * time.Second), + FD: &collector.FDSnapshot{ + GrowthRate: 10.0, + Entries: []collector.FDEntry{ + {PID: 1234, Comm: "leak-proc", GrowthRate: 10.0, NetDelta: 100, CurrentFDs: 64000, FDLimit: 65536}, + }, + }, + }, + } + + report := Predict(snapshots) + var fdPred *Prediction + for i := range report.Predictions { + if report.Predictions[i].Signal == "fd" { + fdPred = &report.Predictions[i] + break + } + } + + if fdPred == nil { + t.Fatal("expected fd prediction, got none") + } + + // limit is 65536. currentFDs is 64000. remaining is 1536. + // avgRate is 10.0. etaSecs is 153.6. + expectedETA := 153600 * time.Millisecond // 153.6s + if fdPred.TimeToImpact != expectedETA { + t.Errorf("expected TimeToImpact to be %v, got %v", expectedETA, fdPred.TimeToImpact) + } + + if fdPred.Limit != "ulimit 65536" { + t.Errorf("expected Limit to be 'ulimit 65536', got %q", fdPred.Limit) + } + + if !strings.Contains(fdPred.CurrentValue, "64000 open fds") { + t.Errorf("expected CurrentValue to contain '64000 open fds', got %q", fdPred.CurrentValue) + } + + if strings.Contains(fdPred.CurrentValue, "live count unavailable") { + t.Errorf("expected CurrentValue to not contain 'live count unavailable', got %q", fdPred.CurrentValue) + } + }) + + t.Run("Estimated", func(t *testing.T) { + snapshots := []*collector.Signals{ + { + Timestamp: now, + FD: &collector.FDSnapshot{ + GrowthRate: 10.0, + }, + }, + { + Timestamp: now.Add(10 * time.Second), + FD: &collector.FDSnapshot{ + GrowthRate: 10.0, + NetDelta: 100, // snapshot level NetDelta + Entries: []collector.FDEntry{ + {PID: 1234, Comm: "leak-proc", GrowthRate: 10.0, NetDelta: 100, CurrentFDs: 0}, + }, + }, + }, + } + + report := Predict(snapshots) + var fdPred *Prediction + for i := range report.Predictions { + if report.Predictions[i].Signal == "fd" { + fdPred = &report.Predictions[i] + break + } + } + + if fdPred == nil { + t.Fatal("expected fd prediction, got none") + } + + // limit is 65536. NetDelta is 100. remaining is 65436. + // avgRate is 10.0. etaSecs is 6543.6. + expectedETA := 6543600 * time.Millisecond // 6543.6s + if fdPred.TimeToImpact != expectedETA { + t.Errorf("expected TimeToImpact to be %v, got %v", expectedETA, fdPred.TimeToImpact) + } + + if fdPred.Limit != "ulimit 65536" { + t.Errorf("expected Limit to be 'ulimit 65536', got %q", fdPred.Limit) + } + + if !strings.Contains(fdPred.CurrentValue, "live count unavailable") { + t.Errorf("expected CurrentValue to contain 'live count unavailable', got %q", fdPred.CurrentValue) + } + }) +} diff --git a/internal/doctor/rules.go b/internal/doctor/rules.go index 93d771b..ce771dc 100644 --- a/internal/doctor/rules.go +++ b/internal/doctor/rules.go @@ -268,16 +268,32 @@ func evalFDLeak(s *collector.Signals, t config.DoctorThresholds) []Finding { Threshold: t.FDGrowthPerSec, } - // Estimate time to ulimit (65536 default). + // Estimate time to fd limit. if s.FD.GrowthRate > 0 { - // Assume 65536 ulimit and current count based on delta. - remainingFDs := 65536.0 - float64(s.FD.NetDelta) - if remainingFDs > 0 { - etaSecs := remainingFDs / s.FD.GrowthRate - if eta, ok := etaDuration(etaSecs); ok { - f.ETA = &eta - f.Impact = fmt.Sprintf("Process will hit ulimit (65536) in %s at current growth rate", f.ETAString()) + // Use the top leaker's stats when available, fall back to snapshot-level values. + var currentFDs, netDelta int64 + var fdLimit int + if len(s.FD.Entries) > 0 { + top := s.FD.Entries[0] + currentFDs = int64(top.CurrentFDs) + netDelta = top.NetDelta + fdLimit = top.FDLimit + } else { + netDelta = s.FD.NetDelta + } + + remaining, limit, exact := fdHeadroom(currentFDs, netDelta, fdLimit) + etaSecs := remaining / s.FD.GrowthRate + if eta, ok := etaDuration(etaSecs); ok { + f.ETA = &eta + qualifier := "" + if !exact { + qualifier = " (estimated from window delta — actual may be lower)" } + f.Impact = fmt.Sprintf( + "Process will hit fd limit (%d) in %s at current growth rate%s", + int(limit), f.ETAString(), qualifier, + ) } } diff --git a/internal/doctor/rules_test.go b/internal/doctor/rules_test.go index c2fe4e9..856637a 100644 --- a/internal/doctor/rules_test.go +++ b/internal/doctor/rules_test.go @@ -4,6 +4,7 @@ package doctor import ( + "strings" "testing" "time" @@ -187,35 +188,87 @@ func TestEvaluate_SchedulerContention_Critical(t *testing.T) { } func TestEvaluate_FDLeak(t *testing.T) { - signals := &collector.Signals{ - FD: &collector.FDSnapshot{ - GrowthRate: 20.0, - TotalOpens: 5000, - TotalCloses: 4400, - NetDelta: 600, - Entries: []collector.FDEntry{ - {PID: 3891, Comm: "app-server", NetDelta: 600, GrowthRate: 20.0}, + t.Run("Accurate", func(t *testing.T) { + signals := &collector.Signals{ + FD: &collector.FDSnapshot{ + GrowthRate: 20.0, + TotalOpens: 5000, + TotalCloses: 4400, + NetDelta: 600, + Entries: []collector.FDEntry{ + {PID: 3891, Comm: "app-server", NetDelta: 600, GrowthRate: 20.0, CurrentFDs: 64000, FDLimit: 65536}, + }, }, - }, - } + } - findings := Evaluate(signals, defaultThresholds()) - found := false - for _, f := range findings { - if f.Rule == "fd_leak" { - found = true - if f.ETA == nil { - t.Error("expected ETA for FD leak finding") + findings := Evaluate(signals, defaultThresholds()) + found := false + for _, f := range findings { + if f.Rule == "fd_leak" { + found = true + if f.ETA == nil { + t.Error("expected ETA for FD leak finding") + } else { + // headroom = 65536 - 64000 = 1536. 1536 / 20.0 = 76.8 seconds. + expectedETA := 76800 * time.Millisecond // 76.8s + if *f.ETA != expectedETA { + t.Errorf("expected ETA %s, got %s", expectedETA, *f.ETA) + } + } + if f.Process != "app-server" { + t.Errorf("expected process=app-server, got %q", f.Process) + } + if strings.Contains(f.Impact, "estimated") { + t.Errorf("expected impact string to not contain 'estimated', got %q", f.Impact) + } + if !strings.Contains(f.Impact, "65536") { + t.Errorf("expected impact string to contain limit '65536', got %q", f.Impact) + } + break } - if f.Process != "app-server" { - t.Errorf("expected process=app-server, got %q", f.Process) + } + if !found { + t.Error("expected fd_leak finding") + } + }) + + t.Run("Estimated", func(t *testing.T) { + signals := &collector.Signals{ + FD: &collector.FDSnapshot{ + GrowthRate: 20.0, + TotalOpens: 5000, + TotalCloses: 4400, + NetDelta: 600, + Entries: []collector.FDEntry{ + {PID: 3891, Comm: "app-server", NetDelta: 600, GrowthRate: 20.0, CurrentFDs: 0}, + }, + }, + } + + findings := Evaluate(signals, defaultThresholds()) + found := false + for _, f := range findings { + if f.Rule == "fd_leak" { + found = true + if f.ETA == nil { + t.Error("expected ETA for FD leak finding") + } else { + // headroom = 65536 - 600 = 64936. 64936 / 20.0 = 3246.8 seconds. + expectedETA := 3246800 * time.Millisecond // 3246.8s + if *f.ETA != expectedETA { + t.Errorf("expected ETA %s, got %s", expectedETA, *f.ETA) + } + } + if !strings.Contains(f.Impact, "estimated from window delta") { + t.Errorf("expected impact string to contain 'estimated from window delta', got %q", f.Impact) + } + break } - break } - } - if !found { - t.Error("expected fd_leak finding") - } + if !found { + t.Error("expected fd_leak finding") + } + }) } func TestEvaluate_SyscallLatencyHigh(t *testing.T) { From c308625fd8cb4706e3d0ab65f9b977f4298b6837 Mon Sep 17 00:00:00 2001 From: Rakshak05 Date: Mon, 8 Jun 2026 14:58:46 +0530 Subject: [PATCH 2/3] fix(doctor): resolve formatting issues and gosec warnings Signed-off-by: Rakshak05 --- internal/collector/proc_fd.go | 4 +- internal/doctor/eta_test.go | 74 ++++++++++++++++----------------- internal/doctor/predict_test.go | 2 +- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/internal/collector/proc_fd.go b/internal/collector/proc_fd.go index d07e87c..4d87d6f 100644 --- a/internal/collector/proc_fd.go +++ b/internal/collector/proc_fd.go @@ -19,7 +19,7 @@ var procDir = "/proc" // "unknown" rather than "zero open fds". func CountProcFDs(pid uint32) (int, error) { dir := fmt.Sprintf("%s/%d/fd", procDir, pid) - f, err := os.Open(dir) + f, err := os.Open(dir) //nolint:gosec // dir is constructed from the controlled procDir if err != nil { return 0, err } @@ -36,7 +36,7 @@ func CountProcFDs(pid uint32) (int, error) { // /proc//limits. Returns (limit, nil) on success; (0, err) on failure. func ReadProcFDLimit(pid uint32) (int, error) { path := fmt.Sprintf("%s/%d/limits", procDir, pid) - f, err := os.Open(path) + f, err := os.Open(path) //nolint:gosec // path is constructed from the controlled procDir if err != nil { return 0, err } diff --git a/internal/doctor/eta_test.go b/internal/doctor/eta_test.go index dd0cc4f..0f38b93 100644 --- a/internal/doctor/eta_test.go +++ b/internal/doctor/eta_test.go @@ -53,58 +53,58 @@ func TestETADurationAcceptsCeiling(t *testing.T) { func TestFDHeadroom(t *testing.T) { tests := []struct { - name string - currentFDs int64 - netDelta int64 - fdLimit int - expectedRemain float64 - expectedLimit float64 - expectedExact bool + name string + currentFDs int64 + netDelta int64 + fdLimit int + expectedRemain float64 + expectedLimit float64 + expectedExact bool }{ { - name: "Case 1: Both currentFDs and fdLimit set", - currentFDs: 64000, - netDelta: 100, - fdLimit: 70000, + name: "Case 1: Both currentFDs and fdLimit set", + currentFDs: 64000, + netDelta: 100, + fdLimit: 70000, expectedRemain: 6000, - expectedLimit: 70000, - expectedExact: true, + expectedLimit: 70000, + expectedExact: true, }, { - name: "Case 2: currentFDs set, fdLimit = 0 (defaults to 65536)", - currentFDs: 64000, - netDelta: 100, - fdLimit: 0, + name: "Case 2: currentFDs set, fdLimit = 0 (defaults to 65536)", + currentFDs: 64000, + netDelta: 100, + fdLimit: 0, expectedRemain: 1536, - expectedLimit: 65536, - expectedExact: true, + expectedLimit: 65536, + expectedExact: true, }, { - name: "Case 3: currentFDs = 0, fdLimit set (falls back to netDelta with known limit)", - currentFDs: 0, - netDelta: 500, - fdLimit: 80000, + name: "Case 3: currentFDs = 0, fdLimit set (falls back to netDelta with known limit)", + currentFDs: 0, + netDelta: 500, + fdLimit: 80000, expectedRemain: 79500, - expectedLimit: 80000, - expectedExact: false, + expectedLimit: 80000, + expectedExact: false, }, { - name: "Case 4: Neither set (falls back to netDelta with 65536 default limit)", - currentFDs: 0, - netDelta: 500, - fdLimit: 0, + name: "Case 4: Neither set (falls back to netDelta with 65536 default limit)", + currentFDs: 0, + netDelta: 500, + fdLimit: 0, expectedRemain: 65036, - expectedLimit: 65536, - expectedExact: false, + expectedLimit: 65536, + expectedExact: false, }, { - name: "Case 5: remaining <= 0 (returns remaining = 1)", - currentFDs: 66000, - netDelta: 100, - fdLimit: 65536, + name: "Case 5: remaining <= 0 (returns remaining = 1)", + currentFDs: 66000, + netDelta: 100, + fdLimit: 65536, expectedRemain: 1, - expectedLimit: 65536, - expectedExact: true, + expectedLimit: 65536, + expectedExact: true, }, } diff --git a/internal/doctor/predict_test.go b/internal/doctor/predict_test.go index cb82b9d..4093324 100644 --- a/internal/doctor/predict_test.go +++ b/internal/doctor/predict_test.go @@ -78,7 +78,7 @@ func TestPredict_FDExhaustion(t *testing.T) { Timestamp: now.Add(10 * time.Second), FD: &collector.FDSnapshot{ GrowthRate: 10.0, - NetDelta: 100, // snapshot level NetDelta + NetDelta: 100, // snapshot level NetDelta Entries: []collector.FDEntry{ {PID: 1234, Comm: "leak-proc", GrowthRate: 10.0, NetDelta: 100, CurrentFDs: 0}, }, From 3bbf417c1f737148e3bd3b7b8fe37a235d7e17a7 Mon Sep 17 00:00:00 2001 From: Rakshak05 Date: Mon, 8 Jun 2026 15:09:10 +0530 Subject: [PATCH 3/3] fix(doctor): format eta_test.go struct literal alignment for gofmt Signed-off-by: Rakshak05 --- internal/doctor/eta_test.go | 72 ++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/internal/doctor/eta_test.go b/internal/doctor/eta_test.go index 0f38b93..d13eaf8 100644 --- a/internal/doctor/eta_test.go +++ b/internal/doctor/eta_test.go @@ -53,58 +53,58 @@ func TestETADurationAcceptsCeiling(t *testing.T) { func TestFDHeadroom(t *testing.T) { tests := []struct { - name string - currentFDs int64 - netDelta int64 - fdLimit int + name string + currentFDs int64 + netDelta int64 + fdLimit int expectedRemain float64 - expectedLimit float64 - expectedExact bool + expectedLimit float64 + expectedExact bool }{ { - name: "Case 1: Both currentFDs and fdLimit set", - currentFDs: 64000, - netDelta: 100, - fdLimit: 70000, + name: "Case 1: Both currentFDs and fdLimit set", + currentFDs: 64000, + netDelta: 100, + fdLimit: 70000, expectedRemain: 6000, - expectedLimit: 70000, - expectedExact: true, + expectedLimit: 70000, + expectedExact: true, }, { - name: "Case 2: currentFDs set, fdLimit = 0 (defaults to 65536)", - currentFDs: 64000, - netDelta: 100, - fdLimit: 0, + name: "Case 2: currentFDs set, fdLimit = 0 (defaults to 65536)", + currentFDs: 64000, + netDelta: 100, + fdLimit: 0, expectedRemain: 1536, - expectedLimit: 65536, - expectedExact: true, + expectedLimit: 65536, + expectedExact: true, }, { - name: "Case 3: currentFDs = 0, fdLimit set (falls back to netDelta with known limit)", - currentFDs: 0, - netDelta: 500, - fdLimit: 80000, + name: "Case 3: currentFDs = 0, fdLimit set (falls back to netDelta with known limit)", + currentFDs: 0, + netDelta: 500, + fdLimit: 80000, expectedRemain: 79500, - expectedLimit: 80000, - expectedExact: false, + expectedLimit: 80000, + expectedExact: false, }, { - name: "Case 4: Neither set (falls back to netDelta with 65536 default limit)", - currentFDs: 0, - netDelta: 500, - fdLimit: 0, + name: "Case 4: Neither set (falls back to netDelta with 65536 default limit)", + currentFDs: 0, + netDelta: 500, + fdLimit: 0, expectedRemain: 65036, - expectedLimit: 65536, - expectedExact: false, + expectedLimit: 65536, + expectedExact: false, }, { - name: "Case 5: remaining <= 0 (returns remaining = 1)", - currentFDs: 66000, - netDelta: 100, - fdLimit: 65536, + name: "Case 5: remaining <= 0 (returns remaining = 1)", + currentFDs: 66000, + netDelta: 100, + fdLimit: 65536, expectedRemain: 1, - expectedLimit: 65536, - expectedExact: true, + expectedLimit: 65536, + expectedExact: true, }, }