Test / test (push) Successful in 2m4s
Release / gates (push) Successful in 2m5s
Release / build (amd64, freebsd) (push) Successful in 1m27s
Release / build (amd64, linux) (push) Successful in 1m22s
Release / build (amd64, netbsd) (push) Successful in 1m19s
Release / build (amd64, openbsd) (push) Successful in 1m20s
Release / build (arm64, darwin) (push) Successful in 1m21s
Release / build (arm64, freebsd) (push) Successful in 1m26s
Release / build (arm64, linux) (push) Successful in 1m25s
Release / build (arm64, netbsd) (push) Successful in 1m31s
Release / build (arm64, openbsd) (push) Successful in 1m27s
Release / build (loong64, linux) (push) Successful in 1m37s
Release / build (riscv64, linux) (push) Successful in 1m21s
Release / release (push) Successful in 40s
Assisted-by: GLM 5.3 Flash
175 lines
4.8 KiB
Go
175 lines
4.8 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package nfsfs
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// cacheTestRoot builds a backend over a fresh directory holding one file
|
|
// and returns the backend, the file handle and the path.
|
|
func cacheTestRoot(t *testing.T, content string) (*Local, Handle, string) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
l, err := NewLocal(root)
|
|
if err != nil {
|
|
t.Fatalf("NewLocal: %v", err)
|
|
}
|
|
p := filepath.Join(root, "file")
|
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
rh, err := l.Root()
|
|
if err != nil {
|
|
t.Fatalf("Root: %v", err)
|
|
}
|
|
h, _, err := l.Lookup(rh, "file")
|
|
if err != nil {
|
|
t.Fatalf("Lookup: %v", err)
|
|
}
|
|
return l, h, p
|
|
}
|
|
|
|
// TestCacheStaleAfterRemove covers the identity contract on a cache
|
|
// hit: a file removed while a descriptor of it is cached answers stale,
|
|
// exactly as it does without the cache.
|
|
func TestCacheStaleAfterRemove(t *testing.T) {
|
|
l, h, p := cacheTestRoot(t, "hello")
|
|
if _, err := l.Read(h, 0, 5); err != nil {
|
|
t.Fatalf("warm read: %v", err)
|
|
}
|
|
if err := os.Remove(p); err != nil {
|
|
t.Fatalf("Remove: %v", err)
|
|
}
|
|
if _, err := l.Read(h, 0, 5); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("read after remove: %v, want ErrStale", err)
|
|
}
|
|
}
|
|
|
|
// TestCacheStaleAfterReplace covers a name swapped for another inode: the
|
|
// cached descriptor of the old inode must never serve through it.
|
|
func TestCacheStaleAfterReplace(t *testing.T) {
|
|
l, h, p := cacheTestRoot(t, "old")
|
|
if _, err := l.Read(h, 0, 3); err != nil {
|
|
t.Fatalf("warm read: %v", err)
|
|
}
|
|
if err := os.Remove(p); err != nil {
|
|
t.Fatalf("Remove: %v", err)
|
|
}
|
|
if err := os.WriteFile(p, []byte("new"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
if _, err := l.Read(h, 0, 3); !errors.Is(err, ErrStale) {
|
|
t.Fatalf("read after replace: %v, want ErrStale", err)
|
|
}
|
|
}
|
|
|
|
// TestCacheInodeReuse covers the recycled inode number at the same path:
|
|
// a cached descriptor of the unlinked old inode must never serve the
|
|
// identity the recycled file now carries. The map entry is forged onto
|
|
// the new file, which is exactly the state a reuse produces.
|
|
func TestCacheInodeReuse(t *testing.T) {
|
|
l, h, p := cacheTestRoot(t, "stale data")
|
|
if _, err := l.Read(h, 0, 4); err != nil {
|
|
t.Fatalf("warm read: %v", err)
|
|
}
|
|
// Remove the file behind the backend's back and recreate a fresh one
|
|
// at the same path, then point the old handle's identity at it the
|
|
// way a recycled inode number would.
|
|
fi, err := os.Lstat(p)
|
|
if err != nil {
|
|
t.Fatalf("Lstat: %v", err)
|
|
}
|
|
oldID := fileID{dev: stat(fi).Dev, ino: stat(fi).Ino}
|
|
if err := os.Remove(p); err != nil {
|
|
t.Fatalf("Remove: %v", err)
|
|
}
|
|
if err := os.WriteFile(p, []byte("fresh"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
fi2, err := os.Lstat(p)
|
|
if err != nil {
|
|
t.Fatalf("Lstat: %v", err)
|
|
}
|
|
newID := fileID{dev: stat(fi2).Dev, ino: stat(fi2).Ino}
|
|
l.mu.Lock()
|
|
l.paths[newID] = p
|
|
if newID == oldID {
|
|
// The filesystem handed back the very same inode; the scenario
|
|
// holds without forging anything.
|
|
l.paths[oldID] = p
|
|
} else {
|
|
delete(l.paths, oldID)
|
|
}
|
|
l.mu.Unlock()
|
|
// Whatever the inode numbers did, a fresh read must serve the fresh
|
|
// content, never the unlinked inode's bytes.
|
|
got, err := l.Read(h, 0, 5)
|
|
if err != nil {
|
|
// A stale answer is also safe: the identity broke and the cache
|
|
// refused. Serving the old bytes is the only failure.
|
|
if !errors.Is(err, ErrStale) {
|
|
t.Fatalf("read after reuse: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if string(got) != "fresh" {
|
|
t.Fatalf("read after reuse: %q, want the fresh content", got)
|
|
}
|
|
}
|
|
|
|
// TestCacheWriteThrough covers that writes land and that a follow up read
|
|
// of the same cached file sees them.
|
|
func TestCacheWriteThrough(t *testing.T) {
|
|
l, h, p := cacheTestRoot(t, "0123456789")
|
|
if _, err := l.Read(h, 0, 10); err != nil {
|
|
t.Fatalf("warm read: %v", err)
|
|
}
|
|
if n, err := l.Write(h, 2, []byte("AB")); err != nil || n != 2 {
|
|
t.Fatalf("Write: %d, %v", n, err)
|
|
}
|
|
got, err := l.Read(h, 0, 10)
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if string(got) != "01AB456789" {
|
|
t.Fatalf("Read: %q", got)
|
|
}
|
|
raw, err := os.ReadFile(p)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile: %v", err)
|
|
}
|
|
if string(raw) != "01AB456789" {
|
|
t.Fatalf("file on disk: %q", raw)
|
|
}
|
|
}
|
|
|
|
// TestCacheConcurrent drives reads and writes of one file from many
|
|
// goroutines; the race detector is the judge.
|
|
func TestCacheConcurrent(t *testing.T) {
|
|
l, h, _ := cacheTestRoot(t, "concurrent")
|
|
var wg sync.WaitGroup
|
|
for i := range 8 {
|
|
wg.Go(func() {
|
|
for range 50 {
|
|
if _, err := l.Read(h, 0, 4); err != nil {
|
|
t.Errorf("Read: %v", err)
|
|
return
|
|
}
|
|
if i%2 == 0 {
|
|
if _, err := l.Write(h, 0, []byte("writ")); err != nil {
|
|
t.Errorf("Write: %v", err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
}
|