Files
seaweedfs/test/winfsp/semantics_test.go
T
Chris LuandGitHub 804111745a mount: discard a path-cache insert that raced a purge (#10842)
* mount: discard a path-cache insert that raced a purge

The Windows adapter's walk resolves a component with a Lookup RPC and inserts
the result holding no lock, so a purge can land in between - and what the walk
just resolved is then the very name the purge removed. Anything opening the
old path concurrently with a rename repopulates the cache with the vacated
name, which the next stat is served from for up to a second. The release path
already guards its equivalent insert; the walk had nothing.

The cache counts purges now. A resolve snapshots the generation before its
lookups and insert discards the entry when any purge ran in between, parking
the reference in the graveyard so the in-flight caller keeps a valid inode
either way.

Seen once in CI as TestRenameOverExisting failing with 'source survived the
rename': every SeaweedFS layer is synchronous with the rename, but a
background open of the source - an antivirus scan of the just-written file
fits - can requalify the stale name through this window. The assertion also
reports what stat returned now, and whether it persisted, so a recurrence
indicts a specific layer instead of reading as a mystery.

* mount: cover the path-cache discard by key, and let a discard rest

Review follow-ups. The generation was global, so any purge between a walk's
snapshot and its insert discarded the entry whatever its name - and an open
retries resolve-then-steal only four times before failing with EIO, so
sustained unrelated churn could fail opens of untouched paths. Purges are
remembered by key now and only one that covers the inserted name discards it;
past the remembered window the insert is discarded without a check, which only
costs a retry.

A discard that itself tripped the sweep also handed its own reference straight
to forget while the walker was still using the inode. The graveyard holds two
generations now, so an appended reference always survives the sweep of the
call that appended it - which the displaced-entry and purge paths needed too.

Also restores the original path-cache test suite this branch had overwritten
instead of extended, and rewords the semantics-test failure so it no longer
claims the source survived when stat returned a transient error.

* mount: take an open's reference directly instead of stealing it back

resolveAndSteal cached the final component only to steal it back, so an open
depended on that insert surviving whatever purges raced it - four attempts and
then EIO. The keyed purge window narrowed how often an insert is discarded,
but past the window the discard is blind again, so the cliff had only moved.

A cached entry is still stolen; anything else is now looked up directly, with
the caller owning the reference from the start. No retry loop, and no way for
churn - covered, unrelated or overflowing the window - to fail an open.

Also covers the whole-cache purge: purge of the root with prefix set clears
every entry, but the covers check tested for a '/'-prefixed key that a
normalised key never has, so it covered no in-flight insert at all.
2026-08-19 17:05:49 -07:00

530 lines
14 KiB
Go

package winfsp
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestTruncate(t *testing.T) {
dir := testRoot(t)
path := filepath.Join(dir, "resize")
original := bytes.Repeat([]byte("abcd"), 4096) // 16 KiB
if err := os.WriteFile(path, original, 0644); err != nil {
t.Fatalf("write: %v", err)
}
// Shrink.
if err := os.Truncate(path, 100); err != nil {
t.Fatalf("truncate down: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read after shrink: %v", err)
}
if len(got) != 100 {
t.Fatalf("size after shrink = %d, want 100", len(got))
}
if !bytes.Equal(got, original[:100]) {
t.Fatal("the surviving prefix does not match the original")
}
// Grow: the new space has to read back as zeros, not stale bytes.
if err := os.Truncate(path, 8192); err != nil {
t.Fatalf("truncate up: %v", err)
}
got, err = os.ReadFile(path)
if err != nil {
t.Fatalf("read after grow: %v", err)
}
if len(got) != 8192 {
t.Fatalf("size after grow = %d, want 8192", len(got))
}
if !bytes.Equal(got[100:], make([]byte, 8192-100)) {
t.Fatal("extension is not zero-filled")
}
// Truncate to empty.
if err := os.Truncate(path, 0); err != nil {
t.Fatalf("truncate to zero: %v", err)
}
if info, err := os.Stat(path); err != nil || info.Size() != 0 {
t.Fatalf("stat after zero truncate: size=%v err=%v", info, err)
}
}
func TestAppend(t *testing.T) {
dir := testRoot(t)
path := filepath.Join(dir, "log")
for i := 0; i < 5; i++ {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
t.Fatalf("open for append: %v", err)
}
if _, err := fmt.Fprintf(f, "line %d\n", i); err != nil {
t.Fatalf("append: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("close: %v", err)
}
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
want := "line 0\nline 1\nline 2\nline 3\nline 4\n"
if string(got) != want {
t.Fatalf("appended content = %q, want %q", got, want)
}
}
func TestReadPastEndOfFile(t *testing.T) {
dir := testRoot(t)
path := filepath.Join(dir, "short")
if err := os.WriteFile(path, []byte("12345"), 0644); err != nil {
t.Fatalf("write: %v", err)
}
f, err := os.Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer f.Close()
buf := make([]byte, 16)
n, err := f.ReadAt(buf, 100)
if n != 0 {
t.Fatalf("read %d bytes past EOF, want 0", n)
}
if err == nil {
t.Fatal("reading past EOF returned no error, want io.EOF")
}
}
func TestErrorPaths(t *testing.T) {
dir := testRoot(t)
t.Run("open missing file", func(t *testing.T) {
_, err := os.Open(filepath.Join(dir, "does-not-exist"))
if !os.IsNotExist(err) {
t.Fatalf("got %v, want a not-exist error", err)
}
})
t.Run("exclusive create over existing", func(t *testing.T) {
path := filepath.Join(dir, "taken")
if err := os.WriteFile(path, []byte("x"), 0644); err != nil {
t.Fatalf("write: %v", err)
}
_, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if !os.IsExist(err) {
t.Fatalf("got %v, want an already-exists error", err)
}
})
t.Run("remove missing file", func(t *testing.T) {
if err := os.Remove(filepath.Join(dir, "never-there")); !os.IsNotExist(err) {
t.Fatalf("got %v, want a not-exist error", err)
}
})
t.Run("mkdir under a file", func(t *testing.T) {
path := filepath.Join(dir, "regular")
if err := os.WriteFile(path, []byte("x"), 0644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.Mkdir(filepath.Join(path, "child"), 0755); err == nil {
t.Fatal("created a directory underneath a regular file")
}
})
t.Run("readdir of a missing directory", func(t *testing.T) {
if _, err := os.ReadDir(filepath.Join(dir, "no-such-dir")); err == nil {
t.Fatal("listed a directory that does not exist")
}
})
}
func TestRenameOverExisting(t *testing.T) {
dir := testRoot(t)
src := filepath.Join(dir, "src")
dst := filepath.Join(dir, "dst")
if err := os.WriteFile(src, []byte("new"), 0644); err != nil {
t.Fatalf("write src: %v", err)
}
if err := os.WriteFile(dst, []byte("old"), 0644); err != nil {
t.Fatalf("write dst: %v", err)
}
if err := os.Rename(src, dst); err != nil {
t.Fatalf("rename over existing: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(got) != "new" {
t.Fatalf("target holds %q, want the source content", got)
}
if fi, err := os.Stat(src); !os.IsNotExist(err) {
// Both a stale positive and a transient non-ENOENT error land here, and
// they indict different layers; a second look says whether it persists.
time.Sleep(200 * time.Millisecond)
fi2, err2 := os.Stat(src)
t.Fatalf("stat of the renamed-away source did not return not-exist: stat=%v err=%v; 200ms later stat=%v err=%v",
describeFileInfo(fi), err, describeFileInfo(fi2), err2)
}
}
func TestRenameAcrossDirectories(t *testing.T) {
dir := testRoot(t)
from := filepath.Join(dir, "from")
to := filepath.Join(dir, "to")
for _, d := range []string{from, to} {
if err := os.Mkdir(d, 0755); err != nil {
t.Fatalf("mkdir %s: %v", d, err)
}
}
src := filepath.Join(from, "file")
dst := filepath.Join(to, "file")
if err := os.WriteFile(src, []byte("moved"), 0644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.Rename(src, dst); err != nil {
t.Fatalf("rename across directories: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(got) != "moved" {
t.Fatalf("content after move = %q", got)
}
// A directory should move too, children and all.
nested := filepath.Join(from, "sub")
if err := os.Mkdir(nested, 0755); err != nil {
t.Fatalf("mkdir sub: %v", err)
}
if err := os.WriteFile(filepath.Join(nested, "child"), []byte("c"), 0644); err != nil {
t.Fatalf("write child: %v", err)
}
if err := os.Rename(nested, filepath.Join(to, "sub")); err != nil {
t.Fatalf("rename directory: %v", err)
}
if _, err := os.Stat(filepath.Join(to, "sub", "child")); err != nil {
t.Fatalf("child did not come along: %v", err)
}
}
// TestAwkwardNames covers what has to survive the UTF-16 boundary and the
// shapes Windows software tends to produce.
func TestAwkwardNames(t *testing.T) {
dir := testRoot(t)
names := []string{
"café.txt",
"日本語のファイル.dat",
"emoji-🐟.bin",
"with space.txt",
"with.many.dots.txt",
"UPPER and lower.TXT",
"dash-and_underscore.txt",
"'quoted'.txt",
"(parens).txt",
"#hash&amp.txt",
strings.Repeat("x", 200) + ".txt",
}
for _, name := range names {
t.Run(name, func(t *testing.T) {
path := filepath.Join(dir, name)
want := []byte(name)
if err := os.WriteFile(path, want, 0644); err != nil {
t.Fatalf("write: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("content = %q, want %q", got, want)
}
})
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("readdir: %v", err)
}
found := make(map[string]bool, len(entries))
for _, e := range entries {
found[e.Name()] = true
}
for _, name := range names {
if !found[name] {
t.Errorf("%q did not come back from readdir", name)
}
}
}
func TestDeepDirectoryNesting(t *testing.T) {
dir := testRoot(t)
deep := dir
for i := 0; i < 24; i++ {
deep = filepath.Join(deep, fmt.Sprintf("level%02d", i))
}
if err := os.MkdirAll(deep, 0755); err != nil {
t.Fatalf("mkdirall depth 24: %v", err)
}
leaf := filepath.Join(deep, "leaf.txt")
if err := os.WriteFile(leaf, []byte("bottom"), 0644); err != nil {
t.Fatalf("write leaf: %v", err)
}
got, err := os.ReadFile(leaf)
if err != nil {
t.Fatalf("read leaf: %v", err)
}
if string(got) != "bottom" {
t.Fatalf("leaf content = %q", got)
}
}
// TestConcurrentSameFile is the interleaving that matters: several handles on
// one file at once, rather than one writer each on separate files.
func TestConcurrentSameFile(t *testing.T) {
dir := testRoot(t)
path := filepath.Join(dir, "shared")
const writers = 4
const blockSize = 4096
if err := os.WriteFile(path, make([]byte, writers*blockSize), 0644); err != nil {
t.Fatalf("preallocate: %v", err)
}
var wg sync.WaitGroup
errs := make(chan error, writers)
for w := 0; w < writers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
f, err := os.OpenFile(path, os.O_WRONLY, 0644)
if err != nil {
errs <- fmt.Errorf("writer %d open: %w", w, err)
return
}
defer f.Close()
block := bytes.Repeat([]byte{byte('A' + w)}, blockSize)
if _, err := f.WriteAt(block, int64(w*blockSize)); err != nil {
errs <- fmt.Errorf("writer %d write: %w", w, err)
}
}(w)
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("%v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if len(got) != writers*blockSize {
t.Fatalf("size = %d, want %d", len(got), writers*blockSize)
}
for w := 0; w < writers; w++ {
want := bytes.Repeat([]byte{byte('A' + w)}, blockSize)
if !bytes.Equal(got[w*blockSize:(w+1)*blockSize], want) {
t.Fatalf("block %d was not written whole", w)
}
}
}
func TestConcurrentReaders(t *testing.T) {
dir := testRoot(t)
path := filepath.Join(dir, "read-me")
want := contentFor("concurrent-readers", 1<<20)
if err := os.WriteFile(path, want, 0644); err != nil {
t.Fatalf("write: %v", err)
}
// WriteFile's own existence probe ran while the file did not exist, and
// WinFsp may serve that answer from its metadata cache for up to the
// mount's FileInfoTimeout. Establish visibility once before racing the
// readers, so the race exercises concurrent reading rather than the
// cache window.
deadline := time.Now().Add(3 * time.Second)
for {
if _, err := os.Stat(path); err == nil {
break
} else if time.Now().After(deadline) {
t.Fatalf("file never became visible: %v", err)
}
time.Sleep(50 * time.Millisecond)
}
var wg sync.WaitGroup
errs := make(chan error, 8)
for r := 0; r < 8; r++ {
wg.Add(1)
go func() {
defer wg.Done()
got, err := os.ReadFile(path)
if err != nil {
errs <- err
return
}
if !bytes.Equal(got, want) {
errs <- fmt.Errorf("reader saw %d bytes, want %d", len(got), len(want))
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("%v", err)
}
}
// Hard links are not something WinFsp offers, so the mount has to refuse them
// rather than appear to succeed.
func TestHardLinkUnsupported(t *testing.T) {
dir := testRoot(t)
target := filepath.Join(dir, "original")
if err := os.WriteFile(target, []byte("x"), 0644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.Link(target, filepath.Join(dir, "hardlink")); err == nil {
t.Fatal("hard link creation reported success")
}
}
// Symlinks are refused rather than half-supported: WinFsp needs a reparse
// point to follow one, and an entry it cannot follow reads back empty.
func TestSymlinkUnsupported(t *testing.T) {
dir := testRoot(t)
target := filepath.Join(dir, "target.txt")
if err := os.WriteFile(target, []byte("pointed at"), 0644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.Symlink(target, filepath.Join(dir, "link.txt")); err == nil {
t.Fatal("symlink creation reported success")
}
}
func TestModTimeAdvances(t *testing.T) {
dir := testRoot(t)
path := filepath.Join(dir, "touched")
if err := os.WriteFile(path, []byte("one"), 0644); err != nil {
t.Fatalf("write: %v", err)
}
first, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
time.Sleep(1100 * time.Millisecond)
if err := os.WriteFile(path, []byte("two"), 0644); err != nil {
t.Fatalf("rewrite: %v", err)
}
second, err := os.Stat(path)
if err != nil {
t.Fatalf("stat again: %v", err)
}
if !second.ModTime().After(first.ModTime()) {
t.Fatalf("mtime did not advance: %v then %v", first.ModTime(), second.ModTime())
}
}
func TestChtimes(t *testing.T) {
dir := testRoot(t)
path := filepath.Join(dir, "dated")
if err := os.WriteFile(path, []byte("x"), 0644); err != nil {
t.Fatalf("write: %v", err)
}
want := time.Date(2020, 3, 4, 5, 6, 7, 0, time.UTC)
if err := os.Chtimes(path, want, want); err != nil {
t.Fatalf("chtimes: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if diff := info.ModTime().UTC().Sub(want); diff > 2*time.Second || diff < -2*time.Second {
t.Fatalf("mtime = %v, want about %v", info.ModTime().UTC(), want)
}
}
func TestStatfsIsSelfConsistent(t *testing.T) {
if *mountPoint == "" {
t.Skip("no -mountpoint given; this test needs a live WinFsp mount")
}
var free, total, totalFree uint64
if err := getDiskFreeSpace(*mountPoint, &free, &total, &totalFree); err != nil {
t.Fatalf("GetDiskFreeSpaceEx: %v", err)
}
if total == 0 {
t.Fatal("drive reports zero total bytes")
}
if free > total {
t.Fatalf("free (%d) exceeds total (%d)", free, total)
}
if totalFree > total {
t.Fatalf("total free (%d) exceeds total (%d)", totalFree, total)
}
}
// TestDeleteOnClose covers the pattern Windows software uses for temporaries:
// the file goes away when the last handle closes, with no explicit delete. A
// filesystem that ignores the flag leaves the name behind, and the next
// program to create it gets a collision.
func TestDeleteOnClose(t *testing.T) {
dir := testRoot(t)
path := filepath.Join(dir, "ephemeral.tmp")
h, err := createDeleteOnClose(path)
if err != nil {
if errors.Is(err, errWindowsOnly) {
t.Skip("delete-on-close needs the Win32 create call")
}
t.Fatalf("open with delete-on-close: %v", err)
}
if _, err := os.Stat(path); err != nil {
closeHandle(h)
t.Fatalf("file is not visible while its handle is open: %v", err)
}
if err := closeHandle(h); err != nil {
t.Fatalf("close: %v", err)
}
if _, err := os.Stat(path); err == nil {
t.Fatal("file outlived its last handle")
} else if !os.IsNotExist(err) {
t.Fatalf("stat after close: %v", err)
}
// The name has to be free again, which is what the conformance suite
// tripped over when an aborted test left its file behind.
h, err = createDeleteOnClose(path)
if err != nil {
t.Fatalf("recreating the same name failed: %v", err)
}
if err := closeHandle(h); err != nil {
t.Fatalf("close after recreate: %v", err)
}
}
func describeFileInfo(fi os.FileInfo) string {
if fi == nil {
return "<nil>"
}
return fmt.Sprintf("{size=%d mode=%v mtime=%s}", fi.Size(), fi.Mode(), fi.ModTime().Format(time.RFC3339Nano))
}