Add tests for file_at_fd.go and fix FD lifetime bug in CopyFolderContents

Agent-Logs-Url: https://github.com/kovidgoyal/kitty/sessions/389bb81e-e9f9-4428-a79a-d8d461cfe943

Co-authored-by: kovidgoyal <1308621+kovidgoyal@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-25 11:30:59 +00:00 committed by GitHub
parent 851ec96979
commit 6000226cdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 726 additions and 11 deletions

View file

@ -557,7 +557,10 @@ func CopyFolderContents(ctx context.Context, src_folder *os.File, dest_folder *o
child_name := filepath.Base(rpath)
return func() bool {
pdf := os.NewFile(uintptr(pfd), parent_dir)
defer pdf.Close()
// Use a RefCountedFile so that if st is a directory the fd
// stays alive until the queued item is processed by next_dir.
rcf := NewRefCountedFile(pdf)
defer rcf.Unref()
st, err := StatAt(pdf, child_name)
if err != nil {
return do_one_child(src, dest, child, true)
@ -573,9 +576,7 @@ func CopyFolderContents(ctx context.Context, src_folder *os.File, dest_folder *o
}
return true
}
// we dont care aboutleaking a ref counted file here
// since the defer above is closing the actual open file.
return do_one_child(NewRefCountedFile(pdf), dest, st, true)
return do_one_child(rcf, dest, st, true)
}()
} else {
target, err := ReadLinkAt(src.File(), child.Name())

View file

@ -1,15 +1,360 @@
// License: GPLv3 Copyright: 2025, Kovid Goyal, <kovid at kovidgoyal.net>
package utils
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"runtime"
"syscall"
"testing"
)
// openTestDir opens the directory at path for the test, closing it on cleanup.
func openTestDir(t *testing.T, path string) *os.File {
t.Helper()
d, err := os.Open(path)
if err != nil {
t.Fatalf("openTestDir %s: %v", path, err)
}
t.Cleanup(func() { d.Close() })
return d
}
// mustReadFile returns the contents of path or fatals the test.
func mustReadFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile %s: %v", path, err)
}
return string(b)
}
// countOpenFDs returns the number of open file descriptors in the process.
// It uses /proc/self/fd and is only meaningful on Linux; returns -1 elsewhere.
func countOpenFDs() int {
if runtime.GOOS != "linux" {
return -1
}
entries, err := os.ReadDir("/proc/self/fd")
if err != nil {
return -1
}
return len(entries)
}
// --- Tests for individual API functions ---
func TestMkdirAt(t *testing.T) {
tmp := t.TempDir()
d := openTestDir(t, tmp)
if err := MkdirAt(d, "newdir", 0755); err != nil {
t.Fatalf("MkdirAt: %v", err)
}
info, err := os.Stat(filepath.Join(tmp, "newdir"))
if err != nil {
t.Fatalf("stat after MkdirAt: %v", err)
}
if !info.IsDir() {
t.Error("expected a directory")
}
// Creating same directory again should return a PathError wrapping EEXIST.
err = MkdirAt(d, "newdir", 0755)
if err == nil {
t.Fatal("expected error for duplicate MkdirAt")
}
var pe *fs.PathError
if !errors.As(err, &pe) {
t.Errorf("expected *fs.PathError, got %T: %v", err, err)
}
}
func TestOpenAt(t *testing.T) {
tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "hello.txt"), []byte("world"), 0644)
d := openTestDir(t, tmp)
f, err := OpenAt(d, "hello.txt")
if err != nil {
t.Fatalf("OpenAt: %v", err)
}
defer f.Close()
buf := make([]byte, 5)
n, _ := f.Read(buf)
if got := string(buf[:n]); got != "world" {
t.Errorf("OpenAt read: got %q, want %q", got, "world")
}
if _, err = OpenAt(d, "nonexistent.txt"); err == nil {
t.Error("expected error for non-existent file")
}
}
func TestOpenDirAt(t *testing.T) {
tmp := t.TempDir()
os.Mkdir(filepath.Join(tmp, "subdir"), 0755)
os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("x"), 0644)
d := openTestDir(t, tmp)
sub, err := OpenDirAt(d, "subdir")
if err != nil {
t.Fatalf("OpenDirAt: %v", err)
}
defer sub.Close()
info, _ := sub.Stat()
if !info.IsDir() {
t.Error("OpenDirAt: expected directory")
}
// Opening a regular file as a directory should fail.
if _, err = OpenDirAt(d, "file.txt"); err == nil {
t.Error("expected error opening regular file as directory")
}
}
func TestCreateAt(t *testing.T) {
tmp := t.TempDir()
d := openTestDir(t, tmp)
f, err := CreateAt(d, "new.txt", 0644)
if err != nil {
t.Fatalf("CreateAt: %v", err)
}
f.WriteString("hello")
f.Close()
if got := mustReadFile(t, filepath.Join(tmp, "new.txt")); got != "hello" {
t.Errorf("CreateAt content: got %q, want %q", got, "hello")
}
// CreateAt on an existing file should truncate it.
f2, err := CreateAt(d, "new.txt", 0644)
if err != nil {
t.Fatalf("CreateAt (truncate): %v", err)
}
f2.WriteString("bye")
f2.Close()
if got := mustReadFile(t, filepath.Join(tmp, "new.txt")); got != "bye" {
t.Errorf("CreateAt truncate: got %q, want %q", got, "bye")
}
}
func TestCreateDirAt(t *testing.T) {
tmp := t.TempDir()
d := openTestDir(t, tmp)
sub, err := CreateDirAt(d, "mydir", 0755)
if err != nil {
t.Fatalf("CreateDirAt new: %v", err)
}
sub.Close()
// Should succeed when the directory already exists.
sub2, err := CreateDirAt(d, "mydir", 0700)
if err != nil {
t.Fatalf("CreateDirAt existing: %v", err)
}
sub2.Close()
}
func TestSymlinkAt(t *testing.T) {
tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "target.txt"), []byte("data"), 0644)
d := openTestDir(t, tmp)
if err := SymlinkAt(d, "link.txt", "target.txt"); err != nil {
t.Fatalf("SymlinkAt: %v", err)
}
got, err := os.Readlink(filepath.Join(tmp, "link.txt"))
if err != nil {
t.Fatalf("Readlink: %v", err)
}
if got != "target.txt" {
t.Errorf("symlink target: got %q, want %q", got, "target.txt")
}
// Duplicate symlink should fail.
if err = SymlinkAt(d, "link.txt", "target.txt"); err == nil {
t.Error("expected error for duplicate symlink")
}
}
func TestStatAt(t *testing.T) {
tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("content"), 0644)
os.Symlink("file.txt", filepath.Join(tmp, "link.txt"))
d := openTestDir(t, tmp)
// StatAt follows symlinks.
info, err := StatAt(d, "link.txt")
if err != nil {
t.Fatalf("StatAt: %v", err)
}
if info.Mode()&os.ModeSymlink != 0 {
t.Error("StatAt should dereference symlinks")
}
if !info.Mode().IsRegular() {
t.Error("StatAt: expected regular file after following symlink")
}
if _, err = StatAt(d, "nonexistent"); err == nil {
t.Error("expected error for non-existent file")
}
}
func TestLstatAt(t *testing.T) {
tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("content"), 0644)
os.Symlink("file.txt", filepath.Join(tmp, "link.txt"))
d := openTestDir(t, tmp)
// LstatAt must not follow symlinks.
info, err := LstatAt(d, "link.txt")
if err != nil {
t.Fatalf("LstatAt symlink: %v", err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Error("LstatAt should not dereference symlinks")
}
info2, err := LstatAt(d, "file.txt")
if err != nil {
t.Fatalf("LstatAt file: %v", err)
}
if !info2.Mode().IsRegular() {
t.Error("LstatAt file: expected regular file")
}
}
func TestUnlinkAt(t *testing.T) {
tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "todelete.txt"), []byte("x"), 0644)
d := openTestDir(t, tmp)
if err := UnlinkAt(d, "todelete.txt"); err != nil {
t.Fatalf("UnlinkAt: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "todelete.txt")); !os.IsNotExist(err) {
t.Error("file should be removed after UnlinkAt")
}
if err := UnlinkAt(d, "nonexistent"); err == nil {
t.Error("expected error for non-existent file")
}
}
func TestRemoveDirAt(t *testing.T) {
tmp := t.TempDir()
os.Mkdir(filepath.Join(tmp, "empty"), 0755)
os.Mkdir(filepath.Join(tmp, "nonempty"), 0755)
os.WriteFile(filepath.Join(tmp, "nonempty", "f"), []byte(""), 0644)
d := openTestDir(t, tmp)
if err := RemoveDirAt(d, "empty"); err != nil {
t.Fatalf("RemoveDirAt empty: %v", err)
}
if _, err := os.Stat(filepath.Join(tmp, "empty")); !os.IsNotExist(err) {
t.Error("empty dir should be removed")
}
// Non-empty directory must fail.
if err := RemoveDirAt(d, "nonempty"); err == nil {
t.Error("expected error removing non-empty directory")
}
}
func TestLinkAt(t *testing.T) {
tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "original.txt"), []byte("data"), 0644)
d := openTestDir(t, tmp)
if err := LinkAt(d, "original.txt", d, "hardlink.txt", false); err != nil {
t.Fatalf("LinkAt: %v", err)
}
info1, _ := os.Stat(filepath.Join(tmp, "original.txt"))
info2, _ := os.Stat(filepath.Join(tmp, "hardlink.txt"))
s1 := info1.Sys().(*syscall.Stat_t)
s2 := info2.Sys().(*syscall.Stat_t)
if s1.Ino != s2.Ino {
t.Error("hard link should share the same inode")
}
}
func TestReadLinkAt(t *testing.T) {
tmp := t.TempDir()
os.Symlink("/absolute/path", filepath.Join(tmp, "abslink"))
os.Symlink("relative/path", filepath.Join(tmp, "rellink"))
os.WriteFile(filepath.Join(tmp, "regular"), []byte("x"), 0644)
d := openTestDir(t, tmp)
abs, err := ReadLinkAt(d, "abslink")
if err != nil {
t.Fatalf("ReadLinkAt abs: %v", err)
}
if abs != "/absolute/path" {
t.Errorf("abs link: got %q, want %q", abs, "/absolute/path")
}
rel, err := ReadLinkAt(d, "rellink")
if err != nil {
t.Fatalf("ReadLinkAt rel: %v", err)
}
if rel != "relative/path" {
t.Errorf("rel link: got %q, want %q", rel, "relative/path")
}
// ReadLinkAt on a regular file must fail.
if _, err = ReadLinkAt(d, "regular"); err == nil {
t.Error("expected error for ReadLinkAt on regular file")
}
}
func TestDupFile(t *testing.T) {
tmp := t.TempDir()
f, err := os.CreateTemp(tmp, "dup")
if err != nil {
t.Fatal(err)
}
defer f.Close()
f.WriteString("original")
dup, err := DupFile(f)
if err != nil {
t.Fatalf("DupFile: %v", err)
}
defer dup.Close()
// Read from the duplicate.
dup.Seek(0, 0)
buf := make([]byte, 8)
n, _ := dup.Read(buf)
if got := string(buf[:n]); got != "original" {
t.Errorf("dup read: got %q, want %q", got, "original")
}
// Closing the dup should not affect the original.
dup.Close()
f.Seek(0, 0)
buf2 := make([]byte, 8)
n2, _ := f.Read(buf2)
if got := string(buf2[:n2]); got != "original" {
t.Errorf("original after dup close: got %q, want %q", got, "original")
}
}
// --- RemoveChildren tests (pre-existing, kept for reference) ---
func TestRemoveChildren(t *testing.T) {
tmpDir := t.TempDir()
// Create nested structure
subDir := filepath.Join(tmpDir, "subdir")
os.Mkdir(subDir, 0755)
os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("data"), 0644)
@ -24,19 +369,14 @@ func TestRemoveChildren(t *testing.T) {
if err := RemoveChildren(d); err != nil {
t.Errorf("expected no error, got %v", err)
}
// Verify directory is empty
entries, _ := os.ReadDir(tmpDir)
if len(entries) != 0 {
t.Errorf("expected 0 entries, got %d", len(entries))
t.Errorf("expected 0 entries after RemoveChildren, got %d", len(entries))
}
}
func TestRemoveChildren_FirstError(t *testing.T) {
tmpDir := t.TempDir()
// Create a read-only file to trigger an error on some systems
// (Note: Behavior varies by OS; this is a conceptual test for firstErr)
lockedFile := filepath.Join(tmpDir, "locked")
os.WriteFile(lockedFile, nil, 0000)
@ -50,3 +390,377 @@ func TestRemoveChildren_FirstError(t *testing.T) {
}
}
}
// --- CopyFolderContents tests ---
// buildDeepTree creates the following 3-level structure under base:
//
// base/
//
// file1.txt ("level1")
// link_to_file1 -> file1.txt
// subdir1/
// file2.txt ("level2")
// subdir2/
// file3.txt ("level3")
// link_to_top -> ../../file1.txt
// subdir3/
// file4.txt ("deepest")
func buildDeepTree(t *testing.T, base string) {
t.Helper()
must := func(err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
must(os.WriteFile(filepath.Join(base, "file1.txt"), []byte("level1"), 0644))
must(os.Symlink("file1.txt", filepath.Join(base, "link_to_file1")))
sub1 := filepath.Join(base, "subdir1")
must(os.Mkdir(sub1, 0755))
must(os.WriteFile(filepath.Join(sub1, "file2.txt"), []byte("level2"), 0644))
sub2 := filepath.Join(sub1, "subdir2")
must(os.Mkdir(sub2, 0755))
must(os.WriteFile(filepath.Join(sub2, "file3.txt"), []byte("level3"), 0644))
must(os.Symlink("../../file1.txt", filepath.Join(sub2, "link_to_top")))
sub3 := filepath.Join(sub2, "subdir3")
must(os.Mkdir(sub3, 0755))
must(os.WriteFile(filepath.Join(sub3, "file4.txt"), []byte("deepest"), 0644))
}
func TestCopyFolderContents_NoHardlinks_NoFollowSymlinks(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "src")
dst := filepath.Join(tmp, "dst")
os.MkdirAll(src, 0755)
os.MkdirAll(dst, 0755)
buildDeepTree(t, src)
srcDir := openTestDir(t, src)
dstDir := openTestDir(t, dst)
err := CopyFolderContents(context.Background(), srcDir, dstDir, CopyFolderOptions{
Disallow_hardlinks: true,
Follow_symlinks: false,
})
if err != nil {
t.Fatalf("CopyFolderContents: %v", err)
}
// All files should be present with correct contents.
cases := []struct{ path, want string }{
{"file1.txt", "level1"},
{"subdir1/file2.txt", "level2"},
{"subdir1/subdir2/file3.txt", "level3"},
{"subdir1/subdir2/subdir3/file4.txt", "deepest"},
}
for _, c := range cases {
got := mustReadFile(t, filepath.Join(dst, c.path))
if got != c.want {
t.Errorf("%s: got %q, want %q", c.path, got, c.want)
}
}
// Symlinks should be copied verbatim (not resolved).
symlinks := []struct{ path, want string }{
{"link_to_file1", "file1.txt"},
{"subdir1/subdir2/link_to_top", "../../file1.txt"},
}
for _, s := range symlinks {
got, err := os.Readlink(filepath.Join(dst, s.path))
if err != nil {
t.Fatalf("readlink %s: %v", s.path, err)
}
if got != s.want {
t.Errorf("symlink %s: got target %q, want %q", s.path, got, s.want)
}
}
// With Disallow_hardlinks, files must have different inodes from source.
srcInfo, _ := os.Stat(filepath.Join(src, "file1.txt"))
dstInfo, _ := os.Stat(filepath.Join(dst, "file1.txt"))
if srcInfo.Sys().(*syscall.Stat_t).Ino == dstInfo.Sys().(*syscall.Stat_t).Ino {
t.Error("files should not share inodes when Disallow_hardlinks=true")
}
}
func TestCopyFolderContents_WithHardlinks(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "src")
dst := filepath.Join(tmp, "dst")
os.MkdirAll(src, 0755)
os.MkdirAll(dst, 0755)
os.WriteFile(filepath.Join(src, "file.txt"), []byte("data"), 0644)
os.Mkdir(filepath.Join(src, "sub"), 0755)
os.WriteFile(filepath.Join(src, "sub", "file2.txt"), []byte("data2"), 0644)
srcDir := openTestDir(t, src)
dstDir := openTestDir(t, dst)
err := CopyFolderContents(context.Background(), srcDir, dstDir, CopyFolderOptions{
Disallow_hardlinks: false,
Follow_symlinks: false,
})
if err != nil {
t.Fatalf("CopyFolderContents: %v", err)
}
// Regular files must share inodes (hardlinked).
for _, name := range []string{"file.txt", "sub/file2.txt"} {
sInfo, _ := os.Stat(filepath.Join(src, name))
dInfo, _ := os.Stat(filepath.Join(dst, name))
sIno := sInfo.Sys().(*syscall.Stat_t).Ino
dIno := dInfo.Sys().(*syscall.Stat_t).Ino
if sIno != dIno {
t.Errorf("%s: expected same inode (hardlink), got src=%d dst=%d", name, sIno, dIno)
}
}
}
// TestCopyFolderContents_SymlinkLoop verifies that a symlink creating a
// directory loop (a -> .) is correctly handled: the already-copied directory
// appears as a relative symlink in the destination.
func TestCopyFolderContents_SymlinkLoop(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "src")
dst := filepath.Join(tmp, "dst")
os.MkdirAll(src, 0755)
os.MkdirAll(dst, 0755)
// src/loop -> . (points back to src itself, creating a loop)
os.Symlink(".", filepath.Join(src, "loop"))
os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello"), 0644)
srcDir := openTestDir(t, src)
dstDir := openTestDir(t, dst)
err := CopyFolderContents(context.Background(), srcDir, dstDir, CopyFolderOptions{
Disallow_hardlinks: true,
Follow_symlinks: true,
})
if err != nil {
t.Fatalf("CopyFolderContents with loop: %v", err)
}
// file.txt must be present with correct content.
if got := mustReadFile(t, filepath.Join(dst, "file.txt")); got != "hello" {
t.Errorf("file.txt: got %q, want %q", got, "hello")
}
// loop must exist and be a symlink (not cause infinite recursion).
linfo, err := os.Lstat(filepath.Join(dst, "loop"))
if err != nil {
t.Fatalf("lstat dst/loop: %v", err)
}
if linfo.Mode()&os.ModeSymlink == 0 {
t.Error("dst/loop should be a symlink (loop broken by relative back-reference)")
}
}
// TestCopyFolderContents_FollowSymlinks_DirectorySymlink tests that when
// Follow_symlinks is true and a symlink points to a subdirectory, the
// directory contents are copied (and the underlying fd is kept alive long
// enough for next_dir to process the queued item — the key bug being tested).
func TestCopyFolderContents_FollowSymlinks_DirectorySymlink(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "src")
dst := filepath.Join(tmp, "dst")
os.MkdirAll(src, 0755)
os.MkdirAll(dst, 0755)
// src/subdir/ with a file, and src/link_to_subdir -> subdir
subdir := filepath.Join(src, "subdir")
os.Mkdir(subdir, 0755)
os.WriteFile(filepath.Join(subdir, "inner.txt"), []byte("inner"), 0644)
os.Symlink("subdir", filepath.Join(src, "link_to_subdir"))
srcDir := openTestDir(t, src)
dstDir := openTestDir(t, dst)
err := CopyFolderContents(context.Background(), srcDir, dstDir, CopyFolderOptions{
Disallow_hardlinks: true,
Follow_symlinks: true,
})
if err != nil {
t.Fatalf("CopyFolderContents (follow symlink to dir): %v", err)
}
// The real subdir must be copied.
if got := mustReadFile(t, filepath.Join(dst, "subdir", "inner.txt")); got != "inner" {
t.Errorf("subdir/inner.txt: got %q, want %q", got, "inner")
}
}
// TestCopyFolderContents_DeepTree_FollowSymlinks copies the 3-level tree
// with Follow_symlinks=true. Symlinks that cross directory boundaries are
// resolved; symlinks whose targets resolve to the same source file become a
// relative symlink in the destination pointing at the already-copied file.
func TestCopyFolderContents_DeepTree_FollowSymlinks(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "src")
dst := filepath.Join(tmp, "dst")
os.MkdirAll(src, 0755)
os.MkdirAll(dst, 0755)
buildDeepTree(t, src)
srcDir := openTestDir(t, src)
dstDir := openTestDir(t, dst)
err := CopyFolderContents(context.Background(), srcDir, dstDir, CopyFolderOptions{
Disallow_hardlinks: true,
Follow_symlinks: true,
})
if err != nil {
t.Fatalf("CopyFolderContents (follow): %v", err)
}
// Deep regular files must be present.
for _, c := range []struct{ path, want string }{
{"file1.txt", "level1"},
{"subdir1/file2.txt", "level2"},
{"subdir1/subdir2/file3.txt", "level3"},
{"subdir1/subdir2/subdir3/file4.txt", "deepest"},
} {
got := mustReadFile(t, filepath.Join(dst, c.path))
if got != c.want {
t.Errorf("%s: got %q, want %q", c.path, got, c.want)
}
}
}
// TestCopyFolderContents_FilterFiles checks that Filter_files can exclude entries.
func TestCopyFolderContents_FilterFiles(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "src")
dst := filepath.Join(tmp, "dst")
os.MkdirAll(src, 0755)
os.MkdirAll(dst, 0755)
os.WriteFile(filepath.Join(src, "keep.txt"), []byte("keep"), 0644)
os.WriteFile(filepath.Join(src, "skip.txt"), []byte("skip"), 0644)
srcDir := openTestDir(t, src)
dstDir := openTestDir(t, dst)
err := CopyFolderContents(context.Background(), srcDir, dstDir, CopyFolderOptions{
Disallow_hardlinks: true,
Filter_files: func(_ *os.File, fi os.FileInfo) bool {
return fi.Name() != "skip.txt"
},
})
if err != nil {
t.Fatalf("CopyFolderContents (filter): %v", err)
}
if _, err := os.Stat(filepath.Join(dst, "keep.txt")); err != nil {
t.Error("keep.txt should be present")
}
if _, err := os.Stat(filepath.Join(dst, "skip.txt")); !os.IsNotExist(err) {
t.Error("skip.txt should have been filtered out")
}
}
// TestCopyFolderContents_CancelledContext verifies that cancellation is
// respected and does not leak file descriptors.
func TestCopyFolderContents_CancelledContext(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("FD leak check requires Linux /proc/self/fd")
}
tmp := t.TempDir()
src := filepath.Join(tmp, "src")
dst := filepath.Join(tmp, "dst")
os.MkdirAll(src, 0755)
os.MkdirAll(dst, 0755)
buildDeepTree(t, src)
srcDir := openTestDir(t, src)
dstDir := openTestDir(t, dst)
ctx, cancel := context.WithCancel(context.Background())
cancel() // pre-cancel
before := countOpenFDs()
_ = CopyFolderContents(ctx, srcDir, dstDir, CopyFolderOptions{
Disallow_hardlinks: true,
})
after := countOpenFDs()
if after > before {
t.Errorf("FD leak after cancelled copy: before=%d after=%d", before, after)
}
}
// TestCopyFolderContents_FDLeaks_Normal verifies that a complete, successful
// copy does not leak file descriptors.
func TestCopyFolderContents_FDLeaks_Normal(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("FD leak check requires Linux /proc/self/fd")
}
tmp := t.TempDir()
src := filepath.Join(tmp, "src")
dst := filepath.Join(tmp, "dst")
os.MkdirAll(src, 0755)
os.MkdirAll(dst, 0755)
buildDeepTree(t, src)
srcDir := openTestDir(t, src)
dstDir := openTestDir(t, dst)
before := countOpenFDs()
err := CopyFolderContents(context.Background(), srcDir, dstDir, CopyFolderOptions{
Disallow_hardlinks: true,
Follow_symlinks: false,
})
after := countOpenFDs()
if err != nil {
t.Fatalf("CopyFolderContents: %v", err)
}
if after > before {
t.Errorf("FD leak in normal copy: before=%d after=%d", before, after)
}
}
// TestCopyFolderContents_FDLeaks_FollowSymlinks_DirSymlink exercises the
// code path that was previously buggy: a symlink pointing to a directory when
// Follow_symlinks=true. Without the fix, the directory fd would be closed
// before next_dir processed the queue item. This test verifies both
// correctness (copy succeeds) and the absence of FD leaks.
func TestCopyFolderContents_FDLeaks_FollowSymlinks_DirSymlink(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("FD leak check requires Linux /proc/self/fd")
}
tmp := t.TempDir()
src := filepath.Join(tmp, "src")
dst := filepath.Join(tmp, "dst")
os.MkdirAll(src, 0755)
os.MkdirAll(dst, 0755)
subdir := filepath.Join(src, "subdir")
os.Mkdir(subdir, 0755)
os.WriteFile(filepath.Join(subdir, "deep.txt"), []byte("deep"), 0644)
// Symlink pointing to the subdirectory — the key bug scenario.
os.Symlink("subdir", filepath.Join(src, "link_to_subdir"))
srcDir := openTestDir(t, src)
dstDir := openTestDir(t, dst)
before := countOpenFDs()
err := CopyFolderContents(context.Background(), srcDir, dstDir, CopyFolderOptions{
Disallow_hardlinks: true,
Follow_symlinks: true,
})
after := countOpenFDs()
if err != nil {
t.Fatalf("CopyFolderContents (dir symlink): %v", err)
}
if after > before {
t.Errorf("FD leak with dir symlink: before=%d after=%d", before, after)
}
// The real subdir contents must appear in the destination.
if got := mustReadFile(t, filepath.Join(dst, "subdir", "deep.txt")); got != "deep" {
t.Errorf("subdir/deep.txt: got %q, want %q", got, "deep")
}
}