Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions embedfs/concurrent_readat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package embedfs

import (
"embed"
"sync"
"testing"

"github.com/stretchr/testify/require"
)

//go:embed testdata/concurrent.bin
var concurrentFS embed.FS

// TestFileConcurrentReadAt asserts that concurrent ReadAt calls on a
// shared embedfs file handle are safe under -race and return correct
// per-byte results. embedfs wraps bytes.Reader, which is already
// concurrent-safe for ReadAt; this test pins the billy.File.ReadAt
// contract so future changes to the wrapper cannot regress it.
func TestFileConcurrentReadAt(t *testing.T) {
t.Parallel()

want, err := concurrentFS.ReadFile("testdata/concurrent.bin")
require.NoError(t, err)
require.Equal(t, 4096, len(want))

fs := New(&concurrentFS)
f, err := fs.Open("testdata/concurrent.bin")
require.NoError(t, err)
t.Cleanup(func() { _ = f.Close() })

const workers = 8
const iters = 200
const bufLen = 64

var wg sync.WaitGroup
wg.Add(workers)
for w := range workers {
go func() {
defer wg.Done()
buf := make([]byte, bufLen)
for i := range iters {
off := int64((w*131 + i*257) % (len(want) - bufLen))
n, err := f.ReadAt(buf, off)
require.NoError(t, err)
require.Equal(t, bufLen, n)
require.Equal(t, want[off:off+int64(n)], buf[:n])
}
}()
}
wg.Wait()
}
2 changes: 1 addition & 1 deletion embedfs/embed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ func TestReadDir(t *testing.T) {
name: "testdataDir w/ path",
path: "testdata",
fs: &testdataDir,
want: []string{"empty.txt", "empty2.txt"},
want: []string{"concurrent.bin", "empty.txt", "empty2.txt"},
},
{
name: "testdataDir return no dir names",
Expand Down
Binary file added embedfs/testdata/concurrent.bin
Binary file not shown.
6 changes: 6 additions & 0 deletions fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ type File interface {
Name() string
io.Writer
io.WriterAt
// ReadAt must be safe for concurrent calls from multiple
// goroutines on the same handle, matching the [io.ReaderAt]
// contract documented in package io. Callers performing
// concurrent random-access reads on a shared File rely on
// this; backings that cannot honour it must not satisfy
// [billy.File].
io.ReaderAt
io.Seeker
// Truncate the file.
Expand Down
54 changes: 54 additions & 0 deletions memfs/concurrent_readat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package memfs

import (
"sync"
"testing"

"github.com/stretchr/testify/require"
)

// TestFileConcurrentReadAt asserts that concurrent ReadAt calls on a
// shared memfs file handle are safe under -race and return correct
// per-byte results. memfs is the in-memory billy backing that go-git
// and other consumers rely on for tests; the billy.File.ReadAt
// contract documents concurrent-safety as required, and this test
// pins memfs's compliance.
func TestFileConcurrentReadAt(t *testing.T) {
t.Parallel()

const size = 64 * 1024
want := make([]byte, size)
for i := range want {
want[i] = byte(i % 251)
}

fs := New()
f, err := fs.Create("data")
require.NoError(t, err)
_, err = f.Write(want)
require.NoError(t, err)
require.NoError(t, f.Close())

rf, err := fs.Open("data")
require.NoError(t, err)
t.Cleanup(func() { _ = rf.Close() })

const workers = 8
const iters = 200
var wg sync.WaitGroup
wg.Add(workers)
for w := range workers {
go func() {
defer wg.Done()
buf := make([]byte, 1024)
for i := range iters {
off := int64((w*131 + i*257) % (len(want) - len(buf)))
n, err := rf.ReadAt(buf, off)
require.NoError(t, err)
require.Equal(t, len(buf), n)
require.Equal(t, want[off:off+int64(n)], buf[:n])
}
}()
}
wg.Wait()
}
177 changes: 177 additions & 0 deletions osfs/mmap_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//go:build darwin || linux

package osfs

import (
"errors"
"io"
"math"
"os"
"runtime"
"sync"

"golang.org/x/sys/unix"
)

// mmapFile is a billy.File backed by a read-only memory map. It is
// returned from BoundOS/RootOS.OpenFile when the filesystem was
// constructed with [WithMmap] and the file is opened without write
// flags. Read and Seek track a real cursor over the mapped bytes,
// ReadAt is concurrent-safe (multiple goroutines may call it in
// parallel against the same handle) and serialised against Close
// via an RWMutex so munmap cannot run while a read is in flight.
// Write/WriteAt/Truncate return [os.ErrPermission] — the file is
// read-only by construction.
type mmapFile struct {
f *os.File
data []byte
name string

mu sync.RWMutex
cursor int64
closed bool
}

// newMmapFile maps f read-only and returns an [*mmapFile] that owns
// f. On success the returned handle is responsible for closing the
// underlying [*os.File] via [(*mmapFile).Close].
//
// If mmap is unavailable for this particular file (zero size, size
// beyond platform int, mmap rejected by the kernel for pipes/devices
// etc.) the function returns [errMmapUnavailable] without closing f
// so the caller can fall back to a regular [*file] wrapper.
//
// Any other error (e.g. fstat failing) is propagated as-is and f is
// closed before returning — the caller must not use it.
func newMmapFile(f *os.File, name string) (*mmapFile, error) {
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, err
}

size := info.Size()
if size <= 0 || size > int64(math.MaxInt) {
// unix.Mmap rejects size 0, and 32-bit platforms can't
// represent very large mappings as an int. Either case
// is fine for the regular fd wrapper.
return nil, errMmapUnavailable
}

data, err := unix.Mmap(int(f.Fd()), 0, int(size), unix.PROT_READ, unix.MAP_SHARED)
if err != nil {
// Many failure modes here are legitimate (pipes, devices,
// FS quirks). Defer to the fd wrapper.
return nil, errMmapUnavailable
}

m := &mmapFile{f: f, data: data, name: name}
// Belt and braces for callers that forget to Close: the runtime
// will munmap and close the fd when m becomes unreachable. Close
// clears this finalizer on the orderly path.
runtime.SetFinalizer(m, (*mmapFile).Close)
return m, nil
}

func (m *mmapFile) Name() string { return m.name }

// Stat returns the underlying *os.File's FileInfo unchanged so that
// f.Stat().Name() matches the basename returned by the fd-backed
// *file across both backings.
func (m *mmapFile) Stat() (os.FileInfo, error) {
return m.f.Stat()
}

// Read implements [io.Reader]. It holds the write lock because it
// mutates the shared cursor; concurrent Read+Read would otherwise
// race on m.cursor even though both could read m.data under RLock.
// Random-access callers should use ReadAt, which is the parallel API.
func (m *mmapFile) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return 0, os.ErrClosed
}
if m.cursor >= int64(len(m.data)) {
return 0, io.EOF
}
n := copy(p, m.data[m.cursor:])
m.cursor += int64(n)
return n, nil
}

