Files
seaweedfs/weed/mount/weedfs_file_mkrm_test.go
T
Chris LuandDevin 0ea818a44f mount: drain OFD locks when a file description is released (#12117)
* mount: drain OFD locks when a file description is released

The kernel scopes OFD fcntl locks to the open file description but
reports no lock_owner for them at RELEASE — it only sends one for flock
holders — so an OFD write lock taken on a file handle outlived the
holder and blocked every later open of that file (e.g. etcd failing to
reopen its WAL after a restart). The mount now records the owner of each
granted non-flock lock under its file handle and releases those owners
on RELEASE, which also covers any POSIX locks left over when FLUSH could
not run.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* mount: give every open its own fh value over the shared handle

AcquireFileHandle reuses one FileHandle per inode, so every open reported
the same fh to the kernel. A RELEASE for one description could then not
be told apart from a sibling still holding the inode open — draining
lock owners recorded under the shared fh dropped locks belonging to
descriptions that stay open.

Each open now registers its own fh value in fh2inode, all resolving to
the shared FileHandle, and RELEASE removes exactly the id that is
closing. This also stops a duplicated RELEASE on one id from
double-decrementing the open count.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* mount: forget flushed lock owners so a later release cannot re-drop locks

POSIX lock owners are process-scoped: Flush releases every lock the
process holds on the inode, but the owner stayed recorded under each
open's fh for the Release-time drain. If the process took a fresh lock
through another open before an old description finished closing, the
stale record made that RELEASE drop the new lock too.

Flush now drops the released owner from every recorded open of the
inode, so the Release drain only ever fires for owners whose locks were
never flushed.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* mount: keep close-time lock owner records when the release fails

A routed POSIX owner release that errors keeps the flush hint for a
retry, but Flush also dropped the owner from every open's release
records unconditionally — so the final Release skipped the retry and
the owner filer's lock was stranded until lease reaping. Only forget
the owner once its locks were actually released.

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 22:02:13 -07:00

556 lines
16 KiB
Go

package mount
import (
"context"
"net"
"path/filepath"
"sync"
"syscall"
"testing"
"time"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/mount/meta_cache"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type createEntryTestServer struct {
filer_pb.UnimplementedSeaweedFilerServer
mu sync.Mutex
lastDirectory string
lastName string
lastUID uint32
lastGID uint32
lastMode uint32
}
type createEntrySnapshot struct {
directory string
name string
uid uint32
gid uint32
mode uint32
}
func (s *createEntryTestServer) CreateEntry(ctx context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.lastDirectory = req.GetDirectory()
if req.GetEntry() != nil {
s.lastName = req.GetEntry().GetName()
if req.GetEntry().GetAttributes() != nil {
s.lastUID = req.GetEntry().GetAttributes().GetUid()
s.lastGID = req.GetEntry().GetAttributes().GetGid()
s.lastMode = req.GetEntry().GetAttributes().GetFileMode()
}
}
return &filer_pb.CreateEntryResponse{}, nil
}
func (s *createEntryTestServer) UpdateEntry(ctx context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
return &filer_pb.UpdateEntryResponse{}, nil
}
func (s *createEntryTestServer) snapshot() createEntrySnapshot {
s.mu.Lock()
defer s.mu.Unlock()
return createEntrySnapshot{
directory: s.lastDirectory,
name: s.lastName,
uid: s.lastUID,
gid: s.lastGID,
mode: s.lastMode,
}
}
func newCreateTestWFS(t *testing.T) (*WFS, *createEntryTestServer) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() {
_ = listener.Close()
})
server := pb.NewGrpcServer()
testServer := &createEntryTestServer{}
filer_pb.RegisterSeaweedFilerServer(server, testServer)
go server.Serve(listener)
t.Cleanup(server.Stop)
uidGidMapper, err := meta_cache.NewUidGidMapper("", "")
if err != nil {
t.Fatalf("create uid/gid mapper: %v", err)
}
root := util.FullPath("/")
option := &Option{
ChunkSizeLimit: 1024,
ConcurrentReaders: 1,
VolumeServerAccess: "filerProxy",
FilerAddresses: []pb.ServerAddress{
pb.NewServerAddressWithGrpcPort("127.0.0.1:1", listener.Addr().(*net.TCPAddr).Port),
},
GrpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
FilerMountRootPath: "/",
MountUid: 99,
MountGid: 100,
MountMode: 0o777,
MountMtime: time.Now(),
MountCtime: time.Now(),
UidGidMapper: uidGidMapper,
uniqueCacheDirForWrite: t.TempDir(),
}
wfs := &WFS{
option: option,
signature: 1,
inodeToPath: NewInodeToPath(root, 0),
fhMap: NewFileHandleToInode(),
fhLockTable: util.NewLockTable[FileHandleId](),
hardLinkLockTable: util.NewLockTable[string](),
}
wfs.metaCache = meta_cache.NewMetaCache(
filepath.Join(t.TempDir(), "meta"),
uidGidMapper,
root,
false,
func(path util.FullPath) {
wfs.inodeToPath.MarkChildrenCached(path)
},
func(path util.FullPath) bool {
return wfs.inodeToPath.IsChildrenCached(path)
},
func(meta_cache.EntryInvalidation) {},
nil,
)
wfs.inodeToPath.MarkChildrenCached(root)
t.Cleanup(func() {
wfs.metaCache.Shutdown()
})
return wfs, testServer
}
func TestCreateCreatesAndOpensFile(t *testing.T) {
wfs, testServer := newCreateTestWFS(t)
out := &fuse.CreateOut{}
status := wfs.Create(make(chan struct{}), &fuse.CreateIn{
InHeader: fuse.InHeader{
NodeId: 1,
Caller: fuse.Caller{
Owner: fuse.Owner{
Uid: 123,
Gid: 456,
},
},
},
Flags: syscall.O_WRONLY | syscall.O_CREAT,
Mode: 0o640,
}, "hello.txt", out)
if status != fuse.OK {
t.Fatalf("Create status = %v, want OK", status)
}
if out.NodeId == 0 {
t.Fatal("Create returned zero inode")
}
if out.Fh == 0 {
t.Fatal("Create returned zero file handle")
}
if out.OpenFlags != 0 {
t.Fatalf("Create returned OpenFlags = %#x, want 0", out.OpenFlags)
}
fileHandle := wfs.GetHandle(FileHandleId(out.Fh))
if fileHandle == nil {
t.Fatal("Create did not register an open file handle")
}
if got := fileHandle.FullPath(); got != "/hello.txt" {
t.Fatalf("FullPath = %q, want %q", got, "/hello.txt")
}
// File creation is deferred to flush time. Trigger a synchronous flush
// so the CreateEntry gRPC call is sent to the test server.
if flushStatus := wfs.Flush(make(chan struct{}), &fuse.FlushIn{
InHeader: fuse.InHeader{
NodeId: out.NodeId,
Caller: fuse.Caller{Owner: fuse.Owner{Uid: 123, Gid: 456}},
},
Fh: out.Fh,
}); flushStatus != fuse.OK {
t.Fatalf("Flush status = %v, want OK", flushStatus)
}
snapshot := testServer.snapshot()
if snapshot.directory != "/" {
t.Fatalf("CreateEntry directory = %q, want %q", snapshot.directory, "/")
}
if snapshot.name != "hello.txt" {
t.Fatalf("CreateEntry name = %q, want %q", snapshot.name, "hello.txt")
}
if snapshot.uid != 123 || snapshot.gid != 456 {
t.Fatalf("CreateEntry uid/gid = %d/%d, want 123/456", snapshot.uid, snapshot.gid)
}
if snapshot.mode != 0o640 {
t.Fatalf("CreateEntry mode = %o, want %o", snapshot.mode, 0o640)
}
}
func TestReleaseFlushesDirtyCreateIfFlushWasSkipped(t *testing.T) {
wfs, testServer := newCreateTestWFS(t)
out := &fuse.CreateOut{}
status := wfs.Create(make(chan struct{}), &fuse.CreateIn{
InHeader: fuse.InHeader{
NodeId: 1,
Caller: fuse.Caller{
Owner: fuse.Owner{
Uid: 123,
Gid: 456,
},
},
},
Flags: syscall.O_WRONLY | syscall.O_CREAT,
Mode: 0o640,
}, "release_flush.txt", out)
if status != fuse.OK {
t.Fatalf("Create status = %v, want OK", status)
}
wfs.Release(make(chan struct{}), &fuse.ReleaseIn{
InHeader: fuse.InHeader{
NodeId: out.NodeId,
Caller: fuse.Caller{Owner: fuse.Owner{Uid: 123, Gid: 456}},
},
Fh: out.Fh,
})
snapshot := testServer.snapshot()
if snapshot.directory != "/" {
t.Fatalf("CreateEntry directory = %q, want %q", snapshot.directory, "/")
}
if snapshot.name != "release_flush.txt" {
t.Fatalf("CreateEntry name = %q, want %q", snapshot.name, "release_flush.txt")
}
if snapshot.uid != 123 || snapshot.gid != 456 {
t.Fatalf("CreateEntry uid/gid = %d/%d, want 123/456", snapshot.uid, snapshot.gid)
}
if snapshot.mode != 0o640 {
t.Fatalf("CreateEntry mode = %o, want %o", snapshot.mode, 0o640)
}
if fh := wfs.GetHandle(FileHandleId(out.Fh)); fh != nil {
t.Fatal("Release should remove the file handle after fallback flush")
}
}
func TestTruncateEntryClearsDirtyPagesForOpenHandle(t *testing.T) {
wfs, _ := newCreateTestWFS(t)
fullPath := util.FullPath("/truncate.txt")
inode := wfs.inodeToPath.Lookup(fullPath, 1, false, false, 0, true)
entry := &filer_pb.Entry{
Name: "truncate.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0o644,
FileSize: 5,
Inode: inode,
Crtime: 1,
Mtime: 1,
},
}
fh, _, _ := wfs.fhMap.AcquireFileHandle(wfs, inode, entry, 0, 0)
fh.RememberPath(fullPath)
if err := fh.dirtyPages.AddPage(0, []byte("hello"), true, time.Now().UnixNano()); err != nil {
t.Fatalf("AddPage: %v", err)
}
oldDirtyPages := fh.dirtyPages
truncatedEntry := &filer_pb.Entry{
Name: "truncate.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0o644,
FileSize: 5,
Inode: inode,
Crtime: 1,
Mtime: 1,
},
}
if status := wfs.truncateEntry(fullPath, truncatedEntry); status != fuse.OK {
t.Fatalf("truncateEntry status = %v, want OK", status)
}
if fh.dirtyPages == oldDirtyPages {
t.Fatal("truncateEntry should replace the dirtyPages writer for an open handle")
}
if got := fh.GetEntry().GetEntry().GetAttributes().GetFileSize(); got != 0 {
t.Fatalf("file handle size = %d, want 0", got)
}
buf := make([]byte, 5)
if maxStop := fh.dirtyPages.ReadDirtyDataAt(buf, 0, time.Now().UnixNano()); maxStop != 0 {
t.Fatalf("dirty pages maxStop = %d, want 0 after truncate", maxStop)
}
}
func TestAccessChecksPermissions(t *testing.T) {
wfs := newCopyRangeTestWFS()
oldLookupSupplementaryGroupIDs := lookupSupplementaryGroupIDs
lookupSupplementaryGroupIDs = func(uint32) ([]string, error) {
return nil, nil
}
clearSupplementaryGroupCache()
t.Cleanup(func() {
lookupSupplementaryGroupIDs = oldLookupSupplementaryGroupIDs
clearSupplementaryGroupCache()
})
fullPath := util.FullPath("/visible.txt")
inode := wfs.inodeToPath.Lookup(fullPath, 1, false, false, 0, true)
handle, _, _ := wfs.fhMap.AcquireFileHandle(wfs, inode, &filer_pb.Entry{
Name: "visible.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0o640,
Uid: 123,
Gid: 456,
Inode: inode,
},
}, 0, 0)
handle.RememberPath(fullPath)
if status := wfs.Access(make(chan struct{}), &fuse.AccessIn{
InHeader: fuse.InHeader{
NodeId: inode,
Caller: fuse.Caller{
Owner: fuse.Owner{
Uid: 123,
Gid: 999,
},
},
},
Mask: fuse.R_OK | fuse.W_OK,
}); status != fuse.OK {
t.Fatalf("owner Access status = %v, want OK", status)
}
if status := wfs.Access(make(chan struct{}), &fuse.AccessIn{
InHeader: fuse.InHeader{
NodeId: inode,
Caller: fuse.Caller{
Owner: fuse.Owner{
Uid: 999,
Gid: 999,
},
},
},
Mask: fuse.W_OK,
}); status != fuse.EACCES {
t.Fatalf("other-user Access status = %v, want EACCES", status)
}
if got := hasAccess(123, 999, 123, 456, 0o400, fuse.R_OK|fuse.W_OK); got {
t.Fatal("owner should not get write access from a read-only owner mode")
}
if got := hasAccess(999, 456, 123, 456, 0o040, fuse.R_OK|fuse.W_OK); got {
t.Fatal("group member should not get write access from a read-only group mode")
}
if got := hasAccess(999, 999, 123, 456, 0o004, fuse.R_OK|fuse.W_OK); got {
t.Fatal("other users should not get write access from a read-only other mode")
}
if got := hasAccess(0, 0, 123, 456, 0o644, fuse.X_OK); got {
t.Fatal("root should not get execute access when no execute bit is set")
}
if got := hasAccess(0, 0, 123, 456, 0o755, fuse.R_OK|fuse.X_OK); !got {
t.Fatal("root should get execute access when at least one execute bit is set")
}
}
func TestHasAccessUsesSupplementaryGroups(t *testing.T) {
oldLookupSupplementaryGroupIDs := lookupSupplementaryGroupIDs
lookupSupplementaryGroupIDs = func(uint32) ([]string, error) {
return []string{"456"}, nil
}
clearSupplementaryGroupCache()
t.Cleanup(func() {
lookupSupplementaryGroupIDs = oldLookupSupplementaryGroupIDs
clearSupplementaryGroupCache()
})
if got := hasAccess(999, 999, 123, 456, 0o060, fuse.R_OK|fuse.W_OK); !got {
t.Fatal("supplementary group membership should grant matching group permissions")
}
}
func TestSupplementaryGroupCaching(t *testing.T) {
callCount := 0
oldLookupSupplementaryGroupIDs := lookupSupplementaryGroupIDs
lookupSupplementaryGroupIDs = func(uid uint32) ([]string, error) {
callCount++
return []string{"456"}, nil
}
clearSupplementaryGroupCache()
t.Cleanup(func() {
lookupSupplementaryGroupIDs = oldLookupSupplementaryGroupIDs
clearSupplementaryGroupCache()
})
cachedLookupSupplementaryGroupIDs(999)
cachedLookupSupplementaryGroupIDs(999)
cachedLookupSupplementaryGroupIDs(999)
if callCount != 1 {
t.Fatalf("lookupSupplementaryGroupIDs called %d times, expected 1 (cache should prevent repeated calls)", callCount)
}
cachedLookupSupplementaryGroupIDs(1000)
if callCount != 2 {
t.Fatalf("lookupSupplementaryGroupIDs called %d times after different UID, expected 2", callCount)
}
}
func TestSupplementaryGroupCacheExpiry(t *testing.T) {
callCount := 0
oldLookupSupplementaryGroupIDs := lookupSupplementaryGroupIDs
oldTTL := supplementaryGroupCacheTTL
supplementaryGroupCacheTTL = 0
lookupSupplementaryGroupIDs = func(uid uint32) ([]string, error) {
callCount++
return []string{"456"}, nil
}
clearSupplementaryGroupCache()
t.Cleanup(func() {
lookupSupplementaryGroupIDs = oldLookupSupplementaryGroupIDs
supplementaryGroupCacheTTL = oldTTL
clearSupplementaryGroupCache()
})
cachedLookupSupplementaryGroupIDs(999)
if callCount != 1 {
t.Fatalf("Expected 1 lookup on first call, got %d", callCount)
}
cachedLookupSupplementaryGroupIDs(999)
if callCount != 2 {
t.Fatalf("Expected 2 lookups after TTL expiry, got %d", callCount)
}
}
func TestCreateExistingFileIgnoresQuotaPreflight(t *testing.T) {
wfs, _ := newCreateTestWFS(t)
wfs.option.Quota = 1
wfs.IsOverQuota = true
entry := &filer_pb.Entry{
Name: "existing.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0o644,
FileSize: 7,
Inode: 101,
Crtime: 1,
Mtime: 1,
Uid: 123,
Gid: 456,
},
}
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/", entry), 0); err != nil {
t.Fatalf("InsertEntry: %v", err)
}
wfs.inodeToPath.Lookup(util.FullPath("/existing.txt"), entry.Attributes.Crtime, false, false, entry.Attributes.Inode, true)
out := &fuse.CreateOut{}
status := wfs.Create(make(chan struct{}), &fuse.CreateIn{
InHeader: fuse.InHeader{
NodeId: 1,
Caller: fuse.Caller{
Owner: fuse.Owner{
Uid: 123,
Gid: 456,
},
},
},
Flags: syscall.O_WRONLY | syscall.O_CREAT | syscall.O_EXCL,
Mode: 0o644,
}, "existing.txt", out)
if status != fuse.Status(syscall.EEXIST) {
t.Fatalf("Create status = %v, want EEXIST", status)
}
}
// With default_permissions the kernel enforces unix bits before it calls
// Open, so AcquireHandle must skip its own check (and the group lookup behind
// it). Without it, AcquireHandle stays the enforcer.
func TestAcquireHandleHonorsDefaultPermissions(t *testing.T) {
for _, tc := range []struct {
name string
defaultPermissions bool
want fuse.Status
}{
{"kernel enforces", true, fuse.OK},
{"mount enforces", false, fuse.EACCES},
} {
t.Run(tc.name, func(t *testing.T) {
wfs, _ := newCreateTestWFS(t)
wfs.option.DefaultPermissions = tc.defaultPermissions
oldLookup := lookupSupplementaryGroupIDs
lookupSupplementaryGroupIDs = func(uint32) ([]string, error) { return nil, nil }
clearSupplementaryGroupCache()
t.Cleanup(func() {
lookupSupplementaryGroupIDs = oldLookup
clearSupplementaryGroupCache()
})
entry := &filer_pb.Entry{
Name: "secret.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0o600, // owner-only: an "other" uid has no read
Inode: 202,
Crtime: 1,
Mtime: 1,
Uid: 123,
Gid: 456,
},
}
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/", entry), 0); err != nil {
t.Fatalf("InsertEntry: %v", err)
}
inode := wfs.inodeToPath.Lookup(util.FullPath("/secret.txt"), entry.Attributes.Crtime, false, false, entry.Attributes.Inode, true)
fh, _, status := wfs.AcquireHandle(context.Background(), inode, syscall.O_RDONLY, 999, 999)
if status != tc.want {
t.Fatalf("AcquireHandle status = %v, want %v", status, tc.want)
}
if status == fuse.OK {
if fh == nil {
t.Fatal("AcquireHandle returned nil handle on OK")
}
wfs.ReleaseHandle(fh.fh)
}
})
}
}
// default_permissions skips only the mode-bit check, not parent-existence
// validation: the create RPC sets SkipCheckParentDirectory, so the mount's
// own parent lookup is the only thing guarding against an orphaned entry.
func TestCreateRegularFileValidatesParentUnderDefaultPermissions(t *testing.T) {
wfs, _ := newCreateTestWFS(t)
wfs.option.DefaultPermissions = true
if _, _, code := wfs.createRegularFile(context.Background(), util.FullPath("/ghost"), "f.txt", 0o644, 99, 100, 0, false, false); code == fuse.OK {
t.Fatal("createRegularFile returned OK for a missing parent directory")
}
}