diff --git a/assert/assert_assertions.go b/assert/assert_assertions.go index b5fc4eea1..715ae64c3 100644 --- a/assert/assert_assertions.go +++ b/assert/assert_assertions.go @@ -1777,6 +1777,45 @@ func NoError(t T, err error, msgAndArgs ...any) bool { return assertions.NoError(t, err, msgAndArgs...) } +// NoFileDescriptorLeak ensures that no file descriptor leaks from inside the tested function. +// +// This assertion works on Linux only (via /proc/self/fd). +// 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. +// +// # Concurrency +// +// [NoFileDescriptorLeak] is not compatible with parallel tests. +// File descriptors are a process-wide resource; any concurrent I/O +// from other goroutines may cause false positives. +// +// Calls to [NoFileDescriptorLeak] are serialized with a mutex +// to prevent multiple leak checks from interfering with each other. +// +// # Usage +// +// NoFileDescriptorLeak(t, func() { +// // code that should not leak file descriptors +// }) +// +// # Examples +// +// success: func() {} +// +// Upon failure, the test [T] is marked as failed and continues execution. +func NoFileDescriptorLeak(t T, tested func(), msgAndArgs ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } + return assertions.NoFileDescriptorLeak(t, tested, msgAndArgs...) +} + // NoGoRoutineLeak ensures that no goroutine did leak from inside the tested function. // // NOTE: only the go routines spawned from inside the tested function are checked for leaks. diff --git a/assert/assert_assertions_test.go b/assert/assert_assertions_test.go index ac6d88f51..2d257ad24 100644 --- a/assert/assert_assertions_test.go +++ b/assert/assert_assertions_test.go @@ -2022,6 +2022,20 @@ func TestNoError(t *testing.T) { }) } +func TestNoFileDescriptorLeak(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := NoFileDescriptorLeak(mock, func() {}) + if !result { + t.Error("NoFileDescriptorLeak should return true on success") + } + }) +} + func TestNoGoRoutineLeak(t *testing.T) { t.Parallel() diff --git a/assert/assert_examples_test.go b/assert/assert_examples_test.go index 7c50bfdda..017862c6f 100644 --- a/assert/assert_examples_test.go +++ b/assert/assert_examples_test.go @@ -12,6 +12,7 @@ import ( "net/url" "path/filepath" "reflect" + "runtime" "slices" "testing" "time" @@ -619,6 +620,23 @@ func ExampleNoError() { // Output: success: true } +func ExampleNoFileDescriptorLeak() { + if runtime.GOOS != "linux" { + // This example is only runnable on linux. On other platforms, the assertion skips the test. + // We force the expected output below, so that tests don't fail on other platforms. + fmt.Println("success: true") + + return + } + + t := new(testing.T) // should come from testing, e.g. func TestNoFileDescriptorLeak(t *testing.T) + success := assert.NoFileDescriptorLeak(t, func() { + }) + fmt.Printf("success: %t\n", success) + + // Output: success: true +} + func ExampleNoGoRoutineLeak() { t := new(testing.T) // should come from testing, e.g. func TestNoGoRoutineLeak(t *testing.T) success := assert.NoGoRoutineLeak(t, func() { diff --git a/assert/assert_format.go b/assert/assert_format.go index 8a3185781..c67756281 100644 --- a/assert/assert_format.go +++ b/assert/assert_format.go @@ -765,6 +765,16 @@ func NoErrorf(t T, err error, msg string, args ...any) bool { return assertions.NoError(t, err, forwardArgs(msg, args)) } +// NoFileDescriptorLeakf is the same as [NoFileDescriptorLeak], but it accepts a format msg string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and continues execution. +func NoFileDescriptorLeakf(t T, tested func(), msg string, args ...any) bool { + if h, ok := t.(H); ok { + h.Helper() + } + return assertions.NoFileDescriptorLeak(t, tested, forwardArgs(msg, args)) +} + // NoGoRoutineLeakf is the same as [NoGoRoutineLeak], but it accepts a format msg string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and continues execution. diff --git a/assert/assert_format_test.go b/assert/assert_format_test.go index ca0ba88ca..ed8dab58f 100644 --- a/assert/assert_format_test.go +++ b/assert/assert_format_test.go @@ -2022,6 +2022,20 @@ func TestNoErrorf(t *testing.T) { }) } +func TestNoFileDescriptorLeakf(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + result := NoFileDescriptorLeakf(mock, func() {}, "test message") + if !result { + t.Error("NoFileDescriptorLeakf should return true on success") + } + }) +} + func TestNoGoRoutineLeakf(t *testing.T) { t.Parallel() diff --git a/assert/assert_forward.go b/assert/assert_forward.go index 37ad23c0e..db60235cf 100644 --- a/assert/assert_forward.go +++ b/assert/assert_forward.go @@ -1110,6 +1110,26 @@ func (a *Assertions) NoErrorf(err error, msg string, args ...any) bool { return assertions.NoError(a.T, err, forwardArgs(msg, args)) } +// NoFileDescriptorLeak is the same as [NoFileDescriptorLeak], as a method rather than a package-level function. +// +// Upon failure, the test [T] is marked as failed and continues execution. +func (a *Assertions) NoFileDescriptorLeak(tested func(), msgAndArgs ...any) bool { + if h, ok := a.T.(H); ok { + h.Helper() + } + return assertions.NoFileDescriptorLeak(a.T, tested, msgAndArgs...) +} + +// NoFileDescriptorLeakf is the same as [Assertions.NoFileDescriptorLeak], but it accepts a format msg string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and continues execution. +func (a *Assertions) NoFileDescriptorLeakf(tested func(), msg string, args ...any) bool { + if h, ok := a.T.(H); ok { + h.Helper() + } + return assertions.NoFileDescriptorLeak(a.T, tested, forwardArgs(msg, args)) +} + // NoGoRoutineLeak is the same as [NoGoRoutineLeak], as a method rather than a package-level function. // // Upon failure, the test [T] is marked as failed and continues execution. diff --git a/assert/assert_forward_test.go b/assert/assert_forward_test.go index 155d6f46d..7e2d87e70 100644 --- a/assert/assert_forward_test.go +++ b/assert/assert_forward_test.go @@ -1560,6 +1560,21 @@ func TestAssertionsNoError(t *testing.T) { }) } +func TestAssertionsNoFileDescriptorLeak(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + a := New(mock) + result := a.NoFileDescriptorLeak(func() {}) + if !result { + t.Error("Assertions.NoFileDescriptorLeak should return true on success") + } + }) +} + func TestAssertionsNoGoRoutineLeak(t *testing.T) { t.Parallel() @@ -3913,6 +3928,21 @@ func TestAssertionsNoErrorf(t *testing.T) { }) } +func TestAssertionsNoFileDescriptorLeakf(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockT) + a := New(mock) + result := a.NoFileDescriptorLeakf(func() {}, "test message") + if !result { + t.Error("Assertions.NoFileDescriptorLeakf should return true on success") + } + }) +} + func TestAssertionsNoGoRoutineLeakf(t *testing.T) { t.Parallel() diff --git a/codegen/internal/generator/templates/assertion_examples_test.gotmpl b/codegen/internal/generator/templates/assertion_examples_test.gotmpl index 8c4eeefd3..cdddf4101 100644 --- a/codegen/internal/generator/templates/assertion_examples_test.gotmpl +++ b/codegen/internal/generator/templates/assertion_examples_test.gotmpl @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "path/filepath" + "runtime" "testing" "github.com/go-openapi/testify/v2/{{ .Package }}" {{ imports .Imports }} @@ -25,6 +26,20 @@ import ( func Example{{ .Name }}() { {{- $fn := . }} + {{- if eq $fn.Name "NoFileDescriptorLeak" }}{{/* handle special case for platform-specific assertion: example only runs on windows */}} + if runtime.GOOS != "linux" { + // This example is only runnable on linux. On other platforms, the assertion skips the test. + // We force the expected output below, so that tests don't fail on other platforms. + {{- if eq $pkg "require" }} + fmt.Println("passed") + {{- else }} + fmt.Println("success: true") + {{- end }} + + return + } + {{ cr false }} + {{- end }} {{- range .Tests }} {{- if .IsSuccess }} {{- cr .IsFirst }} diff --git a/docs/doc-site/api/_index.md b/docs/doc-site/api/_index.md index db63c3327..b5a45daf3 100644 --- a/docs/doc-site/api/_index.md +++ b/docs/doc-site/api/_index.md @@ -43,7 +43,7 @@ Each domain contains assertions regrouped by their use case (e.g. http, json, er - [Number](./number.md) - Asserting Numbers (7) - [Ordering](./ordering.md) - Asserting How Collections Are Ordered (10) - [Panic](./panic.md) - Asserting A Panic Behavior (4) -- [Safety](./safety.md) - Checks Against Leaked Resources (1) +- [Safety](./safety.md) - Checks Against Leaked Resources (Goroutines, File Descriptors) (2) - [String](./string.md) - Asserting Strings (4) - [Testing](./testing.md) - Mimics Methods From The Testing Standard Library (2) - [Time](./time.md) - Asserting Times And Durations (2) diff --git a/docs/doc-site/api/safety.md b/docs/doc-site/api/safety.md index 310f758e8..d8132fdf5 100644 --- a/docs/doc-site/api/safety.md +++ b/docs/doc-site/api/safety.md @@ -1,15 +1,17 @@ --- title: "Safety" -description: "Checks Against Leaked Resources" +description: "Checks Against Leaked Resources (Goroutines, File Descriptors)" weight: 13 domains: - "safety" keywords: + - "NoFileDescriptorLeak" + - "NoFileDescriptorLeakf" - "NoGoRoutineLeak" - "NoGoRoutineLeakf" --- -Checks Against Leaked Resources +Checks Against Leaked Resources (Goroutines, File Descriptors) ## Assertions @@ -18,12 +20,164 @@ Checks Against Leaked Resources _All links point to _ -This domain exposes 1 functionalities. +This domain exposes 2 functionalities. ```tree +- [NoFileDescriptorLeak](#nofiledescriptorleak) | angles-right - [NoGoRoutineLeak](#nogoroutineleak) | angles-right ``` +### NoFileDescriptorLeak{#nofiledescriptorleak} +NoFileDescriptorLeak ensures that no file descriptor leaks from inside the tested function. + +This assertion works on Linux only (via /proc/self/fd). +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. + +#### Concurrency + +[NoFileDescriptorLeak](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#NoFileDescriptorLeak) is not compatible with parallel tests. +File descriptors are a process-wide resource; any concurrent I/O +from other goroutines may cause false positives. + +Calls to [NoFileDescriptorLeak](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#NoFileDescriptorLeak) are serialized with a mutex +to prevent multiple leak checks from interfering with each other. + +{{% expand title="Examples" %}} +{{< tabs >}} +{{% tab title="Usage" %}} +```go + NoFileDescriptorLeak(t, func() { + // code that should not leak file descriptors + }) + success: func() {} +``` +{{< /tab >}} +{{% tab title="Testable Examples (assert)" %}} +{{% cards %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestNoFileDescriptorLeak(t *testing.T) +package main + +import ( + "fmt" + "runtime" + "testing" + + "github.com/go-openapi/testify/v2/assert" +) + +func main() { + if runtime.GOOS != "linux" { + // This example is only runnable on linux. On other platforms, the assertion skips the test. + // We force the expected output below, so that tests don't fail on other platforms. + fmt.Println("success: true") + + return + } + + t := new(testing.T) // should come from testing, e.g. func TestNoFileDescriptorLeak(t *testing.T) + success := assert.NoFileDescriptorLeak(t, func() { + }) + fmt.Printf("success: %t\n", success) + +} + +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{% tab title="Testable Examples (require)" %}} +{{% cards %}} +{{% card %}} + + +*[Copy and click to open Go Playground](https://go.dev/play/)* + + +```go +// real-world test would inject *testing.T from TestNoFileDescriptorLeak(t *testing.T) +package main + +import ( + "fmt" + "runtime" + "testing" + + "github.com/go-openapi/testify/v2/require" +) + +func main() { + if runtime.GOOS != "linux" { + // This example is only runnable on linux. On other platforms, the assertion skips the test. + // We force the expected output below, so that tests don't fail on other platforms. + fmt.Println("passed") + + return + } + + t := new(testing.T) // should come from testing, e.g. func TestNoFileDescriptorLeak(t *testing.T) + require.NoFileDescriptorLeak(t, func() { + }) + fmt.Println("passed") + +} + +``` +{{% /card %}} + + +{{% /cards %}} +{{< /tab >}} + + +{{< /tabs >}} +{{% /expand %}} + +{{< tabs >}} + +{{% tab title="assert" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`assert.NoFileDescriptorLeak(t T, tested func(), msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#NoFileDescriptorLeak) | package-level function | +| [`assert.NoFileDescriptorLeakf(t T, tested func(), msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#NoFileDescriptorLeakf) | formatted variant | +| [`assert.(*Assertions).NoFileDescriptorLeak(tested func()) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.NoFileDescriptorLeak) | method variant | +| [`assert.(*Assertions).NoFileDescriptorLeakf(tested func(), msg string, args ..any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/assert#Assertions.NoFileDescriptorLeakf) | method formatted variant | +{{% /tab %}} +{{% tab title="require" style="secondary" %}} +| Signature | Usage | +|--|--| +| [`require.NoFileDescriptorLeak(t T, tested func(), msgAndArgs ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#NoFileDescriptorLeak) | package-level function | +| [`require.NoFileDescriptorLeakf(t T, tested func(), msg string, args ...any) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#NoFileDescriptorLeakf) | formatted variant | +| [`require.(*Assertions).NoFileDescriptorLeak(tested func()) bool`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.NoFileDescriptorLeak) | method variant | +| [`require.(*Assertions).NoFileDescriptorLeakf(tested func(), msg string, args ..any)`](https://pkg.go.dev/github.com/go-openapi/testify/v2/require#Assertions.NoFileDescriptorLeakf) | method formatted variant | +{{% /tab %}} + +{{% tab title="internal" style="accent" icon="wrench" %}} +| Signature | Usage | +|--|--| +| [`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#L98) +{{% /tab %}} +{{< /tabs >}} + ### NoGoRoutineLeak{#nogoroutineleak} NoGoRoutineLeak ensures that no goroutine did leak from inside the tested function. @@ -275,7 +429,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#L43) +**Source:** [github.com/go-openapi/testify/v2/internal/assertions#NoGoRoutineLeak](https://github.com/go-openapi/testify/blob/master/internal/assertions/safety.go#L45) {{% /tab %}} {{< /tabs >}} diff --git a/enable/colors/go.mod b/enable/colors/go.mod index 6755182c4..d25f7efc5 100644 --- a/enable/colors/go.mod +++ b/enable/colors/go.mod @@ -2,10 +2,10 @@ module github.com/go-openapi/testify/enable/colors/v2 require ( github.com/go-openapi/testify/v2 v2.3.0 - golang.org/x/term v0.39.0 + golang.org/x/term v0.40.0 ) -require golang.org/x/sys v0.40.0 // indirect +require golang.org/x/sys v0.41.0 // indirect replace github.com/go-openapi/testify/v2 => ../.. diff --git a/enable/colors/go.sum b/enable/colors/go.sum index a4e064915..4d79a603a 100644 --- a/enable/colors/go.sum +++ b/enable/colors/go.sum @@ -1,4 +1,2 @@ -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= diff --git a/go.work.sum b/go.work.sum index d6f260c35..f05c4832d 100644 --- a/go.work.sum +++ b/go.work.sum @@ -2,6 +2,7 @@ github.com/go-openapi/testify/v2 v2.0.1/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16p github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= diff --git a/hack/migrate-testify/go.mod b/hack/migrate-testify/go.mod index 5f9e5e892..b649e7d55 100644 --- a/hack/migrate-testify/go.mod +++ b/hack/migrate-testify/go.mod @@ -4,7 +4,7 @@ go 1.24.0 require ( golang.org/x/mod v0.33.0 - golang.org/x/tools v0.41.0 + golang.org/x/tools v0.42.0 ) require golang.org/x/sync v0.19.0 // indirect diff --git a/hack/migrate-testify/go.sum b/hack/migrate-testify/go.sum index 927c44439..b48571944 100644 --- a/hack/migrate-testify/go.sum +++ b/hack/migrate-testify/go.sum @@ -4,5 +4,4 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= diff --git a/internal/assertions/doc.go b/internal/assertions/doc.go index fb136f1d2..873e634e0 100644 --- a/internal/assertions/doc.go +++ b/internal/assertions/doc.go @@ -25,7 +25,7 @@ // - number: asserting numbers // - ordering: asserting how collections are ordered // - panic: asserting a panic behavior -// - safety: checks against leaked resources +// - safety: checks against leaked resources (goroutines, file descriptors) // - string: asserting strings // - testing: mimics methods from the testing standard library // - time: asserting times and durations diff --git a/internal/assertions/ifaces.go b/internal/assertions/ifaces.go index 0b147587a..473ba3d0c 100644 --- a/internal/assertions/ifaces.go +++ b/internal/assertions/ifaces.go @@ -48,3 +48,7 @@ type namer interface { type contextualizer interface { Context() context.Context } + +type skipper interface { + Skip(args ...any) +} diff --git a/internal/assertions/safety.go b/internal/assertions/safety.go index 6dadc720b..31ee06ac8 100644 --- a/internal/assertions/safety.go +++ b/internal/assertions/safety.go @@ -5,7 +5,9 @@ package assertions import ( "context" + "runtime" + "github.com/go-openapi/testify/v2/internal/fdleak" "github.com/go-openapi/testify/v2/internal/leak" ) @@ -62,3 +64,59 @@ func NoGoRoutineLeak(t T, tested func(), msgAndArgs ...any) bool { return Fail(t, "found leaked go routines: "+signature, msgAndArgs...) } + +// NoFileDescriptorLeak ensures that no file descriptor leaks from inside the tested function. +// +// This assertion works on Linux only (via /proc/self/fd). +// 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. +// +// # Concurrency +// +// [NoFileDescriptorLeak] is not compatible with parallel tests. +// File descriptors are a process-wide resource; any concurrent I/O +// from other goroutines may cause false positives. +// +// Calls to [NoFileDescriptorLeak] are serialized with a mutex +// to prevent multiple leak checks from interfering with each other. +// +// # Usage +// +// NoFileDescriptorLeak(t, func() { +// // code that should not leak file descriptors +// }) +// +// # Examples +// +// success: func() {} +func NoFileDescriptorLeak(t T, tested func(), msgAndArgs ...any) bool { + // Domain: safety + if h, ok := t.(H); ok { + h.Helper() + } + + if runtime.GOOS != "linux" { //nolint:goconst // well-known runtime value + if s, ok := t.(skipper); ok { + s.Skip("NoFileDescriptorLeak requires Linux (/proc/self/fd)") + } + + return true + } + + msg, err := fdleak.Leaked(tested) + if err != nil { + return Fail(t, "file descriptor snapshot failed: "+err.Error(), msgAndArgs...) + } + + if msg == "" { + return true + } + + return Fail(t, msg, msgAndArgs...) +} diff --git a/internal/assertions/safety_test.go b/internal/assertions/safety_test.go index 2eb48ffb7..d3f585d2f 100644 --- a/internal/assertions/safety_test.go +++ b/internal/assertions/safety_test.go @@ -4,6 +4,10 @@ package assertions import ( + "context" + "net" + "os" + "runtime" "sync" "testing" ) @@ -49,3 +53,84 @@ func TestNoGoRoutineLeak_Failure(t *testing.T) { t.Error("expected failure to be reported for leaking function") } } + +func TestNoFileDescriptorLeak_Success(t *testing.T) { + mockT := new(mockT) + + result := NoFileDescriptorLeak(mockT, func() { + // Clean function — no file descriptors opened. + }) + + if !result { + t.Error("expected NoFileDescriptorLeak to return true for clean function") + } + if mockT.failed { + t.Error("expected no failure for clean function") + } +} + +func TestNoFileDescriptorLeak_Failure(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("file descriptor leak detection requires Linux") + } + + mockT := new(mockT) + + var leakedFile *os.File + + result := NoFileDescriptorLeak(mockT, func() { + f, err := os.CreateTemp(t.TempDir(), "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()) //nolint:gosec // G703 path traversal is ok: this is a test. + } + }) + + if result { + t.Error("expected NoFileDescriptorLeak to return false for leaking function") + } + if !mockT.failed { + t.Error("expected failure to be reported for leaking function") + } +} + +func TestNoFileDescriptorLeak_SocketFiltered(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("file descriptor leak detection requires Linux") + } + + mockT := new(mockT) + + var leakedListener net.Listener + + result := NoFileDescriptorLeak(mockT, 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 !result { + t.Error("expected socket FD to be filtered, but assertion reported a leak") + } + if mockT.failed { + t.Error("expected no failure when socket FD is filtered") + } +} diff --git a/internal/fdleak/doc.go b/internal/fdleak/doc.go new file mode 100644 index 000000000..a891abe85 --- /dev/null +++ b/internal/fdleak/doc.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// 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. +package fdleak diff --git a/internal/fdleak/fdleak.go b/internal/fdleak/fdleak.go new file mode 100644 index 000000000..dcc8f1b38 --- /dev/null +++ b/internal/fdleak/fdleak.go @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package fdleak + +import ( + "errors" + "fmt" + "os" + "runtime" + "sort" + "strconv" + "strings" + "sync" +) + +// FDInfo describes an open file descriptor. +type FDInfo struct { + FD int + Target string // readlink target (e.g. "/tmp/foo.txt", "socket:[12345]") +} + +// isFiltered returns true if this FD should be excluded from leak reports. +// Sockets, pipes, and anonymous inodes are filtered out by default. +func (f FDInfo) isFiltered() bool { + return strings.HasPrefix(f.Target, "socket:[") || + strings.HasPrefix(f.Target, "pipe:[") || + strings.HasPrefix(f.Target, "anon_inode:[") +} + +// 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" + +// Snapshot reads /proc/self/fd and returns a map of currently open file descriptors. +// +// FDs that close between ReadDir and Readlink are silently skipped. +// Returns an error if not running on Linux. +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 +} + +// 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. +func Leaked(tested func()) (string, error) { + snapshotMu.Lock() + defer snapshotMu.Unlock() + + before, err := Snapshot() + if err != nil { + return "", err + } + + tested() + + after, err := Snapshot() + if err != nil { + return "", err + } + + leaked := Diff(before, after) + + return FormatLeaked(leaked), nil +} + +// Diff returns file descriptors present in after but not in before, +// excluding filtered FD types (sockets, pipes, anonymous inodes). +func Diff(before, after map[int]FDInfo) []FDInfo { + var leaked []FDInfo + + for fd, info := range after { + if _, existed := before[fd]; existed { + continue + } + + if info.isFiltered() { + continue + } + + leaked = append(leaked, info) + } + + sort.Slice(leaked, func(i, j int) bool { + return leaked[i].FD < leaked[j].FD + }) + + return leaked +} + +// FormatLeaked formats leaked file descriptors into a human-readable message. +// Returns the empty string if the slice is empty. +func FormatLeaked(leaked []FDInfo) string { + if len(leaked) == 0 { + return "" + } + + var b strings.Builder + + fmt.Fprintf(&b, "found %d leaked file descriptor(s):\n", len(leaked)) + for _, fd := range leaked { + fmt.Fprintf(&b, " fd %d: %s\n", fd.FD, fd.Target) + } + + return b.String() +} diff --git a/internal/fdleak/fdleak_test.go b/internal/fdleak/fdleak_test.go new file mode 100644 index 000000000..ca1520c6a --- /dev/null +++ b/internal/fdleak/fdleak_test.go @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package fdleak + +import ( + "context" + "net" + "os" + "runtime" + "testing" +) + +func skipIfNotLinux(t *testing.T) { + t.Helper() + + if runtime.GOOS != "linux" { + t.Skip("file descriptor leak detection requires Linux") + } +} + +func TestSnapshot(t *testing.T) { + skipIfNotLinux(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} { + if _, ok := fds[fd]; !ok { + t.Errorf("expected fd %d (stdin/stdout/stderr) in snapshot", fd) + } + } +} + +func TestLeaked_NoLeak(t *testing.T) { + skipIfNotLinux(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) + } +} + +func TestLeaked_WithLeak(t *testing.T) { + skipIfNotLinux(t) + + var leakedFile *os.File + + leaked, err := Leaked(func() { + f, err := os.CreateTemp(t.TempDir(), "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()) //nolint:gosec // G703 path traversal is ok: this is a test. + } + }) + + if err != nil { + t.Fatalf("Leaked() error: %v", err) + } + + if leaked == "" { + t.Error("expected leaked file descriptor to be detected, but found none") + } else { + t.Logf("detected leak:\n%s", leaked) + } +} + +func TestLeaked_SocketsFiltered(t *testing.T) { + skipIfNotLinux(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) + } +} + +func TestDiff(t *testing.T) { + before := map[int]FDInfo{ + 0: {FD: 0, Target: "/dev/stdin"}, + 1: {FD: 1, Target: "/dev/stdout"}, + 2: {FD: 2, Target: "/dev/stderr"}, + 3: {FD: 3, Target: "pipe:[12345]"}, + } + + after := map[int]FDInfo{ + 0: {FD: 0, Target: "/dev/stdin"}, + 1: {FD: 1, Target: "/dev/stdout"}, + 2: {FD: 2, Target: "/dev/stderr"}, + 3: {FD: 3, Target: "pipe:[12345]"}, + 5: {FD: 5, Target: "/tmp/leaked.txt"}, // leaked regular file + 6: {FD: 6, Target: "socket:[67890]"}, // filtered: socket + 7: {FD: 7, Target: "pipe:[11111]"}, // filtered: pipe + 8: {FD: 8, Target: "anon_inode:[eventpoll]"}, // filtered: anon_inode + 9: {FD: 9, Target: "/dev/null"}, // leaked device + } + + leaked := Diff(before, after) + + if len(leaked) != 2 { + t.Fatalf("expected 2 leaked FDs, got %d: %+v", len(leaked), leaked) + } + + // Sorted by FD number. + if leaked[0].FD != 5 || leaked[0].Target != "/tmp/leaked.txt" { + t.Errorf("leaked[0] = %+v, want fd 5 /tmp/leaked.txt", leaked[0]) + } + + if leaked[1].FD != 9 || leaked[1].Target != "/dev/null" { + t.Errorf("leaked[1] = %+v, want fd 9 /dev/null", leaked[1]) + } +} + +func TestDiff_NoLeaks(t *testing.T) { + fds := map[int]FDInfo{ + 0: {FD: 0, Target: "/dev/stdin"}, + 1: {FD: 1, Target: "/dev/stdout"}, + } + + leaked := Diff(fds, fds) + + if len(leaked) != 0 { + t.Errorf("expected no leaks, got %+v", leaked) + } +} + +func TestFormatLeaked(t *testing.T) { + leaked := []FDInfo{ + {FD: 7, Target: "/tmp/unclosed.txt"}, + {FD: 9, Target: "/dev/null"}, + } + + result := FormatLeaked(leaked) + expected := "found 2 leaked file descriptor(s):\n fd 7: /tmp/unclosed.txt\n fd 9: /dev/null\n" + + if result != expected { + t.Errorf("FormatLeaked:\ngot: %q\nwant: %q", result, expected) + } +} + +func TestFormatLeaked_Empty(t *testing.T) { + result := FormatLeaked(nil) + + if result != "" { + t.Errorf("expected empty string for nil input, got %q", result) + } +} diff --git a/internal/testintegration/go.mod b/internal/testintegration/go.mod index 68aecd682..c1dd51051 100644 --- a/internal/testintegration/go.mod +++ b/internal/testintegration/go.mod @@ -10,8 +10,8 @@ require ( ) require ( - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect ) replace ( diff --git a/internal/testintegration/go.sum b/internal/testintegration/go.sum index 3e4fefb60..888079832 100644 --- a/internal/testintegration/go.sum +++ b/internal/testintegration/go.sum @@ -1,9 +1,7 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= diff --git a/require/require_assertions.go b/require/require_assertions.go index dc2171d73..c0698fd6c 100644 --- a/require/require_assertions.go +++ b/require/require_assertions.go @@ -2073,6 +2073,49 @@ func NoError(t T, err error, msgAndArgs ...any) { t.FailNow() } +// NoFileDescriptorLeak ensures that no file descriptor leaks from inside the tested function. +// +// This assertion works on Linux only (via /proc/self/fd). +// 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. +// +// # Concurrency +// +// [NoFileDescriptorLeak] is not compatible with parallel tests. +// File descriptors are a process-wide resource; any concurrent I/O +// from other goroutines may cause false positives. +// +// Calls to [NoFileDescriptorLeak] are serialized with a mutex +// to prevent multiple leak checks from interfering with each other. +// +// # Usage +// +// NoFileDescriptorLeak(t, func() { +// // code that should not leak file descriptors +// }) +// +// # Examples +// +// success: func() {} +// +// Upon failure, the test [T] is marked as failed and stops execution. +func NoFileDescriptorLeak(t T, tested func(), msgAndArgs ...any) { + if h, ok := t.(H); ok { + h.Helper() + } + if assertions.NoFileDescriptorLeak(t, tested, msgAndArgs...) { + return + } + + t.FailNow() +} + // NoGoRoutineLeak ensures that no goroutine did leak from inside the tested function. // // NOTE: only the go routines spawned from inside the tested function are checked for leaks. diff --git a/require/require_assertions_test.go b/require/require_assertions_test.go index b6b60552e..01ade79df 100644 --- a/require/require_assertions_test.go +++ b/require/require_assertions_test.go @@ -1726,6 +1726,18 @@ func TestNoError(t *testing.T) { }) } +func TestNoFileDescriptorLeak(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + NoFileDescriptorLeak(mock, func() {}) + // require functions don't return a value + }) +} + func TestNoGoRoutineLeak(t *testing.T) { t.Parallel() diff --git a/require/require_examples_test.go b/require/require_examples_test.go index 9698fbde4..cae077385 100644 --- a/require/require_examples_test.go +++ b/require/require_examples_test.go @@ -12,6 +12,7 @@ import ( "net/url" "path/filepath" "reflect" + "runtime" "slices" "testing" "time" @@ -620,6 +621,23 @@ func ExampleNoError() { // Output: passed } +func ExampleNoFileDescriptorLeak() { + if runtime.GOOS != "linux" { + // This example is only runnable on linux. On other platforms, the assertion skips the test. + // We force the expected output below, so that tests don't fail on other platforms. + fmt.Println("passed") + + return + } + + t := new(testing.T) // should come from testing, e.g. func TestNoFileDescriptorLeak(t *testing.T) + require.NoFileDescriptorLeak(t, func() { + }) + fmt.Println("passed") + + // Output: passed +} + func ExampleNoGoRoutineLeak() { t := new(testing.T) // should come from testing, e.g. func TestNoGoRoutineLeak(t *testing.T) require.NoGoRoutineLeak(t, func() { diff --git a/require/require_format.go b/require/require_format.go index 22b28c464..1a3bb53ce 100644 --- a/require/require_format.go +++ b/require/require_format.go @@ -1061,6 +1061,20 @@ func NoErrorf(t T, err error, msg string, args ...any) { t.FailNow() } +// NoFileDescriptorLeakf is the same as [NoFileDescriptorLeak], but it accepts a format msg string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and stops execution. +func NoFileDescriptorLeakf(t T, tested func(), msg string, args ...any) { + if h, ok := t.(H); ok { + h.Helper() + } + if assertions.NoFileDescriptorLeak(t, tested, forwardArgs(msg, args)) { + return + } + + t.FailNow() +} + // NoGoRoutineLeakf is the same as [NoGoRoutineLeak], but it accepts a format msg string to format arguments like [fmt.Printf]. // // Upon failure, the test [T] is marked as failed and stops execution. diff --git a/require/require_format_test.go b/require/require_format_test.go index 0a11dd6c8..7799de412 100644 --- a/require/require_format_test.go +++ b/require/require_format_test.go @@ -1726,6 +1726,18 @@ func TestNoErrorf(t *testing.T) { }) } +func TestNoFileDescriptorLeakf(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + NoFileDescriptorLeakf(mock, func() {}, "test message") + // require functions don't return a value + }) +} + func TestNoGoRoutineLeakf(t *testing.T) { t.Parallel() diff --git a/require/require_forward.go b/require/require_forward.go index 834829113..5a4f77bbc 100644 --- a/require/require_forward.go +++ b/require/require_forward.go @@ -1534,6 +1534,34 @@ func (a *Assertions) NoErrorf(err error, msg string, args ...any) { a.T.FailNow() } +// NoFileDescriptorLeak is the same as [NoFileDescriptorLeak], as a method rather than a package-level function. +// +// Upon failure, the test [T] is marked as failed and stops execution. +func (a *Assertions) NoFileDescriptorLeak(tested func(), msgAndArgs ...any) { + if h, ok := a.T.(H); ok { + h.Helper() + } + if assertions.NoFileDescriptorLeak(a.T, tested, msgAndArgs...) { + return + } + + a.T.FailNow() +} + +// NoFileDescriptorLeakf is the same as [Assertions.NoFileDescriptorLeak], but it accepts a format msg string to format arguments like [fmt.Printf]. +// +// Upon failure, the test [T] is marked as failed and stops execution. +func (a *Assertions) NoFileDescriptorLeakf(tested func(), msg string, args ...any) { + if h, ok := a.T.(H); ok { + h.Helper() + } + if assertions.NoFileDescriptorLeak(a.T, tested, forwardArgs(msg, args)) { + return + } + + a.T.FailNow() +} + // NoGoRoutineLeak is the same as [NoGoRoutineLeak], as a method rather than a package-level function. // // Upon failure, the test [T] is marked as failed and stops execution. diff --git a/require/require_forward_test.go b/require/require_forward_test.go index 98032b858..ce42f7f04 100644 --- a/require/require_forward_test.go +++ b/require/require_forward_test.go @@ -1348,6 +1348,19 @@ func TestAssertionsNoError(t *testing.T) { }) } +func TestAssertionsNoFileDescriptorLeak(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + a := New(mock) + a.NoFileDescriptorLeak(func() {}) + // require functions don't return a value + }) +} + func TestAssertionsNoGoRoutineLeak(t *testing.T) { t.Parallel() @@ -3377,6 +3390,19 @@ func TestAssertionsNoErrorf(t *testing.T) { }) } +func TestAssertionsNoFileDescriptorLeakf(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + mock := new(mockFailNowT) + a := New(mock) + a.NoFileDescriptorLeakf(func() {}, "test message") + // require functions don't return a value + }) +} + func TestAssertionsNoGoRoutineLeakf(t *testing.T) { t.Parallel()