func (m *mmapFile) ReadAt(p []byte, off int64) (int, error) {
if len(p) == 0 {
return 0, nil
}
m.mu.RLock()
defer m.mu.RUnlock()
if m.closed {
return 0, os.ErrClosed
}
if off < 0 {
return 0, &os.PathError{Op: "readat", Path: m.name, Err: errors.New("negative offset")}
}
if off >= int64(len(m.data)) {
return 0, io.EOF
}
n := copy(p, m.data[off:])
if n < len(p) {
return n, io.EOF
}
return n, nil
}

func (m *mmapFile) Seek(offset int64, whence int) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return 0, os.ErrClosed
}
var abs int64
switch whence {
case io.SeekStart:
abs = offset
case io.SeekCurrent:
abs = m.cursor + offset
case io.SeekEnd:
abs = int64(len(m.data)) + offset
default:
return 0, &os.PathError{Op: "seek", Path: m.name, Err: errors.New("invalid whence")}
}
if abs < 0 {
return 0, &os.PathError{Op: "seek", Path: m.name, Err: errors.New("negative position")}
}
m.cursor = abs
return abs, nil
}

func (m *mmapFile) Write(p []byte) (int, error) {
return 0, &os.PathError{Op: "write", Path: m.name, Err: os.ErrPermission}
}

