diff --git a/.changes/v1.16/BUG FIXES-20260324-113149.yaml b/.changes/v1.16/BUG FIXES-20260324-113149.yaml new file mode 100644 index 000000000000..9af27e6e04ec --- /dev/null +++ b/.changes/v1.16/BUG FIXES-20260324-113149.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: Close module manifest snapshot files after writing to avoid leaking file descriptors. +time: 2026-03-24T11:31:49-07:00 +custom: + Issue: "38302" diff --git a/internal/modsdir/manifest.go b/internal/modsdir/manifest.go index c320b063a9f9..5e1823d4aef5 100644 --- a/internal/modsdir/manifest.go +++ b/internal/modsdir/manifest.go @@ -5,6 +5,7 @@ package modsdir import ( "encoding/json" + "errors" "fmt" "io" "io/ioutil" @@ -172,12 +173,15 @@ func (m Manifest) WriteSnapshot(w io.Writer) error { return err } -func (m Manifest) WriteSnapshotToDir(dir string) error { +func (m Manifest) WriteSnapshotToDir(dir string) (retErr error) { fn := filepath.Join(dir, ManifestSnapshotFilename) log.Printf("[TRACE] modsdir: writing modules manifest to %s", fn) w, err := os.Create(fn) if err != nil { return err } + defer func() { + retErr = errors.Join(retErr, w.Close()) + }() return m.WriteSnapshot(w) } diff --git a/internal/modsdir/manifest_test.go b/internal/modsdir/manifest_test.go new file mode 100644 index 000000000000..14766fc696fb --- /dev/null +++ b/internal/modsdir/manifest_test.go @@ -0,0 +1,73 @@ +//go:build darwin || linux + +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package modsdir + +import ( + "fmt" + "os" + "path/filepath" + "runtime/debug" + "testing" + + "golang.org/x/sys/unix" +) + +func TestManifestWriteSnapshotToDirClosesFile(t *testing.T) { + oldGCPercent := debug.SetGCPercent(-1) + defer debug.SetGCPercent(oldGCPercent) + + manifest := Manifest{ + "root": { + Key: "root", + Dir: "modules/root", + }, + } + + baseDir := t.TempDir() + before := countOpenFileDescriptors(t) + + const iterations = 32 + for i := range iterations { + dir := filepath.Join(baseDir, fmt.Sprintf("manifest-%d", i)) + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatalf("creating manifest dir %d: %s", i, err) + } + + if err := manifest.WriteSnapshotToDir(dir); err != nil { + t.Fatalf("writing manifest %d: %s", i, err) + } + } + + after := countOpenFileDescriptors(t) + if leaked := after - before; leaked > 2 { + t.Fatalf("expected WriteSnapshotToDir to close its file descriptor, but open descriptor count increased by %d", leaked) + } +} + +func countOpenFileDescriptors(t *testing.T) int { + t.Helper() + + var limit unix.Rlimit + if err := unix.Getrlimit(unix.RLIMIT_NOFILE, &limit); err != nil { + t.Fatalf("reading RLIMIT_NOFILE: %s", err) + } + + maxFD := int(limit.Cur) + if maxFD > 4096 { + maxFD = 4096 + } + + openDescriptors := 0 + for fd := range maxFD { + if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); err == nil { + openDescriptors++ + } else if err != unix.EBADF { + t.Fatalf("checking file descriptor %d: %s", fd, err) + } + } + + return openDescriptors +}