Files
seaweedfs/weed/mount/weedfs_file_mkrm_test.go
T
Chris Lu 34b4ecc631 fix(mount): serialize hard-link mutations on HardLinkId (#9064)
* fix(mount): serialize hard-link mutations on HardLinkId

syncHardLinkSiblings stamps every sibling of a hard-link to
authoritativeEntry.HardLinkCounter, and the caller computes that value
as entry.HardLinkCounter - 1 (Unlink) or entry.HardLinkCounter + 1
(Link) from a cached entry read before the filer mutation. With
concurrent Unlinks on different links of the same file, both callers
observe the same pre-decrement counter, the filer's atomic blob
decrement lands correctly, but both then stamp their siblings to
counter-1 — leaving the mount metacache one higher than the authoritative
blob.

Serialize Link and Unlink on string(HardLinkId) via a new
hardLinkLockTable on WFS, and re-load the entry under the lock so the
second caller sees the updated sibling counter its predecessor just
wrote before computing its own delta. First-link races (empty
HardLinkId on the source) are a separate pre-existing issue and are
not addressed here.

Full pjdfstest suite still passes (235 files, 8803 tests).

* fix(mount): abort on stale pre-lock entry after HardLinkId lock

Review follow-up: if maybeLoadEntry fails after acquiring the
hardLinkLockTable lock, the prior revision silently fell back to the
pre-lock snapshot, reintroducing the stale-base update the lock is
meant to prevent.

- Unlink: treat fuse.ENOENT as success (the file was already removed by
  the thread that held the lock before us) and propagate any other
  error.
- Link: abort with the returned status so we never derive the next
  HardLinkCounter from a stale source entry.

* fix(mount): re-resolve Link source alias under HardLinkId lock

Review follow-up: Link resolved oldEntryPath from in.Oldnodeid before
waiting on the HardLinkId lock. A concurrent Unlink that held the same
lock could remove the specific alias we picked pre-lock while leaving
other sibling hard links for the same inode intact. The post-lock
maybeLoadEntry then returned ENOENT even though the source inode was
still reachable.

Call GetPath(in.Oldnodeid) again under the lock to pick whichever
alias is still active, refresh oldParentPath, and only return ENOENT
if no sibling survived.
2026-04-13 22:39:50 -07:00

385 lines
10 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(util.FullPath, *filer_pb.Entry) {},
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 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)
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
}
t.Cleanup(func() {
lookupSupplementaryGroupIDs = oldLookupSupplementaryGroupIDs
})
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,
},
})
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
}
t.Cleanup(func() {
lookupSupplementaryGroupIDs = oldLookupSupplementaryGroupIDs
})
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 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)); 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)
}
}