Files
seaweedfs/weed/mount/posix_file_lock_test.go
T
Chris Lu e377149d39 mount: support mounting on Windows through WinFsp (#10536)
* mount: add the WinFsp filesystem adapter

WinFsp speaks a path-based FUSE dialect; weed/mount implements the
inode-based raw protocol the Linux kernel uses. This translates between
them so Windows runs the same filesystem code as everywhere else rather
than a second implementation: paths resolve to inodes one Lookup at a
time, and the raw operations run unchanged underneath.

Errno translation is spelled out rather than passed through. Go numbers
Windows errnos as offsets from APPLICATION_ERROR, so the raw value would
mean something unrelated by the time WinFsp read it.

Hard links return ENOSYS since WinFsp has none, and byte-range locks stay
with its kernel driver rather than the mount's lock table.

Not reachable from the mount command yet.

* mount: build the winfsp errno table with explicit precedence

Platforms alias errnos differently: freebsd has no ENODATA and linux makes
ENOATTR the same value as it. A map literal with colliding constant keys
does not compile, so build the table and let the first entry win, keeping
the general codes their own meaning.

* mount: wire the winfsp adapter into the mount command

RunMount was one function doing filer setup, mount-point preparation and
serving. The setup is the same everywhere, so it moves to mount_common.go
and each platform keeps only what differs.

Windows differs mostly in the mount point: WinFsp wants a drive letter or
a path that does not exist yet, so none of the unix preparation applies,
and a bad one is worth rejecting up front because WinFsp reports failure
as a bare false. Adds -windows.caseInsensitive for software that expects
Windows naming rules.

* ci: mount on windows and exercise it

Builds weed.exe, installs WinFsp, starts a cluster, mounts S: and runs a
test suite against it: round trips at several sizes, offset writes,
rename, delete, nested directories, concurrent writers, and a directory
wide enough to stand in for the case that prompted this.

Nothing else here can run the Windows mount, so without this the adapter
is only known to compile.

* ci: build the windows mount without cgo

The runner has MinGW, so cgo is on by default and cgofuse compiles its
cgo variant, which needs WinFsp's headers. The nocgo variant loads the
DLL at run time and is what the released weed.exe uses.

* mount: make the winfsp path splitting portable and test it

resolve and resolveParent had the splitting inline in a windows-tagged
file, so the cases that matter most there — both separators, empty and
dot components, the root having no parent to create in — could not be
tested on any runner that builds this.

* test: check the windows mount persists across a remount

Reading a file back through the same live mount proves nothing about
durability; the answer can come from the mount's own caches. Write the
fixtures, confirm the filer serves them with the mount out of the path,
then re-read after a teardown and remount.

* test: cover the windows mount operations that had none

Truncate, append, chtimes and the hard-link refusal were implemented but
never exercised, and the errno table was only unit-tested for mapping,
never end to end. Adds names that have to survive the UTF-16 boundary,
rename over an existing target and across directories, and concurrent
handles on one file rather than one file each.

* ci: dial the filer over ipv4 and run the persistence phases

localhost resolves to ::1 first on windows and the cluster binds ipv4
only, so the mount's grpc dial was refused while the http readiness
probe passed by falling back to ipv4.

* ci: pin the cluster to loopback and probe ports by connecting

weed mini advertises the runner's LAN address and binds filer grpc there,
so the mount's dial to 127.0.0.1:18888 was refused while http answered.

The readiness probe also passed with nothing on 18888: Test-NetConnection
reported success for a port that then refused a connection, so it now
opens a socket instead.

* ci: report listening ports before mounting

The readiness probe connects to the filer grpc port and the mount is then
refused on it, which cannot both be true; print the actual state.

* ci: run the cluster, mount and tests in one step

The runner tears down a step's process tree when its shell exits, so the
cluster started in an earlier step was already gone: the readiness probe
passed against a live filer, the step ended, and the mount then found
nothing listening. A diagnostic step reported no weed.exe at all.

Everything that needs those processes alive now shares a step.

* mount: key windows file io on the handle, not the path

Read and Write walked the path on every call to fill in a NodeId the raw
filesystem never reads: both look the file up by handle. Under eight
writers creating files in one directory the walk transiently missed and
the write failed with ENOENT before reaching the filesystem at all.

Same for flush, fsync and the release calls. O_EXCL now fails on an
existing name instead of taking it over, and Symlink is refused: the
entry is easy to create but WinFsp only follows it once the reparse
point is wired up, so it read back as an empty file.

* mount: translate cgofuse open flags for windows

cgofuse reports MSVC's numbering and the raw filesystem tests Go's, so
only the access mode and O_TRUNC lined up: O_EXCL arrived as O_APPEND and
O_CREAT as nothing at all.

Also report which handle a failed write was using, to tell a handle that
was never issued from one released while still in use.

* mount: report which step of a windows create failed

A concurrent create fails with ENOENT and the path walk, the parent
lookup and the create itself are indistinguishable from the caller.

* ci: send weed logs to stderr on windows

glog writes to its own files by default, so the mount's own error output
never reached the redirected log. Its flags are global and have to come
before the subcommand.

* mount: resolve known paths from the inode table on windows

Every create walked the parent chain with a filer lookup per component.
With eight writers creating files in one directory that is hundreds of
concurrent lookups of the same parent, and lookupEntry reports an
authoritative ENOENT when the directory is cached, the entry is not in
the cache and the inode table has no record — a window a concurrent
refresh can open for a directory that plainly exists.

A path the mount already tracks now resolves straight out of that table.

* test: sync the windows persistence fixtures before closing

The mount is killed rather than unmounted, so anything still queued for
flush is legitimately lost and the test was measuring crash durability
while calling it persistence. A 9MB file lost four chunks that way.

* mount: keep the lookup refresh on the target path

Resolving a tracked path straight from the inode table skipped Lookup,
which is also what refreshes the entry: a truncate then read back the
pre-truncate size. Only the parent chain takes the shortcut now, which
is where the concurrent creates were racing anyway.

* mount: log every windows resolve failure

Open suppressed ENOENT and Getattr logged nothing, which hid the two
callbacks that can report a missing file during a create.

* mount: drop dot entries from windows directory listings

readdir reports "." and ".." for the kernel, but Windows enumerates a
directory without them and displays whatever it is handed, so a folder of
200 files listed 202. Go's ReadDir filters them, which is why only the
PowerShell walk caught it.

* mount: flush queued writes when windows mount is interrupted

The signal handler exits the process the moment its hooks return, so the
WaitForAsyncFlush after Serve never ran on ctrl-c and queued writes were
dropped.

* mount: let windows mount over an empty directory

WinFsp turns a directory mount point into a reparse point, which NTFS
allows on an empty directory and refuses on a populated one. The check
rejected every existing directory, so the ordinary habit of creating the
mount point first failed with a message saying it should not exist.

CI now mounts over a pre-created directory and writes through it.

* ci: run the windows mount check on any pull request

It is the only thing that exercises the Windows mount, so restricting it
to pull requests based on master skipped it for stacked ones. Replaces
the branch name that was pushed to trigger it.

* mount: do not log a missing windows entry as an error

Windows probes for entries that do not exist as a matter of course, so
ENOENT from getattr and open is an answer rather than a fault and would
have filled the log.

* mount: take the fast path for parent chains in every windows resolve

Narrowing it to resolveParent left Getattr and Open re-walking the parent
with a filer lookup per component, and those are what Windows calls
before a create: eight writers in one directory still raced a meta cache
refresh there. Only the final component needs the Lookup refresh.

The pass that suggested otherwise came from a run five times slower than
the failing ones, where the race had no room to appear.

* mount: drop the windows path resolution shortcut

Resolving from the inode table skipped the Lookup that refreshes an
entry, and a truncate then read back its old size. Applying it only to
the parent chain kept truncate correct but left concurrent creates
failing, and applying it to the final component too inverted that. The
two cannot both be satisfied this way, so this returns to looking up
every component and leaves the concurrent create failure open.

* mount: fall back to the open handle when a deferred entry is evicted

A create that defers the filer write leaves the entry only in the local
cache. Creating many files at once pushes the directory past the hot
threshold and evicts it, taking that placeholder with it, so a lookup
went to the filer, found nothing, and reported a file that plainly
exists as missing.

The handle still holding the unflushed entry is authoritative for it.
Caught by concurrent creates over a Windows mount, which resolves a path
on every call rather than relying on a kernel dentry cache.

* mount: let cgofuse resolve to the version the module graph requires

rclone already depends on cgofuse at a newer commit than the v1.6.0 pin,
so readonly builds refused the go.mod until it matched what MVS picks.
The interface and flag values the adapter uses are unchanged there.

* mount: wait for a pending async flush before looking up on the filer

Open, unlink and rename already wait, but a plain lookup went straight
to the filer and read pre-close metadata: truncate a file, close it, and
a path probe during the flush window reported the old size. The kernel
attr cache hides this on linux; a front end that resolves paths on every
operation hit it directly.

* mount: reject a umask wider than the file mode it becomes

ParseUint allowed 64 bits and the result is narrowed to os.FileMode,
which is 32, so an out-of-range umask truncated silently instead of
being reported as unparseable.

* mount: address review findings on the windows mount

WaitForAsyncFlush closed its channel unconditionally and shutdown reaches
it from both the interrupt hook and the path that resumes after serving,
so a ctrl-c could panic on a second close.

The deferred-entry fallback read an open handle's entry without its lock,
which is what the other two readers of that field take so FromPbEntry
does not walk the chunk slice mid-append. The async-flush wait also sat
ahead of the meta cache, making every stat of a recently closed file
queue behind uploads; it belongs just before the filer is consulted.

Windows entries were persisted as uid 0: the raw filesystem stores
InHeader's owner and the adapter left it zero. They now carry the
identity the mount was started with.

The errno table used Linux numbering while cgofuse decodes MSVC's, so
ENAMETOOLONG arrived as EDEADLK and five others were likewise wrong; a
windows test pins each value to cgofuse's own constant.

Also: break the filer handshake loop on success rather than always
running ten rounds, accept a drive letter written S:\\, report a missing
WinFsp instead of panicking, keep commas out of the volume label, and
drop -windows.caseInsensitive, which told WinFsp the mount folds case
while lookups stayed exact.

* mount: return windows lookup references so the inode table stays bounded

Every operation that hands back an EntryOut grants a reference the Linux
kernel returns with FORGET. WinFsp has no FORGET, so the adapter took one
per path component per call, plus one per child of every readdirplus, and
never gave any back: inodeToPath grew for the life of the mount. Walking
the 200k-file directory this exists for stranded 200k references.

The adapter now plays the part the kernel plays. Each resolution releases
what it took, and an open handle keeps the reference for its inode until
Release, counted because the raw filesystem reuses one handle for repeated
opens. Holding it is not optional: completeAsyncFlush skips the metadata
flush when the saved path no longer maps to the inode, so releasing early
would lose a close's metadata.

Also stops persisting the display owner. -o uid=-1 makes WinFsp report the
calling user whatever we say, but the value handed to the raw filesystem is
written to the filer, and 4294967295 is what every other client would read.
-windows.uid and -windows.gid set what is recorded.

* mount: fix windows behaviours the reference implementations guard against

WinFsp has no ro option — it discards the flag and leaves the volume
writable — so -readOnly accepted writes and deletes. The refusal now
happens in the operations themselves.

Windows sends times around its own 1601 epoch, which arrive as a large
negative second count; casting them through stored a year-1601 timestamp
that every other client then read. Those are now left alone. rclone
carries the same guard.

Chown returned ENOSYS, and WinFsp passes a chown failure straight out of
SetSecurity, so Explorer's Security tab and icacls failed for edits that
were not about ownership. It now accepts and discards.

Only create and mkdir presented a caller; the rest sent uid 0, which
hasAccess treats as root, so deletes and renames skipped the permission
check that creates got. Every operation presents the same identity now.

A drive letter written S:\ reached WinFsp unnormalised, which recognises
a drive only as exactly two characters and then failed as a directory
path. A test also pins the open flag translation, since swapping O_EXCL
and O_TRUNC would turn 'fail if it exists' into 'truncate it'.

* mount: answer windows getattr and truncate from the open handle

WinFsp keeps the path a handle was opened with and never updates it when
the file is renamed, so resolving the path again fails on a handle that is
still perfectly valid — the ordinary write-temp-then-rename save pattern.
The handle already knows its inode, which also removes a full path walk
from two operations WinFsp calls constantly.

Readlink on the root now refuses. WinFsp probes there to decide whether
the volume has symlinks and enables them unless it fails, and with them on
it resolves a path a component at a time, each one reaching us as its own
walk — all for a feature Symlink already refuses.

* mount: require the windows mount directory not to exist

WinFsp creates the directory itself with FILE_CREATE and removes it when
the filesystem goes away, so an existing one — empty or not — fails with
"mount point in use". Allowing an empty directory was wrong, and the CI
check that appeared to prove otherwise was the vacuous one: listing a
plain directory succeeds whether or not anything is mounted on it, so the
step passed while the mount had failed and the writes went to local disk.

That check now waits for the reparse point, which is what caught this.

* mount: apply review comments on the windows mount

-windows.uid and -windows.gid reached the adapter but not the filesystem
parameters, which is what carries the owner written to the filer, so the
flags changed nothing.

Readdir re-resolved the path while Getattr and Truncate answer from the
handle; a directory renamed during an enumeration then failed on the
stale path WinFsp still holds.

Utimens now honours UTIME_OMIT instead of writing whatever came with it.

* mount: tag the unix-only lock tests away from windows

The production lock files were tagged when the package was made to build
on windows, but the tests that exercise them were not, so anything that
compiles tests for windows still failed on syscall.F_WRLCK.

* ci: vet the mount tests for each target too

Only compiling the non-test build let an untagged test keep a per-OS
syscall constant without anything noticing.
2026-08-03 21:20:26 -07:00

853 lines
26 KiB
Go

//go:build !windows
package mount
import (
"math"
"runtime"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
"github.com/seaweedfs/go-fuse/v2/fuse"
)
func TestNonOverlappingLocksFromDifferentOwners(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
s1 := plt.SetLk(inode, lockRange{Start: 0, End: 49, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
if s1 != fuse.OK {
t.Fatalf("expected OK, got %v", s1)
}
s2 := plt.SetLk(inode, lockRange{Start: 50, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20})
if s2 != fuse.OK {
t.Fatalf("expected OK, got %v", s2)
}
}
func TestOverlappingReadLocksFromDifferentOwners(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
s1 := plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_RDLCK, Owner: 1, Pid: 10})
if s1 != fuse.OK {
t.Fatalf("expected OK, got %v", s1)
}
s2 := plt.SetLk(inode, lockRange{Start: 50, End: 149, Typ: syscall.F_RDLCK, Owner: 2, Pid: 20})
if s2 != fuse.OK {
t.Fatalf("expected OK, got %v", s2)
}
}
func TestOverlappingWriteReadConflict(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
s := plt.SetLk(inode, lockRange{Start: 50, End: 149, Typ: syscall.F_RDLCK, Owner: 2, Pid: 20})
if s != fuse.EAGAIN {
t.Fatalf("expected EAGAIN, got %v", s)
}
}
func TestOverlappingWriteWriteConflict(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
s := plt.SetLk(inode, lockRange{Start: 50, End: 149, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20})
if s != fuse.EAGAIN {
t.Fatalf("expected EAGAIN, got %v", s)
}
}
func TestSameOwnerUpgradeReadToWrite(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_RDLCK, Owner: 1, Pid: 10})
s := plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
if s != fuse.OK {
t.Fatalf("expected OK for same-owner upgrade, got %v", s)
}
// Verify the lock is now a write lock.
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20}, &out)
if out.Lk.Typ != syscall.F_WRLCK {
t.Fatalf("expected conflicting write lock, got type %d", out.Lk.Typ)
}
}
func TestSameOwnerDowngradeWriteToRead(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
s := plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_RDLCK, Owner: 1, Pid: 10})
if s != fuse.OK {
t.Fatalf("expected OK for same-owner downgrade, got %v", s)
}
// Another owner should now be able to get a read lock.
s2 := plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_RDLCK, Owner: 2, Pid: 20})
if s2 != fuse.OK {
t.Fatalf("expected OK for shared read lock, got %v", s2)
}
}
func TestLockCoalescing(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 9, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
plt.SetLk(inode, lockRange{Start: 10, End: 19, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
il := plt.getInodeLocks(inode)
il.mu.Lock()
ownerLocks := 0
for _, lk := range il.locks {
if lk.Owner == 1 {
ownerLocks++
if lk.Start != 0 || lk.End != 19 {
t.Errorf("expected coalesced lock [0,19], got [%d,%d]", lk.Start, lk.End)
}
}
}
il.mu.Unlock()
if ownerLocks != 1 {
t.Fatalf("expected 1 coalesced lock, got %d", ownerLocks)
}
}
func TestLockSplitting(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
// Unlock the middle portion.
plt.SetLk(inode, lockRange{Start: 40, End: 59, Typ: syscall.F_UNLCK, Owner: 1, Pid: 10})
il := plt.getInodeLocks(inode)
il.mu.Lock()
ownerLocks := 0
for _, lk := range il.locks {
if lk.Owner == 1 {
ownerLocks++
}
}
if ownerLocks != 2 {
il.mu.Unlock()
t.Fatalf("expected 2 locks after split, got %d", ownerLocks)
}
// Check the ranges.
if il.locks[0].Start != 0 || il.locks[0].End != 39 {
t.Errorf("expected left lock [0,39], got [%d,%d]", il.locks[0].Start, il.locks[0].End)
}
if il.locks[1].Start != 60 || il.locks[1].End != 99 {
t.Errorf("expected right lock [60,99], got [%d,%d]", il.locks[1].Start, il.locks[1].End)
}
il.mu.Unlock()
}
func TestGetLkConflict(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 10, End: 50, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 30, End: 70, Typ: syscall.F_RDLCK, Owner: 2, Pid: 20}, &out)
if out.Lk.Typ != syscall.F_WRLCK {
t.Fatalf("expected conflicting write lock, got type %d", out.Lk.Typ)
}
if out.Lk.Pid != 10 {
t.Fatalf("expected holder PID 10, got %d", out.Lk.Pid)
}
if out.Lk.Start != 10 || out.Lk.End != 50 {
t.Fatalf("expected conflict [10,50], got [%d,%d]", out.Lk.Start, out.Lk.End)
}
}
func TestGetLkNoConflict(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 10, End: 50, Typ: syscall.F_RDLCK, Owner: 1, Pid: 10})
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 30, End: 70, Typ: syscall.F_RDLCK, Owner: 2, Pid: 20}, &out)
if out.Lk.Typ != syscall.F_UNLCK {
t.Fatalf("expected F_UNLCK (no conflict), got type %d", out.Lk.Typ)
}
}
func TestGetLkSameOwnerNoConflict(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10}, &out)
if out.Lk.Typ != syscall.F_UNLCK {
t.Fatalf("same owner should not conflict with itself, got type %d", out.Lk.Typ)
}
}
func TestReleaseOwner(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 49, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
plt.SetLk(inode, lockRange{Start: 50, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
plt.SetLk(inode, lockRange{Start: 200, End: 299, Typ: syscall.F_RDLCK, Owner: 2, Pid: 20})
plt.ReleaseOwner(inode, 1)
// Owner 1's locks should be gone.
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 3, Pid: 30}, &out)
if out.Lk.Typ != syscall.F_UNLCK {
t.Fatalf("expected no conflict after ReleaseOwner, got type %d", out.Lk.Typ)
}
// Owner 2's lock should still exist.
plt.GetLk(inode, lockRange{Start: 200, End: 299, Typ: syscall.F_WRLCK, Owner: 3, Pid: 30}, &out)
if out.Lk.Typ != syscall.F_RDLCK {
t.Fatalf("expected owner 2's read lock to remain, got type %d", out.Lk.Typ)
}
}
func TestDifferentLockKindsDoNotConflict(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
s1 := plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
if s1 != fuse.OK {
t.Fatalf("expected POSIX lock OK, got %v", s1)
}
s2 := plt.SetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20, IsFlock: true})
if s2 != fuse.OK {
t.Fatalf("expected flock lock OK in separate namespace, got %v", s2)
}
}
func TestReleasePosixOwnerReleasesPosixLocksAndWakesWaiters(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
done := make(chan fuse.Status, 1)
go func() {
cancel := make(chan struct{})
done <- plt.SetLkw(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20}, cancel)
}()
time.Sleep(50 * time.Millisecond)
plt.ReleasePosixOwner(inode, 1)
select {
case s := <-done:
if s != fuse.OK {
t.Fatalf("expected OK after ReleasePosixOwner, got %v", s)
}
case <-time.After(2 * time.Second):
t.Fatal("SetLkw did not unblock after ReleasePosixOwner")
}
}
func TestReleasePosixOwnerDoesNotReleaseFlockLocks(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10, IsFlock: true})
plt.ReleasePosixOwner(inode, 1)
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20, IsFlock: true}, &out)
if out.Lk.Typ != syscall.F_WRLCK {
t.Fatalf("expected flock lock to remain after ReleasePosixOwner, got type %d", out.Lk.Typ)
}
}
func TestHasPosixOwnerIgnoresMissingOwnerAndFlock(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
if plt.HasPosixOwner(inode, 1) {
t.Fatal("missing owner should not be reported as holding POSIX locks")
}
if s := plt.SetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10, IsFlock: true}); s != fuse.OK {
t.Fatalf("set flock: %v", s)
}
if plt.HasPosixOwner(inode, 1) {
t.Fatal("flock owner should not be reported as a POSIX lock owner")
}
if s := plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20}); s != fuse.OK {
t.Fatalf("set POSIX lock: %v", s)
}
if !plt.HasPosixOwner(inode, 2) {
t.Fatal("POSIX lock owner was not reported")
}
if plt.HasPosixOwner(inode, 0) {
t.Fatal("zero owner should not be reported")
}
}
func TestWakeEligibleWaitersKeepsInodeUntilWakeRefReleased(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
il := plt.getOrCreateInodeLocks(inode)
waiter := &lockWaiter{
requested: lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20},
ch: make(chan struct{}),
}
il.mu.Lock()
il.waiters = append(il.waiters, waiter)
il.mu.Unlock()
plt.releaseMatching(inode, func(lockRange) bool { return false })
select {
case <-waiter.ch:
// Expected.
default:
t.Fatal("expected waiter to be woken")
}
plt.mu.Lock()
_, exists := plt.inodes[inode]
plt.mu.Unlock()
if !exists {
t.Fatal("inodeLocks should remain while a woken waiter still holds a wake ref")
}
il.mu.Lock()
releaseWakeRef(il, waiter)
il.mu.Unlock()
plt.maybeCleanupInode(inode, il)
plt.mu.Lock()
_, exists = plt.inodes[inode]
plt.mu.Unlock()
if exists {
t.Fatal("inodeLocks should be cleaned up after the final wake ref is released")
}
}
func TestReleaseFlockOwnerDoesNotReleasePosixLocks(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
plt.SetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 2, Pid: 10, IsFlock: true})
plt.ReleaseFlockOwner(inode, 2)
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 3, Pid: 30}, &out)
if out.Lk.Typ != syscall.F_WRLCK {
t.Fatalf("expected POSIX lock to remain after ReleaseFlockOwner, got type %d", out.Lk.Typ)
}
plt.GetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 4, Pid: 40, IsFlock: true}, &out)
if out.Lk.Typ != syscall.F_UNLCK {
t.Fatalf("expected flock lock to be removed after ReleaseFlockOwner, got type %d", out.Lk.Typ)
}
}
func TestReleaseOwnerWakesWaiters(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
done := make(chan fuse.Status, 1)
go func() {
cancel := make(chan struct{})
s := plt.SetLkw(inode, lockRange{Start: 50, End: 60, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20}, cancel)
done <- s
}()
// Give the goroutine time to block.
time.Sleep(50 * time.Millisecond)
plt.ReleaseOwner(inode, 1)
select {
case s := <-done:
if s != fuse.OK {
t.Fatalf("expected OK after ReleaseOwner woke waiter, got %v", s)
}
case <-time.After(2 * time.Second):
t.Fatal("SetLkw did not unblock after ReleaseOwner")
}
}
func TestSetLkwBlocksAndSucceeds(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
done := make(chan fuse.Status, 1)
go func() {
cancel := make(chan struct{})
s := plt.SetLkw(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20}, cancel)
done <- s
}()
// Give the goroutine time to block.
time.Sleep(50 * time.Millisecond)
// Release the conflicting lock.
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_UNLCK, Owner: 1, Pid: 10})
select {
case s := <-done:
if s != fuse.OK {
t.Fatalf("expected OK, got %v", s)
}
case <-time.After(2 * time.Second):
t.Fatal("SetLkw did not unblock after conflicting lock was released")
}
}
func TestSetLkwCancellation(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
cancel := make(chan struct{})
done := make(chan fuse.Status, 1)
go func() {
s := plt.SetLkw(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20}, cancel)
done <- s
}()
// Give the goroutine time to block.
time.Sleep(50 * time.Millisecond)
close(cancel)
select {
case s := <-done:
if s != fuse.EINTR {
t.Fatalf("expected EINTR on cancel, got %v", s)
}
case <-time.After(2 * time.Second):
t.Fatal("SetLkw did not unblock after cancel")
}
}
func TestWholeFileLock(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
// Simulate flock() — whole-file exclusive lock.
s1 := plt.SetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
if s1 != fuse.OK {
t.Fatalf("expected OK, got %v", s1)
}
// Second owner should be blocked.
s2 := plt.SetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20})
if s2 != fuse.EAGAIN {
t.Fatalf("expected EAGAIN, got %v", s2)
}
// Even a partial overlap should fail.
s3 := plt.SetLk(inode, lockRange{Start: 100, End: 200, Typ: syscall.F_RDLCK, Owner: 2, Pid: 20})
if s3 != fuse.EAGAIN {
t.Fatalf("expected EAGAIN for partial overlap with whole-file lock, got %v", s3)
}
}
func TestUnlockNoExistingLocks(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
// Unlock on an inode with no locks should succeed silently.
s := plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_UNLCK, Owner: 1, Pid: 10})
if s != fuse.OK {
t.Fatalf("expected OK for unlock with no existing locks, got %v", s)
}
}
func TestMultipleInodesIndependent(t *testing.T) {
plt := NewPosixLockTable()
// Write lock on inode 1 should not affect inode 2.
plt.SetLk(1, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
s := plt.SetLk(2, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20})
if s != fuse.OK {
t.Fatalf("locks on different inodes should be independent, got %v", s)
}
}
func TestMemoryCleanup(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
plt.ReleaseOwner(inode, 1)
plt.mu.Lock()
_, exists := plt.inodes[inode]
plt.mu.Unlock()
if exists {
t.Fatal("expected inode entry to be cleaned up after all locks released")
}
}
func TestSelectiveWaking(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
// Owner 1 holds write lock on [0, 99], owner 2 holds write lock on [200, 299].
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
plt.SetLk(inode, lockRange{Start: 200, End: 299, Typ: syscall.F_WRLCK, Owner: 2, Pid: 20})
// Owner 3 waits for [50, 60] (blocked by owner 1).
done3 := make(chan fuse.Status, 1)
go func() {
cancel := make(chan struct{})
s := plt.SetLkw(inode, lockRange{Start: 50, End: 60, Typ: syscall.F_WRLCK, Owner: 3, Pid: 30}, cancel)
done3 <- s
}()
// Owner 4 waits for [250, 260] (blocked by owner 2).
done4 := make(chan fuse.Status, 1)
go func() {
cancel := make(chan struct{})
s := plt.SetLkw(inode, lockRange{Start: 250, End: 260, Typ: syscall.F_WRLCK, Owner: 4, Pid: 40}, cancel)
done4 <- s
}()
time.Sleep(50 * time.Millisecond)
// Release owner 1's lock. Only owner 3 should be woken; owner 4 is still blocked.
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_UNLCK, Owner: 1, Pid: 10})
select {
case s := <-done3:
if s != fuse.OK {
t.Fatalf("expected OK for owner 3, got %v", s)
}
case <-time.After(2 * time.Second):
t.Fatal("owner 3 was not woken after owner 1 released")
}
// Owner 4 should still be blocked.
select {
case s := <-done4:
t.Fatalf("owner 4 should still be blocked, but got %v", s)
case <-time.After(100 * time.Millisecond):
// Expected — still blocked.
}
// Now release owner 2's lock. Owner 4 should wake.
plt.SetLk(inode, lockRange{Start: 200, End: 299, Typ: syscall.F_UNLCK, Owner: 2, Pid: 20})
select {
case s := <-done4:
if s != fuse.OK {
t.Fatalf("expected OK for owner 4, got %v", s)
}
case <-time.After(2 * time.Second):
t.Fatal("owner 4 was not woken after owner 2 released")
}
}
func TestSameOwnerReplaceDifferentType(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
// Lock [0, 99] as write.
plt.SetLk(inode, lockRange{Start: 0, End: 99, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
// Replace middle portion [30, 60] with read lock.
plt.SetLk(inode, lockRange{Start: 30, End: 60, Typ: syscall.F_RDLCK, Owner: 1, Pid: 10})
il := plt.getInodeLocks(inode)
il.mu.Lock()
defer il.mu.Unlock()
// Should have 3 locks: write [0,29], read [30,60], write [61,99].
if len(il.locks) != 3 {
t.Fatalf("expected 3 locks after partial type change, got %d", len(il.locks))
}
if il.locks[0].Typ != syscall.F_WRLCK || il.locks[0].Start != 0 || il.locks[0].End != 29 {
t.Errorf("expected write [0,29], got type=%d [%d,%d]", il.locks[0].Typ, il.locks[0].Start, il.locks[0].End)
}
if il.locks[1].Typ != syscall.F_RDLCK || il.locks[1].Start != 30 || il.locks[1].End != 60 {
t.Errorf("expected read [30,60], got type=%d [%d,%d]", il.locks[1].Typ, il.locks[1].Start, il.locks[1].End)
}
if il.locks[2].Typ != syscall.F_WRLCK || il.locks[2].Start != 61 || il.locks[2].End != 99 {
t.Errorf("expected write [61,99], got type=%d [%d,%d]", il.locks[2].Typ, il.locks[2].Start, il.locks[2].End)
}
}
func TestNonAdjacentRangesNotCoalesced(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
// Lock [5, MaxUint64] then [0, 2] — gap at [3,4] must prevent coalescing.
plt.SetLk(inode, lockRange{Start: 5, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
s := plt.SetLk(inode, lockRange{Start: 0, End: 2, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
if s != fuse.OK {
t.Fatalf("expected OK, got %v", s)
}
il := plt.getInodeLocks(inode)
il.mu.Lock()
defer il.mu.Unlock()
if len(il.locks) != 2 {
t.Fatalf("expected 2 separate locks (gap [3,4] prevents coalescing), got %d", len(il.locks))
}
if il.locks[0].Start != 0 || il.locks[0].End != 2 {
t.Errorf("expected first lock [0,2], got [%d,%d]", il.locks[0].Start, il.locks[0].End)
}
if il.locks[1].Start != 5 || il.locks[1].End != math.MaxUint64 {
t.Errorf("expected second lock [5,MaxUint64], got [%d,%d]", il.locks[1].Start, il.locks[1].End)
}
}
func TestAdjacencyNoOverflowAtMaxUint64(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(1)
// Lock to EOF (End = MaxUint64), then lock [0, 0] same type.
// Without the overflow guard, MaxUint64+1 wraps to 0, falsely merging.
plt.SetLk(inode, lockRange{Start: 100, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
plt.SetLk(inode, lockRange{Start: 0, End: 0, Typ: syscall.F_WRLCK, Owner: 1, Pid: 10})
il := plt.getInodeLocks(inode)
il.mu.Lock()
defer il.mu.Unlock()
// Should remain 2 separate locks, not merged.
ownerLocks := 0
for _, lk := range il.locks {
if lk.Owner == 1 {
ownerLocks++
}
}
if ownerLocks != 2 {
t.Fatalf("expected 2 separate locks (no overflow merge), got %d", ownerLocks)
}
}
// TestSetLkRetriesPastDeadInodeLocks deterministically exercises the
// getOrCreateInodeLocks vs maybeCleanupInode race: a caller holding a
// pointer to an inodeLocks that is concurrently marked dead must refetch
// from the map instead of mutating the orphaned instance (which would be
// invisible to subsequent callers and let two exclusive flock holders
// coexist). The test bypasses scheduling by hand-installing a dead il into
// the table and asserting that the next SetLk routes around it.
func TestSetLkRetriesPastDeadInodeLocks(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(42)
// Acquire and release a lock so maybeCleanupInode marks the il dead and
// removes it from the map.
lock := lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 1, IsFlock: true}
if s := plt.SetLk(inode, lock); s != fuse.OK {
t.Fatalf("prime SetLk: got %v", s)
}
dead := plt.getInodeLocks(inode)
unlock := lock
unlock.Typ = syscall.F_UNLCK
if s := plt.SetLk(inode, unlock); s != fuse.OK {
t.Fatalf("prime unlock: got %v", s)
}
if !dead.dead {
t.Fatal("expected il to be marked dead after unlock+cleanup")
}
plt.mu.Lock()
_, stillMapped := plt.inodes[inode]
plt.mu.Unlock()
if stillMapped {
t.Fatal("expected the dead il to be removed from the map")
}
// Simulate the race: the next caller's getOrCreateInodeLocks races with
// the cleanup and ends up holding a pointer to the dead il. We force that
// state by re-publishing `dead` into the map.
plt.mu.Lock()
plt.inodes[inode] = dead
plt.mu.Unlock()
// SetLk must notice dead, refetch, and install the new lock in a fresh il.
if s := plt.SetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 2, IsFlock: true}); s != fuse.OK {
t.Fatalf("SetLk after dead: got %v", s)
}
dead.mu.Lock()
if n := len(dead.locks); n != 0 {
t.Fatalf("dead il should not have accepted the insert, found %d locks", n)
}
dead.mu.Unlock()
plt.mu.Lock()
live := plt.inodes[inode]
plt.mu.Unlock()
if live == nil || live == dead {
t.Fatalf("expected a fresh live il, got %v", live)
}
// A conflicting owner must see the new lock and be rejected.
if s := plt.SetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 3, IsFlock: true}); s != fuse.EAGAIN {
t.Fatalf("second owner should conflict with owner 2, got %v", s)
}
// GetLk must report the conflict as well: without the dead-recheck the
// GetLk path would answer F_UNLCK off the orphaned il.
var out fuse.LkOut
plt.GetLk(inode, lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 4, IsFlock: true}, &out)
if out.Lk.Typ != syscall.F_WRLCK {
t.Fatalf("GetLk should report the live conflict, got Typ=%d", out.Lk.Typ)
}
}
// TestGetInodeLocksEvictsDeadEntry verifies that a dead inodeLocks which
// somehow ends up in the map (e.g. through a future refactor that reorders
// delete and dead=true) is dropped on read so callers never observe one.
// This is the backstop that lets GetLk's and SetLk's retry loops terminate.
func TestGetInodeLocksEvictsDeadEntry(t *testing.T) {
plt := NewPosixLockTable()
inode := uint64(42)
lock := lockRange{Start: 0, End: math.MaxUint64, Typ: syscall.F_WRLCK, Owner: 1, IsFlock: true}
if s := plt.SetLk(inode, lock); s != fuse.OK {
t.Fatalf("prime SetLk: got %v", s)
}
dead := plt.getInodeLocks(inode)
unlock := lock
unlock.Typ = syscall.F_UNLCK
if s := plt.SetLk(inode, unlock); s != fuse.OK {
t.Fatalf("prime unlock: got %v", s)
}
if !dead.dead {
t.Fatal("expected dead after cleanup")
}
// Force the broken state that production cannot reach but tests and
// future refactors might: dead entry still in the map.
plt.mu.Lock()
plt.inodes[inode] = dead
plt.mu.Unlock()
if il := plt.getInodeLocks(inode); il != nil {
t.Fatalf("getInodeLocks should drop a dead map entry, got %p", il)
}
plt.mu.Lock()
_, stillMapped := plt.inodes[inode]
plt.mu.Unlock()
if stillMapped {
t.Fatal("expected dead entry to be removed from the map")
}
// getOrCreateInodeLocks must also self-heal (replace the dead entry with
// a fresh live one) so SetLk's retry path cannot spin.
plt.mu.Lock()
plt.inodes[inode] = dead
plt.mu.Unlock()
fresh := plt.getOrCreateInodeLocks(inode)
if fresh == dead {
t.Fatal("getOrCreateInodeLocks should not return a dead entry")
}
if fresh.dead {
t.Fatal("fresh entry should not be dead")
}
}
// TestConcurrentFlockChurnPreservesMutualExclusion is a stress companion to
// the deterministic tests above. It uses a Swap+CAS detector that flags
// overlap at two points (on acquire and on release), so a second granted
// holder is caught even if it sneaks in after the first goroutine's claim
// but before its release. 16 goroutines churn whole-file exclusive flock on
// one inode; with the race the detector fires hundreds of times per run,
// with the fix it stays at zero.
func TestConcurrentFlockChurnPreservesMutualExclusion(t *testing.T) {
plt := NewPosixLockTable()
const (
inode = uint64(42)
numWorkers = 16
iterations = 500
)
var (
wg sync.WaitGroup
holder atomic.Int64 // 0 = nobody; otherwise = holder's claim token
overlapSeen atomic.Int32
)
for w := 0; w < numWorkers; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
owner := uint64(100 + id)
lock := lockRange{
Start: 0,
End: math.MaxUint64,
Typ: syscall.F_WRLCK,
Owner: owner,
Pid: uint32(id + 1),
IsFlock: true,
}
unlock := lock
unlock.Typ = syscall.F_UNLCK
token := int64(id + 1)
for i := 0; i < iterations; i++ {
// SetLk(WRLCK) may only return OK (granted) or EAGAIN
// (conflict); anything else indicates a bug and the test
// must fail rather than spin. Use Errorf + return because
// Fatalf is not safe from a non-test goroutine.
for {
s := plt.SetLk(inode, lock)
if s == fuse.OK {
break
}
if s != fuse.EAGAIN {
t.Errorf("worker %d iter %d: unexpected SetLk(WRLCK) status %v", id, i, s)
return
}
runtime.Gosched()
}
// Claim the slot. If Swap observes a non-zero predecessor,
// another goroutine already believes it holds the lock.
if prev := holder.Swap(token); prev != 0 {
overlapSeen.Add(1)
}
// Widen the window so a concurrently-granted peer has a
// chance to race into its own Swap before we release.
runtime.Gosched()
runtime.Gosched()
// Release the slot. If CAS fails someone else overwrote our
// claim, which only happens when two holders raced.
if !holder.CompareAndSwap(token, 0) {
overlapSeen.Add(1)
}
if s := plt.SetLk(inode, unlock); s != fuse.OK {
t.Errorf("worker %d iter %d: unexpected SetLk(UNLCK) status %v", id, i, s)
return
}
}
}(w)
}
wg.Wait()
if n := overlapSeen.Load(); n != 0 {
t.Fatalf("flock overlap detected %d times: two owners simultaneously granted the same exclusive lock", n)
}
}