Files
seaweedfs/weed/mount/weedfs_file_fallocate_test.go
T
Chris Lu fd493f2547 mount: refuse to attach opens to a draining file handle (#12219)
* mount: refuse to attach opens to a draining file handle

With the writeback cache, releasing the last open of a file leaves the
shared FileHandle in the map at counter zero while an async flush
drains it; the worker removes the map entries once the flush commits.
AcquireHandle waits for a pending flush and then attaches, but the two
steps are not atomic: a release can slip in between, drop the counter
to zero, and queue the drain while the opener goes on to mint a new
file-handle id on the draining handle. When the drain completes,
RemoveFileHandle deletes every fh id for the inode -- including the one
just minted. The kernel still holds that id, so its next FUSE_READ
gets ENOENT from GetHandle; the failed read leaves the folio not
uptodate and the kernel surfaces EIO to userspace. This was the flaky
TestWritebackCacheConcurrentMixedOps failure.

AcquireFileHandle now refuses to attach when the shared handle has
reached a zero counter and reports it as draining. AcquireHandle and
the Create path retry the whole wait-resolve-attach sequence; the next
pass blocks on the pending flush (flag registration and the counter
drop share pendingAsyncFlushMu) and builds a fresh handle from the
post-flush entry. The DLM-failure release in AcquireHandle also goes
through ReleaseHandle so it cannot strand a counter-zero handle
outside the drain bookkeeping.

Removes the refreshEntryForRead retry from #12206, which worked around
the symptom by refetching the entry after the fh id was already gone.

Test: TestAcquireFileHandleRefusesDrainingHandle covers the refusal
and fresh-handle path; TestWritebackCacheConcurrentMixedOps and an
amplified concurrent-overwrite repro run clean.

* mount: keep refresh-on-error retry for stale-chunk reads
2026-10-29 16:06:57 -07:00

160 lines
5.5 KiB
Go

package mount
import (
"sync/atomic"
"syscall"
"testing"
"time"
"github.com/seaweedfs/go-fuse/v2/fuse"
"google.golang.org/protobuf/proto"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// newOpenFileHandle builds a WFS holding one open handle on a file of the given
// size, the state a descriptor is in after an open and before any write.
func newOpenFileHandle(t *testing.T, fileSize uint64) (*WFS, *FileHandle) {
t.Helper()
wfs := newCopyRangeTestWFS()
path := util.FullPath("/file.txt")
inode := wfs.inodeToPath.Lookup(path, 1, false, false, 0, true)
fh, _, _, _ := wfs.fhMap.AcquireFileHandle(wfs, inode, &filer_pb.Entry{
Name: "file.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
FileSize: fileSize,
Inode: inode,
},
}, 0, 0)
fh.RememberPath(path)
return wfs, fh
}
// TestFallocateWithinFile covers the posix_fallocate() case that used to fall
// back to a glibc emulation reading through a write-only descriptor: the range
// is already inside the file, so the file must be left alone and answered OK.
func TestFallocateWithinFile(t *testing.T) {
wfs, fh := newOpenFileHandle(t, 10)
in := &fuse.FallocateIn{Fh: uint64(fh.fh), Offset: 0, Length: 1}
if status := wfs.Fallocate(nil, in); status != fuse.OK {
t.Fatalf("Fallocate inside the file: got %v, want OK", status)
}
if got := fh.GetEntry().GetEntry().GetAttributes().GetFileSize(); got != 10 {
t.Fatalf("file size: got %d, want 10", got)
}
if fh.dirtyMetadata {
t.Fatal("Fallocate inside the file marked the handle dirty")
}
}
func TestFallocateGrowsFile(t *testing.T) {
wfs, fh := newOpenFileHandle(t, 10)
in := &fuse.FallocateIn{Fh: uint64(fh.fh), Offset: 4096, Length: 4096}
if status := wfs.Fallocate(nil, in); status != fuse.OK {
t.Fatalf("Fallocate past the end: got %v, want OK", status)
}
if got := fh.GetEntry().GetEntry().GetAttributes().GetFileSize(); got != 8192 {
t.Fatalf("file size: got %d, want 8192", got)
}
if !fh.dirtyMetadata {
t.Fatal("Fallocate past the end did not mark the handle dirty")
}
}
func TestFallocateKeepSize(t *testing.T) {
wfs, fh := newOpenFileHandle(t, 10)
in := &fuse.FallocateIn{Fh: uint64(fh.fh), Offset: 0, Length: 4096, Mode: FALLOC_FL_KEEP_SIZE}
if status := wfs.Fallocate(nil, in); status != fuse.OK {
t.Fatalf("Fallocate with FALLOC_FL_KEEP_SIZE: got %v, want OK", status)
}
if got := fh.GetEntry().GetEntry().GetAttributes().GetFileSize(); got != 10 {
t.Fatalf("file size: got %d, want 10", got)
}
}
// TestFallocateUnsupportedMode pins the status for a mode we cannot honor:
// ENOSYS would make the kernel stop sending FUSE_FALLOCATE altogether.
func TestFallocateUnsupportedMode(t *testing.T) {
wfs, fh := newOpenFileHandle(t, 10)
const punchHole = 0x02
in := &fuse.FallocateIn{Fh: uint64(fh.fh), Offset: 0, Length: 4, Mode: punchHole | FALLOC_FL_KEEP_SIZE}
if status := wfs.Fallocate(nil, in); status != fuse.ENOTSUP {
t.Fatalf("Fallocate with FALLOC_FL_PUNCH_HOLE: got %v, want ENOTSUP", status)
}
}
func TestFallocateUnknownHandle(t *testing.T) {
wfs, _ := newOpenFileHandle(t, 10)
in := &fuse.FallocateIn{Fh: 12345, Offset: 0, Length: 1}
if status := wfs.Fallocate(nil, in); status != fuse.EBADF {
t.Fatalf("Fallocate on an unknown handle: got %v, want EBADF", status)
}
}
// TestFallocateNoOpIgnoresQuotaAndWorm covers the two guards a request that
// allocates nothing must not trip: it reserves no space and rewrites no entry.
func TestFallocateNoOpIgnoresQuotaAndWorm(t *testing.T) {
wfs, fh := newOpenFileHandle(t, 10)
wfs.option.Quota = 1
wfs.IsOverQuota = true
wfs.FilerConf = filer.NewFilerConf()
if err := wfs.FilerConf.AddLocationConf(&filer_pb.FilerConf_PathConf{
LocationPrefix: "/",
Worm: proto.Bool(true),
}); err != nil {
t.Fatalf("AddLocationConf: %v", err)
}
fh.GetEntry().GetEntry().WormEnforcedAtTsNs = time.Now().UnixNano()
in := &fuse.FallocateIn{Fh: uint64(fh.fh), Offset: 0, Length: 1}
if status := wfs.Fallocate(nil, in); status != fuse.OK {
t.Fatalf("Fallocate inside the file: got %v, want OK", status)
}
in = &fuse.FallocateIn{Fh: uint64(fh.fh), Offset: 0, Length: 4096}
if status := wfs.Fallocate(nil, in); status != fuse.Status(syscall.ENOSPC) {
t.Fatalf("Fallocate past the end over quota: got %v, want ENOSPC", status)
}
wfs.option.Quota = 0
wfs.IsOverQuota = false
if status := wfs.Fallocate(nil, in); status != fuse.EPERM {
t.Fatalf("Fallocate past the end under worm: got %v, want EPERM", status)
}
}
// TestFallocateChargesTheGrowth pins the quota accounting: the writes that fill
// a pre-extended range do not grow the file, so they charge nothing and the
// growth has to be counted where it happens.
func TestFallocateChargesTheGrowth(t *testing.T) {
atomic.StoreInt64(&uncommittedBytes, 0)
wfs, fh := newOpenFileHandle(t, 10)
wfs.option.Quota = 100 << 20
in := &fuse.FallocateIn{Fh: uint64(fh.fh), Offset: 0, Length: 8192}
if status := wfs.Fallocate(nil, in); status != fuse.OK {
t.Fatalf("Fallocate past the end: got %v, want OK", status)
}
if got := wfs.GetUncommittedBytes(); got != 8192-10 {
t.Fatalf("uncommitted bytes: got %d, want %d", got, 8192-10)
}
// A second request inside the now-larger file allocates nothing more.
if status := wfs.Fallocate(nil, in); status != fuse.OK {
t.Fatalf("Fallocate inside the file: got %v, want OK", status)
}
if got := wfs.GetUncommittedBytes(); got != 8192-10 {
t.Fatalf("uncommitted bytes after a no-op: got %d, want %d", got, 8192-10)
}
}