mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-14 18:40:48 +02:00
* 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.
853 lines
26 KiB
Go
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)
|
|
}
|
|
}
|