func (m *mmapFile) WriteAt(p []byte, off int64) (int, error) {
return 0, &os.PathError{Op: "writeat", Path: m.name, Err: os.ErrPermission}
}

func (m *mmapFile) Truncate(size int64) error {
return &os.PathError{Op: "truncate", Path: m.name, Err: os.ErrPermission}
}

func (m *mmapFile) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return os.ErrClosed
}
m.closed = true
runtime.SetFinalizer(m, nil)

munmapErr := unix.Munmap(m.data)
m.data = nil
closeErr := m.f.Close()
return errors.Join(munmapErr, closeErr)
}
18 changes: 18 additions & 0 deletions osfs/mmap_file_assert_other_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//go:build !darwin && !linux && !wasm

package osfs

import (
"testing"

"github.com/go-git/go-billy/v6"
"github.com/stretchr/testify/require"
)

func assertMmapBackingWhenAvailable(t *testing.T, f billy.File) {
t.Helper()
// mmap is unavailable on this platform; WithMmap is honoured as
// best-effort and falls through to the fd-backed wrapper.
_, ok := f.(*file)
require.True(t, ok, "platform has no mmap path, expected *file, got %T", f)
}
20 changes: 20 additions & 0 deletions osfs/mmap_file_assert_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//go:build darwin || linux

package osfs

import (
"testing"

"github.com/go-git/go-billy/v6"
"github.com/stretchr/testify/require"
)

func assertMmapBackingWhenAvailable(t *testing.T, f billy.File) {
t.Helper()
_, ok := f.(*mmapFile)
require.True(t, ok, "WithMmap should select *mmapFile on this platform, got %T", f)
}

// Ensure *mmapFile satisfies billy.File at compile-time on platforms
// where it has a real implementation.
var _ billy.File = (*mmapFile)(nil)
28 changes: 28 additions & 0 deletions osfs/mmap_file_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//go:build !darwin && !linux && !js

package osfs

import "os"

// mmapFile is a stub on platforms without mmap support. The
// real implementation lives in mmap_file.go (darwin || linux);
// here the type exists solely so the wrapOpenedFile return
// path that constructs it still type-checks. newMmapFile
// always returns [errMmapUnavailable], so a value of this type
// is never actually constructed at runtime — the method bodies
// below are unreachable.
type mmapFile struct{}

func newMmapFile(_ *os.File, _ string) (*mmapFile, error) {
return nil, errMmapUnavailable
}

func (m *mmapFile) Name() string { return "" }
func (m *mmapFile) Stat() (os.FileInfo, error) { return nil, os.ErrInvalid }
func (m *mmapFile) Read(p []byte) (int, error) { return 0, os.ErrInvalid }
func (m *mmapFile) ReadAt(p []byte, off int64) (int, error) { return 0, os.ErrInvalid }
func (m *mmapFile) Write(p []byte) (int, error) { return 0, os.ErrInvalid }
func (m *mmapFile) WriteAt(p []byte, off int64) (int, error) { return 0, os.ErrInvalid }
func (m *mmapFile) Seek(offset int64, whence int) (int64, error) { return 0, os.ErrInvalid }
func (m *mmapFile) Truncate(size int64) error { return os.ErrInvalid }
func (m *mmapFile) Close() error { return os.ErrInvalid }
Loading
Loading