From 3483c1879328356c1884e27779592d750842065e Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Fri, 17 Apr 2026 22:14:33 +0200 Subject: [PATCH] feat(fdleak): added support for macos (darwin) This PR extends the NoFileDescriptorLeak assertion to support darwin, in addition to linux. NOTE: Windows OS remains unsupported for now * doc: updated documentation and ROADMAP Signed-off-by: Frederic BIDON --- assert/assert_assertions.go | 7 +- docs/doc-site/api/safety.md | 11 +- docs/doc-site/project/maintainers/ROADMAP.md | 22 ++- docs/doc-site/usage/CHANGES.md | 6 +- docs/doc-site/usage/TRACKING.md | 27 ++- internal/assertions/safety.go | 22 ++- internal/assertions/safety_test.go | 8 +- internal/fdleak/doc.go | 42 +++-- internal/fdleak/fdleak.go | 103 +++++++----- internal/fdleak/fdleak_darwin.go | 129 +++++++++++++++ internal/fdleak/fdleak_darwin_test.go | 147 +++++++++++++++++ internal/fdleak/fdleak_linux.go | 61 +++++++ internal/fdleak/fdleak_linux_test.go | 133 +++++++++++++++ internal/fdleak/fdleak_test.go | 163 +++++-------------- internal/fdleak/fdleak_unsupported.go | 16 ++ require/require_assertions.go | 7 +- 16 files changed, 687 insertions(+), 217 deletions(-) create mode 100644 internal/fdleak/fdleak_darwin.go create mode 100644 internal/fdleak/fdleak_darwin_test.go create mode 100644 internal/fdleak/fdleak_linux.go create mode 100644 internal/fdleak/fdleak_linux_test.go create mode 100644 internal/fdleak/fdleak_unsupported.go diff --git a/assert/assert_assertions.go b/assert/assert_assertions.go index 7d78b3485..6d87540d1 100644 --- a/assert/assert_assertions.go +++ b/assert/assert_assertions.go @@ -2029,15 +2029,16 @@ func NoError(t T, err error, msgAndArgs ...any) bool { // NoFileDescriptorLeak ensures that no file descriptor leaks from inside the tested function. // -// This assertion works on Linux only (via /proc/self/fd). +// This assertion works on Linux (via /proc/self/fd) and macOS (via fstat probing). // On other platforms, the test is skipped. // // NOTE: this assertion is not compatible with parallel tests. // File descriptors are a process-wide resource; concurrent tests // opening files would cause false positives. // -// Sockets, pipes, and anonymous inodes are filtered out by default, -// as these are typically managed by the Go runtime. +// Sockets, pipes, and other kernel-internal descriptors (Linux anon_inode, +// darwin kqueue) are filtered out by default, as these are typically +// managed by the Go runtime. // // # Concurrency // diff --git a/docs/doc-site/api/safety.md b/docs/doc-site/api/safety.md index 66aca29c4..8ace37c05 100644 --- a/docs/doc-site/api/safety.md +++ b/docs/doc-site/api/safety.md @@ -30,15 +30,16 @@ This domain exposes 2 functionalities. ### NoFileDescriptorLeak{#nofiledescriptorleak} NoFileDescriptorLeak ensures that no file descriptor leaks from inside the tested function. -This assertion works on Linux only (via /proc/self/fd). +This assertion works on Linux (via /proc/self/fd) and macOS (via fstat probing). On other platforms, the test is skipped. NOTE: this assertion is not compatible with parallel tests. File descriptors are a process-wide resource; concurrent tests opening files would cause false positives. -Sockets, pipes, and anonymous inodes are filtered out by default, -as these are typically managed by the Go runtime. +Sockets, pipes, and other kernel-internal descriptors (Linux anon_inode, +darwin kqueue) are filtered out by default, as these are typically +managed by the Go runtime. #### Concurrency @@ -174,7 +175,7 @@ func main() { |--|--| | [`assertions.NoFileDescriptorLeak(t T, tested func(), msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#NoFileDescriptorLeak) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NoFileDescriptorLeak](https://github.com/go-openapi/testify/blob/master/internal/assertions/safety.go#L100) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NoFileDescriptorLeak](https://github.com/go-openapi/testify/blob/master/internal/assertions/safety.go#L110) {{% /tab %}} {{< /tabs >}} @@ -429,7 +430,7 @@ func (m *mockFailNowT) Failed() bool { |--|--| | [`assertions.NoGoRoutineLeak(t T, tested func(), msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/internal/assertions#NoGoRoutineLeak) | internal implementation | -**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NoGoRoutineLeak](https://github.com/go-openapi/testify/blob/master/internal/assertions/safety.go#L47) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NoGoRoutineLeak](https://github.com/go-openapi/testify/blob/master/internal/assertions/safety.go#L56) {{% /tab %}} {{< /tabs >}} diff --git a/docs/doc-site/project/maintainers/ROADMAP.md b/docs/doc-site/project/maintainers/ROADMAP.md index 69fb99166..8e91b8f0a 100644 --- a/docs/doc-site/project/maintainers/ROADMAP.md +++ b/docs/doc-site/project/maintainers/ROADMAP.md @@ -33,17 +33,31 @@ timeline : NoGoRoutineLeak : more documentation and examples ✅ v2.4 (Mar 2026) : Stabilize API (no more removals) - : NoFileDescriptorLeak (unix) + : NoFileDescriptorLeak (Linux) : Eventually, Eventually (with context), Consistently : Migration tool section Q2 2026 - 📝 v2.5 (May 2026) : synctest opt-in for Eventually/Never/Consistently/EventuallyWith (done) - : NoFileDescriptorLeak (macOS, Windows) - : New candidate features from upstream + 📝 v2.5 (May 2026) : synctest opt-in for Eventually, Never, Consistently, EventuallyWith + : NoFileDescriptorLeak (macOS) : export internal tools (spew, difflib) + : New candidate features from upstream : go1.25+ + 🔍 v2.6 (June 2026) : (tentative) + : go build guards (codegen) + : ErrorAsType (go1.26+) {{< /mermaid >}} +## Dropped enveavors + +For the moment, and after some research, we punt on the following features. +We might reconsider these choices in the future, but for now, we are unsure about whether they are worth the added complexity. + +* Enrich `CollectT` (either as an interface or an extended type that wraps `testing.TB`) - for `EventuallyWith` + (see also [#1862](https://github.com/stretchr/testify/issues/1862)). +* Expose the internal go routine leak detection package as a drop-in replacement for `go.uber.org/go-leak` +* Port `NoFileDescriptorLeak` to Windows OS +* Consider hijacking [msgAndArgs ...any] to pass options into assertions + ## Notes 1. [x] The first release comes with zero dependencies and an unstable API (see below [our use case](#usage-at-go-openapi)) diff --git a/docs/doc-site/usage/CHANGES.md b/docs/doc-site/usage/CHANGES.md index 7f25fc213..5bdbfdcdc 100644 --- a/docs/doc-site/usage/CHANGES.md +++ b/docs/doc-site/usage/CHANGES.md @@ -408,7 +408,7 @@ Removed extraneous type declaration `PanicTestFunc` (`func()`). | Function | Type | Description | |----------|------|-------------| | `NoGoRoutineLeak` | Reflection | Assert that no goroutines leak from a tested function | -| `NoFileDescriptorLeak` | Reflection | Assert that no file descriptors leak from a tested function (Linux) | +| `NoFileDescriptorLeak` | Reflection | Assert that no file descriptors leak from a tested function (Linux, macOS) | #### Implementation @@ -418,7 +418,9 @@ Removed extraneous type declaration `PanicTestFunc` (`func()`). - No configuration or filter lists needed - Works safely with `t.Parallel()` -`NoFileDescriptorLeak` compares open file descriptors before and after the tested function (Linux only, via `/proc/self/fd`). +`NoFileDescriptorLeak` compares open file descriptors before and after the tested function. +Linux uses `/proc/self/fd`; macOS probes the process FD table with `fstat` and resolves vnode paths via `fcntl(F_GETPATH)`. +On other platforms the assertion skips cleanly. See [Examples](./EXAMPLES.md#goroutine-leak-detection) for usage patterns. diff --git a/docs/doc-site/usage/TRACKING.md b/docs/doc-site/usage/TRACKING.md index 2b02e96ec..804b405f1 100644 --- a/docs/doc-site/usage/TRACKING.md +++ b/docs/doc-site/usage/TRACKING.md @@ -16,6 +16,7 @@ We continue to monitor and selectively adopt changes from the upstream repositor - ✅ [#1828] - Spew panic fixes - ✅ [#1825], [#1818], [#1223], [#1813], [#1611], [#1822], [#1829] - Various bug fixes - ✅ [#1606], [#1087] - Consistently assertion +- ✅ [#1848] - Subset error message ### Monitoring - 🔍 [#1601] - `NoFieldIsZero` @@ -34,6 +35,7 @@ We continue to monitor and selectively adopt changes from the upstream repositor [#1824]: https://github.com/stretchr/testify/pull/1824 [#1819]: https://github.com/stretchr/testify/pull/1819 [#1845]: https://github.com/stretchr/testify/pull/1845 +[#1848]: https://github.com/stretchr/testify/pull/1848 **Review frequency**: Quarterly (next review: May 2026) @@ -82,6 +84,7 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | [#1813] | Issue | Panic with unexported fields | ✅ Fixed via #1828 in internalized spew | | [#1087] | Issue | Consistently assertion | ✅ Adapted | | [#1606] | PR | Consistently assertion | ✅ Adapted | +| [#1848] | PR | Subset (garbled error message) | ✅ Adapted | [#994]: https://github.com/stretchr/testify/pull/994 [#1232]: https://github.com/stretchr/testify/pull/1232 @@ -95,6 +98,7 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif [#1829]: https://github.com/stretchr/testify/issues/1829 [#1087]: https://github.com/stretchr/testify/issues/1087 [#1606]: https://github.com/stretchr/testify/pull/1606 +[#1848]: https://github.com/stretchr/testify/pull/1848 ### Superseded by Our Implementation @@ -103,10 +107,8 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif | [#1845] | PR | Fix Eventually/Never regression | Superseded by context-based pollCondition implementation (we don't have this bug) | | [#1819] | PR | Handle unexpected exits in Eventually | Implemented in v2.4 via per-tick goroutine wrap — a `runtime.Goexit` in the condition only aborts the current tick | | [#1824] | PR | Spew testing improvements | Superseded by property-based fuzzing with random type generator | -| [#1830] | PR | CollectT.Halt() for stopping tests | Implemented in v2.4 as `CollectT.Cancel()` — see [CHANGES](./CHANGES.md) | +| [#1830] | PR | `CollectT.Halt()` for stopping tests | Implemented in v2.4 as `CollectT.Cancel()` — see [CHANGES](./CHANGES.md) | -[#1819]: https://github.com/stretchr/testify/pull/1819 -[#1845]: https://github.com/stretchr/testify/pull/1845 ### Under Consideration (Monitoring) @@ -114,6 +116,9 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif |-----------|------|---------|--------| | [#1601] | Issue | `NoFieldIsZero` assertion | 🔍 Monitoring - Considering implementation | | [#1840] | Issue | JSON presence check without exact values | 🔍 Monitoring - Interesting for testing APIs with generated IDs | +| [#1859] | Issue | Channel assertions | 🔍 Monitoring - aligned with synctest support | +| [#1860] | Issue+PR | `ErrorAsType[E]` for Go 1.26+ - PR: [#1861] | 🔍 Monitoring - Interesting UX syntax | +| [#1863] | PR | Number equality with symmetric role | 🔍 Monitoring | ### Informational (Not Implemented) @@ -121,19 +126,27 @@ This table catalogs all upstream PRs and issues from [github.com/stretchr/testif |-----------|------|---------|---------| | [#1147] | Issue | General discussion about generics adoption | â„šī¸ Marked "Not Planned" upstream - We implemented our own generics approach ({{% siteparam "metrics.generics" %}} functions) | | [#1308] | PR | Comprehensive refactor with generic type parameters | â„šī¸ Draft for v2.0.0 upstream - We took a different approach with the same objective | +| [#1862] | Issue | `CollectT` extension/redesign | 🔍 Monitoring - Breaking change | +[#1819]: https://github.com/stretchr/testify/pull/1819 +[#1845]: https://github.com/stretchr/testify/pull/1845 [#1147]: https://github.com/stretchr/testify/issues/1147 [#1308]: https://github.com/stretchr/testify/pull/1308 +[#1859]: https://github.com/stretchr/testify/pull/1859 +[#1860]: https://github.com/stretchr/testify/pull/1860 +[#1861]: https://github.com/stretchr/testify/pull/1861 +[#1862]: https://github.com/stretchr/testify/pull/1862 +[#1863]: https://github.com/stretchr/testify/pull/1863 ### Summary Statistics | Category | Count | |----------|-------| -| **Implemented/Merged** | 23 | +| **Implemented/Merged** | 24 | | **Superseded** | 4 | -| **Monitoring** | 2 | -| **Informational** | 2 | -| **Total Processed** | 31 | +| **Monitoring** | 5 | +| **Informational** | 3 | +| **Total Processed** | 36 | **Note**: This fork maintains an active relationship with upstream, regularly reviewing new PRs and issues. The quarterly review process ensures we stay informed about upstream developments while maintaining our architectural independence. diff --git a/internal/assertions/safety.go b/internal/assertions/safety.go index 7cac1a6ba..f47b47346 100644 --- a/internal/assertions/safety.go +++ b/internal/assertions/safety.go @@ -11,7 +11,16 @@ import ( "github.com/go-openapi/testify/v2/internal/leak" ) -const linuxOS = "linux" +// fdLeakSupported reports whether the current platform has an fdleak +// implementation. Mirrors the build tags in internal/fdleak. +func fdLeakSupported() bool { + switch runtime.GOOS { + case "linux", "darwin": + return true + default: + return false + } +} // NoGoRoutineLeak ensures that no goroutine did leak from inside the tested function. // @@ -69,15 +78,16 @@ func NoGoRoutineLeak(t T, tested func(), msgAndArgs ...any) bool { // NoFileDescriptorLeak ensures that no file descriptor leaks from inside the tested function. // -// This assertion works on Linux only (via /proc/self/fd). +// This assertion works on Linux (via /proc/self/fd) and macOS (via fstat probing). // On other platforms, the test is skipped. // // NOTE: this assertion is not compatible with parallel tests. // File descriptors are a process-wide resource; concurrent tests // opening files would cause false positives. // -// Sockets, pipes, and anonymous inodes are filtered out by default, -// as these are typically managed by the Go runtime. +// Sockets, pipes, and other kernel-internal descriptors (Linux anon_inode, +// darwin kqueue) are filtered out by default, as these are typically +// managed by the Go runtime. // // # Concurrency // @@ -103,9 +113,9 @@ func NoFileDescriptorLeak(t T, tested func(), msgAndArgs ...any) bool { h.Helper() } - if runtime.GOOS != linuxOS { + if !fdLeakSupported() { if s, ok := t.(skipper); ok { - s.Skip("NoFileDescriptorLeak requires Linux (/proc/self/fd)") + s.Skip("NoFileDescriptorLeak is not supported on " + runtime.GOOS) } return true diff --git a/internal/assertions/safety_test.go b/internal/assertions/safety_test.go index 4f52c4fcf..d3f4a7f63 100644 --- a/internal/assertions/safety_test.go +++ b/internal/assertions/safety_test.go @@ -70,8 +70,8 @@ func TestNoFileDescriptorLeak_Success(t *testing.T) { } func TestNoFileDescriptorLeak_Failure(t *testing.T) { - if runtime.GOOS != linuxOS { - t.Skip("file descriptor leak detection requires Linux") + if !fdLeakSupported() { + t.Skipf("file descriptor leak detection is not supported on %s", runtime.GOOS) } mockT := new(mockT) @@ -103,8 +103,8 @@ func TestNoFileDescriptorLeak_Failure(t *testing.T) { } func TestNoFileDescriptorLeak_SocketFiltered(t *testing.T) { - if runtime.GOOS != linuxOS { - t.Skip("file descriptor leak detection requires Linux") + if !fdLeakSupported() { + t.Skipf("file descriptor leak detection is not supported on %s", runtime.GOOS) } mockT := new(mockT) diff --git a/internal/fdleak/doc.go b/internal/fdleak/doc.go index a891abe85..75dc78022 100644 --- a/internal/fdleak/doc.go +++ b/internal/fdleak/doc.go @@ -3,20 +3,30 @@ // Package fdleak provides file descriptor leak detection. // -// It uses /proc/self/fd snapshots on Linux to take a snapshot -// of open file descriptors before and after -// running the tested function. Any file descriptors present in the -// "after" snapshot but not in the "before" snapshot are considered leaks. -// -// By default, sockets, pipes, and anonymous inodes are filtered out, -// as these are typically managed by the Go runtime or OS internals. -// -// This approach is inherently process-wide: /proc/self/fd lists all -// file descriptors for the process. Any concurrent I/O from other -// goroutines may cause false positives. A mutex serializes [Leaked] -// calls to prevent multiple leak checks from interfering with each -// other, but cannot protect against external concurrent file operations. -// -// This package only works on Linux. On other platforms, -// [Snapshot] returns an error. +// It takes a snapshot of open file descriptors before and after running the tested function. +// +// Any file descriptors present in the "after" snapshot but not in the "before" snapshot +// — and not of a filtered [Kind] — are considered leaks. +// +// # Platform support +// +// - Linux: enumerates /proc/self/fd and classifies FDs from the +// readlink target (socket:/pipe:/anon_inode:/ path). +// - darwin: enumerates /dev/fd and resolves each FD via fcntl(F_GETPATH), +// falling back to fstat to classify sockets, pipes and kqueues. +// - other: [Snapshot] returns an error. +// +// # Filtering +// +// Sockets, pipes and other kernel-internal descriptors (Linux anon_inode, +// darwin kqueue) are excluded from leak reports by default, as these are +// typically managed by the Go runtime or external libraries. +// +// # Concurrency +// +// This approach is inherently process-wide: the FD table lists all file descriptors for the process. +// +// Any concurrent I/O from other goroutines may cause false positives. +// A mutex serializes [Leaked] calls to prevent multiple leak checks from interfering with each other, +// but cannot protect against external concurrent file operations. package fdleak diff --git a/internal/fdleak/fdleak.go b/internal/fdleak/fdleak.go index dcc8f1b38..1a1810756 100644 --- a/internal/fdleak/fdleak.go +++ b/internal/fdleak/fdleak.go @@ -4,85 +4,98 @@ package fdleak import ( - "errors" "fmt" - "os" - "runtime" "sort" - "strconv" "strings" "sync" ) +// Kind classifies an open file descriptor independently of platform-specific target-string conventions. +type Kind int + +const ( + // KindUnknown is used when the descriptor kind could not be determined. + KindUnknown Kind = iota + + // KindFile denotes anything backed by a path in the file system: + // regular files, directories, symlinks, block devices. + KindFile + + // KindSocket denotes a socket (AF_UNIX, AF_INET, â€Ļ). + KindSocket + + // KindPipe denotes a pipe or FIFO. + KindPipe + + // KindChar denotes a character device without a resolvable path + // (fallback for darwin when F_GETPATH fails on a char device). + KindChar + + // KindOther covers kernel-level descriptors that are not directly + // opened by user code: Linux anon_inode (epoll, timerfd, â€Ļ), darwin + // kqueue, and similar. + KindOther +) + // FDInfo describes an open file descriptor. +// +// It should remain a human-readable description: a file-system path for vnode-backed FDs, +// or a synthetic label such as "socket:[]" for non-vnode kinds. +// +// [Kind] is the authoritative classification; use it rather than parsing Target when filtering. type FDInfo struct { FD int - Target string // readlink target (e.g. "/tmp/foo.txt", "socket:[12345]") + Kind Kind + Target string } -// isFiltered returns true if this FD should be excluded from leak reports. -// Sockets, pipes, and anonymous inodes are filtered out by default. +// isFiltered reports whether this FD should be excluded from leak reports. +// Sockets, pipes, and other kernel-internal descriptors are filtered by default, +// because they are typically managed by the Go runtime or external libraries +// and their lifecycles do not correlate with user-level resource management. func (f FDInfo) isFiltered() bool { - return strings.HasPrefix(f.Target, "socket:[") || - strings.HasPrefix(f.Target, "pipe:[") || - strings.HasPrefix(f.Target, "anon_inode:[") + switch f.Kind { + case KindSocket, KindPipe, KindOther: + return true + default: + return false + } } // snapshotMu serializes Leaked calls to prevent false positives // from concurrent tests. -var snapshotMu sync.Mutex //nolint:gochecknoglobals // serializes process-wide /proc/self/fd access - -const procSelfFD = "/proc/self/fd" +var snapshotMu sync.Mutex //nolint:gochecknoglobals // serializes process-wide FD table access -// Snapshot reads /proc/self/fd and returns a map of currently open file descriptors. +// Snapshot returns a map of currently open file descriptors for the +// running process. +// +// The set of supported platforms is determined at build time; see the +// per-platform implementations (fdleak_linux.go, fdleak_darwin.go). // -// FDs that close between ReadDir and Readlink are silently skipped. -// Returns an error if not running on Linux. +// On unsupported platforms, Snapshot returns an error. +// +// FDs that close between enumeration and resolution are silently skipped. func Snapshot() (map[int]FDInfo, error) { - if runtime.GOOS != "linux" { - return nil, errors.New("file descriptor leak detection requires Linux (/proc/self/fd)") - } - - entries, err := os.ReadDir(procSelfFD) - if err != nil { - return nil, fmt.Errorf("reading %s: %w", procSelfFD, err) - } - - fds := make(map[int]FDInfo, len(entries)) - for _, e := range entries { - fd, err := strconv.Atoi(e.Name()) - if err != nil { - continue - } - - target, err := os.Readlink(procSelfFD + "/" + e.Name()) - if err != nil { - continue // FD closed between ReadDir and Readlink - } - - fds[fd] = FDInfo{FD: fd, Target: target} - } - - return fds, nil + return snapshot() } // Leaked takes a before/after snapshot around the tested function // and returns a formatted description of leaked file descriptors. // // Returns the empty string if no leaks are found. -// The caller is responsible for checking [runtime.GOOS] before calling. +// On unsupported platforms, Leaked returns an error. func Leaked(tested func()) (string, error) { snapshotMu.Lock() defer snapshotMu.Unlock() - before, err := Snapshot() + before, err := snapshot() if err != nil { return "", err } tested() - after, err := Snapshot() + after, err := snapshot() if err != nil { return "", err } @@ -93,7 +106,7 @@ func Leaked(tested func()) (string, error) { } // Diff returns file descriptors present in after but not in before, -// excluding filtered FD types (sockets, pipes, anonymous inodes). +// excluding filtered FD kinds (sockets, pipes, other kernel descriptors). func Diff(before, after map[int]FDInfo) []FDInfo { var leaked []FDInfo diff --git a/internal/fdleak/fdleak_darwin.go b/internal/fdleak/fdleak_darwin.go new file mode 100644 index 000000000..8e3432281 --- /dev/null +++ b/internal/fdleak/fdleak_darwin.go @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package fdleak + +import ( + "fmt" + "math" + "syscall" + "unsafe" +) + +const ( + // fGetPath is the darwin fcntl command to retrieve the vnode path of a + // file descriptor (see ). + fGetPath = 50 + + // maxPathLen is darwin's MAXPATHLEN. + maxPathLen = 1024 + + // fdProbeCap bounds the linear FD scan. Typical darwin RLIMIT_NOFILE soft + // limits range from 256 to 10240; real test processes rarely hold more than + // a few dozen FDs, so a 4096 ceiling caps the cost at a few thousand fstat + // syscalls per snapshot (single-digit milliseconds). + fdProbeCap = uint64(4096) +) + +// snapshot enumerates open file descriptors on darwin by probing FDs 0..N +// with fstat. Unlike Linux's /proc/self/fd, darwin's /dev/fd is a devfs mount +// that does not cooperate with Go's os.ReadDir fallback to fstatat, so we +// bypass directory enumeration and probe FD numbers directly. +// +// For each live FD: +// +// - fcntl(F_GETPATH) resolves vnode-backed paths (regular files, devices). +// - fstat classifies non-vnode kinds (sockets, pipes, kqueues, â€Ļ) and +// yields a synthetic target string compatible with the Linux output. +// +// FDs that close between fstat and fcntl are silently skipped. +func snapshot() (map[int]FDInfo, error) { + var rlim syscall.Rlimit + if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlim); err != nil { + return nil, fmt.Errorf("getrlimit(RLIMIT_NOFILE): %w", err) + } + + limit := int(min(rlim.Cur, fdProbeCap, uint64(math.MaxInt))) //nolint:gosec // the min guarantees that limit doesn't overflow int + + fds := make(map[int]FDInfo) + for fd := range limit { + info, ok := resolveFD(fd) + if !ok { + continue + } + + fds[fd] = info + } + + return fds, nil +} + +// resolveFD builds an FDInfo for fd. It returns false when the FD is not open +// (fstat returns EBADF) or the FD was closed mid-probe. +func resolveFD(fd int) (FDInfo, bool) { + // Classify first via fstat: this is also our liveness check (EBADF means + // "not open"). Querying F_GETPATH on a closed FD returns a similar error, + // but ordering fstat first keeps the closed-FD fast path to a single + // syscall. + var stat syscall.Stat_t + if err := syscall.Fstat(fd, &stat); err != nil { + return FDInfo{}, false + } + + switch stat.Mode & syscall.S_IFMT { + case syscall.S_IFSOCK: + return FDInfo{FD: fd, Kind: KindSocket, Target: fmt.Sprintf("socket:[%d]", stat.Ino)}, true + case syscall.S_IFIFO: + return FDInfo{FD: fd, Kind: KindPipe, Target: fmt.Sprintf("pipe:[%d]", stat.Ino)}, true + } + + // Remaining kinds are vnode-backed; try to resolve the path. + path, pathErr := fcntlGetPath(fd) + + switch stat.Mode & syscall.S_IFMT { + case syscall.S_IFCHR: + if pathErr == nil { + return FDInfo{FD: fd, Kind: KindChar, Target: path}, true + } + + return FDInfo{FD: fd, Kind: KindChar, Target: fmt.Sprintf("char:[%d]", stat.Rdev)}, true + case syscall.S_IFREG, syscall.S_IFDIR, syscall.S_IFLNK, syscall.S_IFBLK: + if pathErr == nil { + return FDInfo{FD: fd, Kind: KindFile, Target: path}, true + } + + return FDInfo{FD: fd, Kind: KindFile, Target: fmt.Sprintf("file:[%d]", stat.Ino)}, true + default: + // kqueue, fsevents, netpolicy, pshm, psem, and other kernel-internal + // descriptors land here. They never expose a vnode path. + return FDInfo{FD: fd, Kind: KindOther, Target: fmt.Sprintf("other:[%d]", stat.Ino)}, true + } +} + +// fcntlGetPath issues fcntl(fd, F_GETPATH, buf) and returns the resolved path. +// +// The unsafe.Pointer → uintptr conversion happens at the syscall.Syscall call +// site so Go's runtime keeps buf pinned for the duration of the call (the +// documented "Case 4" rule for syscall arguments). +func fcntlGetPath(fd int) (string, error) { + var buf [maxPathLen]byte + + _, _, errno := syscall.Syscall( + syscall.SYS_FCNTL, + uintptr(fd), //nolint:gosec // fd is a int and it may be stored in a uintptr by definition of the int type. + uintptr(fGetPath), + uintptr(unsafe.Pointer(&buf[0])), // F_GETPATH requires a raw buffer pointer; lifetime is pinned across the syscall. + ) + if errno != 0 { + return "", errno + } + + n := 0 + for n < len(buf) && buf[n] != 0 { + n++ + } + + return string(buf[:n]), nil +} diff --git a/internal/fdleak/fdleak_darwin_test.go b/internal/fdleak/fdleak_darwin_test.go new file mode 100644 index 000000000..8aa439f82 --- /dev/null +++ b/internal/fdleak/fdleak_darwin_test.go @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package fdleak + +import ( + "context" + "net" + "os" + "strings" + "testing" +) + +func TestSnapshot(t *testing.T) { + fds, err := Snapshot() + if err != nil { + t.Fatalf("Snapshot() error: %v", err) + } + + // stdin, stdout, stderr should always be present. + for _, fd := range []int{0, 1, 2} { + info, ok := fds[fd] + if !ok { + t.Errorf("expected fd %d (stdin/stdout/stderr) in snapshot", fd) + continue + } + if info.Kind == KindUnknown { + t.Errorf("fd %d has KindUnknown; expected a concrete kind (got target=%q)", fd, info.Target) + } + } +} + +func TestLeaked_NoLeak(t *testing.T) { + leaked, err := Leaked(func() { + // Clean function — no file descriptors opened. + }) + if err != nil { + t.Fatalf("Leaked() error: %v", err) + } + + if leaked != "" { + t.Errorf("expected no leaked file descriptors, got:\n%s", leaked) + } +} + +// TestLeaked_WithLeak additionally verifies that fcntl(F_GETPATH) recovers +// the file's absolute path on darwin. +func TestLeaked_WithLeak(t *testing.T) { + var leakedFile *os.File + dir := t.TempDir() + + leaked, err := Leaked(func() { + f, err := os.CreateTemp(dir, "fdleak-test-*") + if err != nil { + t.Fatalf("CreateTemp: %v", err) + } + + leakedFile = f // intentionally not closed + }) + + t.Cleanup(func() { + if leakedFile != nil { + leakedFile.Close() + os.Remove(leakedFile.Name()) + } + }) + + if err != nil { + t.Fatalf("Leaked() error: %v", err) + } + + if leaked == "" { + t.Fatal("expected leaked file descriptor to be detected, but found none") + } + + // F_GETPATH should have resolved the full path of the leaked temp file. + // On darwin t.TempDir() lives under /private/var/... so match on the basename prefix. + if !strings.Contains(leaked, "fdleak-test-") { + t.Errorf("expected leak report to include the resolved file path; got:\n%s", leaked) + } else { + t.Logf("detected leak:\n%s", leaked) + } +} + +// TestLeaked_SocketsFiltered exercises the fstat fallback on darwin: sockets +// have no vnode path so F_GETPATH fails, and they must be classified as +// KindSocket via fstat to be filtered out. +func TestLeaked_SocketsFiltered(t *testing.T) { + var leakedListener net.Listener + + leaked, err := Leaked(func() { + var lc net.ListenConfig + ln, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen: %v", err) + } + + leakedListener = ln // intentionally not closed — socket FD should be filtered + }) + + t.Cleanup(func() { + if leakedListener != nil { + leakedListener.Close() + } + }) + + if err != nil { + t.Fatalf("Leaked() error: %v", err) + } + + if leaked != "" { + t.Errorf("expected socket FD to be filtered, but got:\n%s", leaked) + } +} + +// TestLeaked_PipesFiltered exercises the fstat fallback for pipes on darwin. +func TestLeaked_PipesFiltered(t *testing.T) { + var r, w *os.File + + leaked, err := Leaked(func() { + pr, pw, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + + r, w = pr, pw // intentionally not closed — pipe FDs should be filtered + }) + + t.Cleanup(func() { + if r != nil { + r.Close() + } + if w != nil { + w.Close() + } + }) + + if err != nil { + t.Fatalf("Leaked() error: %v", err) + } + + if leaked != "" { + t.Errorf("expected pipe FDs to be filtered, but got:\n%s", leaked) + } +} diff --git a/internal/fdleak/fdleak_linux.go b/internal/fdleak/fdleak_linux.go new file mode 100644 index 000000000..a4ec2580a --- /dev/null +++ b/internal/fdleak/fdleak_linux.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package fdleak + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +const procSelfFD = "/proc/self/fd" + +// snapshot enumerates open file descriptors via /proc/self/fd. +// +// The readlink target carries the kind on Linux: +// - "socket:[]" → KindSocket +// - "pipe:[]" → KindPipe +// - "anon_inode:[