dsfx/internal/sim/disk.go

311 lines
7.4 KiB
Go
Raw Normal View History

package sim
import (
"errors"
"io"
"io/fs"
"math/rand"
"sync"
"time"
2025-04-01 10:57:37 -04:00
"git.numenor-labs.us/dsfx/internal/lib/disk"
)
// Tolerance defines simulation tolerance parameters.
type Tolerance struct {
// Latency to wait before executing an operation.
Latency time.Duration
// FailureChance is the probability (0.01.0) that an operation fails.
FailureChance float64
// CorruptionChance is the probability (0.01.0) that a write is corrupted.
CorruptionChance float64
}
// simEntry represents an entry in our inmemory file system.
type simEntry struct {
name string
isDir bool
perm fs.FileMode
modTime time.Time
// For files, data holds the file contents.
data []byte
mu sync.Mutex
}
// simFile is a simulated file object. It implements fs.File and io.Writer.
// The same underlying simEntry is shared among all handles to a given file.
type simFile struct {
entry *simEntry
// offset to simulate reading sequentially.
offset int
// readOnly indicates whether writes are allowed.
readOnly bool
// closed is set once Close() has been called.
closed bool
// You could add a mutex here if you want to protect concurrent access per handle.
mu sync.Mutex
// tol copied from the SimDisk for perfile simulation.
tol Tolerance
}
// Ensure simFile implements the interfaces.
var _ disk.File = (*simFile)(nil)
// Read reads from the simulated file starting at the current offset.
func (sf *simFile) Read(p []byte) (int, error) {
sf.mu.Lock()
defer sf.mu.Unlock()
if sf.closed {
return 0, errors.New("read from closed file")
}
// Lock the underlying data.
sf.entry.mu.Lock()
defer sf.entry.mu.Unlock()
if sf.offset >= len(sf.entry.data) {
return 0, io.EOF
}
n := copy(p, sf.entry.data[sf.offset:])
sf.offset += n
return n, nil
}
// Write writes the given bytes at the end of the file (for a writable file).
// It may simulate corruption.
func (sf *simFile) Write(p []byte) (int, error) {
if sf.readOnly {
return 0, errors.New("cannot write to read-only file")
}
sf.mu.Lock()
defer sf.mu.Unlock()
if sf.closed {
return 0, errors.New("write to closed file")
}
// simulate latency
time.Sleep(sf.tol.Latency)
// simulate failure
if rand.Float64() < sf.tol.FailureChance {
return 0, errors.New("simulated write failure")
}
// prepare data to write
dataToWrite := make([]byte, len(p))
copy(dataToWrite, p)
// simulate corruption by flipping bits in the data (if triggered)
if rand.Float64() < sf.tol.CorruptionChance {
for i := range dataToWrite {
dataToWrite[i] = ^dataToWrite[i] // simple corruption: bitwise complement
}
}
// lock the underlying entry and append data
sf.entry.mu.Lock()
defer sf.entry.mu.Unlock()
sf.entry.data = append(sf.entry.data, dataToWrite...)
// update modification time
sf.entry.modTime = time.Now()
return len(p), nil
}
// Close marks the file as closed.
func (sf *simFile) Close() error {
sf.mu.Lock()
defer sf.mu.Unlock()
if sf.closed {
return errors.New("file already closed")
}
sf.closed = true
return nil
}
// Stat returns file information for the simulated file.
func (sf *simFile) Stat() (fs.FileInfo, error) {
sf.mu.Lock()
defer sf.mu.Unlock()
if sf.closed {
return nil, errors.New("stat on closed file")
}
return &simFileInfo{entry: sf.entry}, nil
}
// simFileInfo implements fs.FileInfo for a simEntry.
type simFileInfo struct {
entry *simEntry
}
var _ fs.FileInfo = (*simFileInfo)(nil)
func (fi *simFileInfo) Name() string { return fi.entry.name }
func (fi *simFileInfo) Size() int64 {
fi.entry.mu.Lock()
defer fi.entry.mu.Unlock()
return int64(len(fi.entry.data))
}
func (fi *simFileInfo) Mode() fs.FileMode { return fi.entry.perm }
func (fi *simFileInfo) ModTime() time.Time { return fi.entry.modTime }
func (fi *simFileInfo) IsDir() bool { return fi.entry.isDir }
func (fi *simFileInfo) Sys() interface{} { return nil }
// SimDisk is our inmemory simulation that implements the disk.Disk interface.
type SimDisk struct {
// tol holds the tolerance parameters to simulate latency, failures, corruption.
tol Tolerance
// entries is a map from file or directory name to its corresponding simEntry.
entries map[string]*simEntry
mu sync.Mutex
}
// NewSimDisk returns a new simulated disk with the given tolerance parameters.
func NewSimDisk(tol Tolerance) disk.Disk {
return &SimDisk{
tol: tol,
entries: make(map[string]*simEntry),
}
}
// simulateOp sleeps for the configured latency and then returns an error if a failure is simulated.
func (sd *SimDisk) simulateOp() error {
time.Sleep(sd.tol.Latency)
if rand.Float64() < sd.tol.FailureChance {
return errors.New("simulated disk operation failure")
}
return nil
}
// Mkdir creates a directory in the simulated file system.
func (sd *SimDisk) Mkdir(name string, perm fs.FileMode) error {
if err := sd.simulateOp(); err != nil {
return err
}
sd.mu.Lock()
defer sd.mu.Unlock()
if _, exists := sd.entries[name]; exists {
return errors.New("directory already exists")
}
sd.entries[name] = &simEntry{
name: name,
isDir: true,
perm: perm,
modTime: time.Now(),
}
return nil
}
// MkdirAll creates a directory and any necessary parent directories in the simulated file system.
// It behaves like os.MkdirAll.
// If the directory already exists, it does nothing.
func (sd *SimDisk) MkdirAll(name string, perm fs.FileMode) error {
if err := sd.simulateOp(); err != nil {
return err
}
sd.mu.Lock()
defer sd.mu.Unlock()
if _, exists := sd.entries[name]; exists {
return errors.New("directory already exists")
}
sd.entries[name] = &simEntry{
name: name,
isDir: true,
perm: perm,
modTime: time.Now(),
}
return nil
}
// Create creates (or truncates) a file in the simulated file system and returns a writable file.
func (sd *SimDisk) Create(name string) (disk.File, error) {
if err := sd.simulateOp(); err != nil {
return nil, err
}
sd.mu.Lock()
// For simplicity, Create always truncates (or creates new)
se := &simEntry{
name: name,
isDir: false,
perm: 0644,
modTime: time.Now(),
data: []byte{},
}
sd.entries[name] = se
sd.mu.Unlock()
// Return a writable file handle
return &simFile{
entry: se,
offset: 0,
readOnly: false,
tol: sd.tol,
}, nil
}
// Open opens an existing file in the simulated file system.
// The returned file is "read-only" (write attempts will error),
func (sd *SimDisk) Open(name string) (disk.File, error) {
if err := sd.simulateOp(); err != nil {
return nil, err
}
sd.mu.Lock()
se, exists := sd.entries[name]
sd.mu.Unlock()
if !exists {
return nil, errors.New("file does not exist")
}
if se.isDir {
return nil, errors.New("cannot open directory")
}
// Return a new file handle starting at offset 0; marked as readOnly.
return &simFile{
entry: se,
offset: 0,
readOnly: true,
tol: sd.tol,
}, nil
}
// Stat returns information about the file or directory.
func (sd *SimDisk) Stat(name string) (fs.FileInfo, error) {
if err := sd.simulateOp(); err != nil {
return nil, err
}
sd.mu.Lock()
se, exists := sd.entries[name]
sd.mu.Unlock()
if !exists {
return nil, errors.New("no such file or directory")
}
return &simFileInfo{entry: se}, nil
}
// Remove deletes a file or directory from the simulated file system.
func (sd *SimDisk) Remove(name string) error {
if err := sd.simulateOp(); err != nil {
return err
}
sd.mu.Lock()
defer sd.mu.Unlock()
if _, exists := sd.entries[name]; !exists {
return errors.New("file or directory does not exist")
}
delete(sd.entries, name)
return nil
}