detached 72a9140

feat(fsys): add file interface and fault-injecting fs

commit
72a914061d73b35ca844ef1d590b8d0c12b4c8c4
author
brice · · 2026-10-10 01:52 UTC
committer
brice · · 2026-10-10 01:57 UTC
parent
15cf399
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +1216 -0
A internal/fsys/faultfs/faultfs.go +592 -0
@@ -0,0 +1,592 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package faultfs is an in-memory fsys.FS for tests. It models a page cache in
4+// front of durable storage and can inject write and sync failures, delay
5+// syncs, count syncs and simulate a power loss.
6+//
7+// Durability model: file bytes become durable on Sync or SyncAll; directory
8+// operations (create, rename, remove, mkdir) become durable on SyncDir of the
9+// directory that contains them. PowerLoss discards everything that is not.
10+//
11+// All methods are safe for concurrent use.
12+package faultfs
13+
14+import (
15+ "errors"
16+ "io"
17+ "io/fs"
18+ "math/rand/v2"
19+ "os"
20+ "path/filepath"
21+ "sort"
22+ "sync"
23+ "time"
24+
25+ "bnema.dev/ermite-kv/internal/fsys"
26+)
27+
28+// ErrPowerLoss is returned by handles that were open when PowerLoss was called.
29+var ErrPowerLoss = errors.New("faultfs: handle invalidated by power loss")
30+
31+// ErrInjected is the default error of injected failures.
32+var ErrInjected = errors.New("faultfs: injected failure")
33+
34+const defaultPageSize = 4096
35+
36+type node struct {
37+ data []byte // current content (page cache)
38+ durable []byte // content as of the last successful sync
39+ truncated bool // data was truncated since the last sync
40+}
41+
42+type nsOp struct {
43+ kind byte // 'c' create, 'r' rename, 'd' remove, 'm' mkdir
44+ path string
45+ oldPath string // rename source
46+ n *node // created, removed or renamed node
47+ prev *node // node replaced by a rename
48+ dirs map[string]bool
49+}
50+
51+// FS is an in-memory file system with fault injection.
52+type FS struct {
53+ mu sync.Mutex
54+ files map[string]*node
55+ dirs map[string]bool
56+ locks map[string]bool
57+ ns []*nsOp // directory operations not yet made durable
58+ epoch uint64 // incremented by PowerLoss
59+
60+ writes, syncs, syncAlls int
61+ failWrite map[int]writeFault // by absolute write number
62+ failSync map[int]error // by absolute sync number (Sync+SyncAll)
63+ syncDelay time.Duration
64+ nSyncCalls int
65+}
66+
67+type writeFault struct {
68+ keep int // bytes stored before failing; <0 means a plain failure
69+ err error
70+}
71+
72+var _ fsys.FS = (*FS)(nil)
73+
74+// New returns an empty file system.
75+func New() *FS {
76+ return &FS{
77+ files: map[string]*node{},
78+ dirs: map[string]bool{"/": true, ".": true},
79+ locks: map[string]bool{},
80+ failWrite: map[int]writeFault{},
81+ failSync: map[int]error{},
82+ }
83+}
84+
85+// FailWrite makes the n-th Write call from now (1 = the next one) fail with
86+// err (ErrInjected if nil) without storing any byte.
87+func (f *FS) FailWrite(n int, err error) {
88+ if err == nil {
89+ err = ErrInjected
90+ }
91+ f.mu.Lock()
92+ defer f.mu.Unlock()
93+ f.failWrite[f.writes+n] = writeFault{keep: -1, err: err}
94+}
95+
96+// ShortWrite makes the n-th Write call from now store only its first keep
97+// bytes and return (keep, io.ErrShortWrite). A call shorter than keep+1 bytes
98+// is unaffected.
99+func (f *FS) ShortWrite(n, keep int) {
100+ f.mu.Lock()
101+ defer f.mu.Unlock()
102+ f.failWrite[f.writes+n] = writeFault{keep: keep, err: io.ErrShortWrite}
103+}
104+
105+// FailSync makes the n-th Sync or SyncAll call from now (1 = the next one)
106+// fail with err (ErrInjected if nil). Nothing becomes durable.
107+func (f *FS) FailSync(n int, err error) {
108+ if err == nil {
109+ err = ErrInjected
110+ }
111+ f.mu.Lock()
112+ defer f.mu.Unlock()
113+ f.failSync[f.nSyncCalls+n] = err
114+}
115+
116+// SetSyncDelay makes every Sync and SyncAll block for d before taking effect.
117+func (f *FS) SetSyncDelay(d time.Duration) {
118+ f.mu.Lock()
119+ f.syncDelay = d
120+ f.mu.Unlock()
121+}
122+
123+// Writes returns the number of Write calls so far.
124+func (f *FS) Writes() int { f.mu.Lock(); defer f.mu.Unlock(); return f.writes }
125+
126+// Syncs returns the number of Sync (fdatasync) calls so far.
127+func (f *FS) Syncs() int { f.mu.Lock(); defer f.mu.Unlock(); return f.syncs }
128+
129+// SyncAlls returns the number of SyncAll (fsync) calls so far.
130+func (f *FS) SyncAlls() int { f.mu.Lock(); defer f.mu.Unlock(); return f.syncAlls }
131+
132+// PowerLossOptions tunes PowerLoss.
133+type PowerLossOptions struct {
134+ // KeepPages keeps a random subset of the unsynced pages of each file.
135+ // Dropped pages inside the kept range read as zeros.
136+ KeepPages bool
137+ // ZeroFill keeps the unsynced size extension: the file keeps its length
138+ // and every unsynced page that is not kept reads as zeros.
139+ ZeroFill bool
140+ // PageSize is the page granularity; 0 means 4096.
141+ PageSize int
142+ // Rand drives KeepPages; nil uses a fixed seed.
143+ Rand *rand.Rand
144+}
145+
146+// PowerLoss simulates a power failure: file bytes written after their last
147+// sync are dropped (see PowerLossOptions) and directory operations done after
148+// the last SyncDir of their directory are undone. Open handles become invalid
149+// and locks are released.
150+func (f *FS) PowerLoss(o PowerLossOptions) {
151+ f.mu.Lock()
152+ defer f.mu.Unlock()
153+ if o.PageSize <= 0 {
154+ o.PageSize = defaultPageSize
155+ }
156+ if o.Rand == nil {
157+ o.Rand = rand.New(rand.NewPCG(1, 2))
158+ }
159+ for i := len(f.ns) - 1; i >= 0; i-- {
160+ f.undo(f.ns[i])
161+ }
162+ f.ns = nil
163+ // Drop entries whose parent directory did not survive.
164+ for again := true; again; {
165+ again = false
166+ for d := range f.dirs {
167+ if !f.dirs[filepath.Dir(d)] {
168+ delete(f.dirs, d)
169+ again = true
170+ }
171+ }
172+ }
173+ for p := range f.files {
174+ if !f.dirs[filepath.Dir(p)] {
175+ delete(f.files, p)
176+ }
177+ }
178+ seen := map[*node]bool{}
179+ for _, n := range f.files {
180+ if !seen[n] {
181+ seen[n] = true
182+ n.crash(o)
183+ }
184+ }
185+ f.locks = map[string]bool{}
186+ f.epoch++
187+}
188+
189+func (f *FS) undo(op *nsOp) {
190+ switch op.kind {
191+ case 'c':
192+ delete(f.files, op.path)
193+ case 'm':
194+ delete(f.dirs, op.path)
195+ case 'd':
196+ f.files[op.path] = op.n
197+ case 'r':
198+ f.files[op.oldPath] = op.n
199+ if op.prev != nil {
200+ f.files[op.path] = op.prev
201+ } else {
202+ delete(f.files, op.path)
203+ }
204+ }
205+}
206+
207+func (n *node) crash(o PowerLossOptions) {
208+ defer func() {
209+ n.durable = append([]byte(nil), n.data...)
210+ n.truncated = false
211+ }()
212+ if n.truncated || len(n.data) <= len(n.durable) {
213+ n.data = append([]byte(nil), n.durable...)
214+ return
215+ }
216+ off, end := len(n.durable), len(n.data)
217+ out := append(make([]byte, 0, end), n.durable...)
218+ out = append(out, make([]byte, end-off)...) // zero-filled extension
219+ kept := off
220+ for p := off / o.PageSize; p*o.PageSize < end; p++ {
221+ lo, hi := max(p*o.PageSize, off), min((p+1)*o.PageSize, end)
222+ if o.KeepPages && o.Rand.IntN(2) == 0 {
223+ copy(out[lo:hi], n.data[lo:hi])
224+ kept = hi
225+ }
226+ }
227+ if o.ZeroFill {
228+ kept = end
229+ }
230+ n.data = out[:kept]
231+}
232+
233+// WriteFile creates or replaces a durable file (and its parent directories)
234+// without going through fault injection. It is meant for test setup.
235+func (f *FS) WriteFile(name string, data []byte) {
236+ name = filepath.Clean(name)
237+ f.mu.Lock()
238+ defer f.mu.Unlock()
239+ f.mkdirAll(filepath.Dir(name), true)
240+ f.files[name] = &node{
241+ data: append([]byte(nil), data...),
242+ durable: append([]byte(nil), data...),
243+ }
244+}
245+
246+// ReadFile returns a copy of the current content of name.
247+func (f *FS) ReadFile(name string) ([]byte, bool) {
248+ f.mu.Lock()
249+ defer f.mu.Unlock()
250+ n, ok := f.files[filepath.Clean(name)]
251+ if !ok {
252+ return nil, false
253+ }
254+ return append([]byte(nil), n.data...), true
255+}
256+
257+// DurableFile returns a copy of the content of name that would survive a
258+// power loss right now, ignoring directory entries.
259+func (f *FS) DurableFile(name string) ([]byte, bool) {
260+ f.mu.Lock()
261+ defer f.mu.Unlock()
262+ n, ok := f.files[filepath.Clean(name)]
263+ if !ok {
264+ return nil, false
265+ }
266+ return append([]byte(nil), n.durable...), true
267+}
268+
269+func (f *FS) journal(op *nsOp, dirs ...string) {
270+ op.dirs = map[string]bool{}
271+ for _, d := range dirs {
272+ op.dirs[d] = true
273+ }
274+ f.ns = append(f.ns, op)
275+}
276+
277+// mkdirAll creates dir and parents; with durable set nothing is journaled.
278+func (f *FS) mkdirAll(dir string, durable bool) {
279+ for d := dir; !f.dirs[d]; d = filepath.Dir(d) {
280+ f.dirs[d] = true
281+ if !durable {
282+ f.journal(&nsOp{kind: 'm', path: d}, filepath.Dir(d))
283+ }
284+ }
285+}
286+
287+// OpenFile implements fsys.FS. Supported flags: O_RDONLY, O_WRONLY, O_RDWR,
288+// O_CREATE, O_EXCL, O_TRUNC. Writing handles append.
289+func (f *FS) OpenFile(name string, flag int, perm os.FileMode) (fsys.File, error) {
290+ name = filepath.Clean(name)
291+ f.mu.Lock()
292+ defer f.mu.Unlock()
293+ n, ok := f.files[name]
294+ switch {
295+ case f.dirs[name]:
296+ return nil, &fs.PathError{Op: "open", Path: name, Err: errors.New("is a directory")}
297+ case ok && flag&os.O_CREATE != 0 && flag&os.O_EXCL != 0:
298+ return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrExist}
299+ case !ok && flag&os.O_CREATE == 0:
300+ return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
301+ case !ok:
302+ if !f.dirs[filepath.Dir(name)] {
303+ return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
304+ }
305+ n = &node{}
306+ f.files[name] = n
307+ f.journal(&nsOp{kind: 'c', path: name, n: n}, filepath.Dir(name))
308+ }
309+ acc := flag & (os.O_WRONLY | os.O_RDWR)
310+ if flag&os.O_TRUNC != 0 && acc != 0 {
311+ f.truncate(n, 0)
312+ }
313+ return &file{fs: f, n: n, epoch: f.epoch, write: acc != 0, read: acc != os.O_WRONLY}, nil
314+}
315+
316+// Rename implements fsys.FS.
317+func (f *FS) Rename(oldpath, newpath string) error {
318+ oldpath, newpath = filepath.Clean(oldpath), filepath.Clean(newpath)
319+ f.mu.Lock()
320+ defer f.mu.Unlock()
321+ n, ok := f.files[oldpath]
322+ if !ok {
323+ return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: fs.ErrNotExist}
324+ }
325+ if !f.dirs[filepath.Dir(newpath)] {
326+ return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: fs.ErrNotExist}
327+ }
328+ if oldpath == newpath {
329+ return nil
330+ }
331+ prev := f.files[newpath]
332+ delete(f.files, oldpath)
333+ f.files[newpath] = n
334+ f.journal(&nsOp{kind: 'r', path: newpath, oldPath: oldpath, n: n, prev: prev},
335+ filepath.Dir(oldpath), filepath.Dir(newpath))
336+ return nil
337+}
338+
339+// Remove implements fsys.FS. It only removes files.
340+func (f *FS) Remove(name string) error {
341+ name = filepath.Clean(name)
342+ f.mu.Lock()
343+ defer f.mu.Unlock()
344+ n, ok := f.files[name]
345+ if !ok {
346+ return &fs.PathError{Op: "remove", Path: name, Err: fs.ErrNotExist}
347+ }
348+ delete(f.files, name)
349+ f.journal(&nsOp{kind: 'd', path: name, n: n}, filepath.Dir(name))
350+ return nil
351+}
352+
353+// ReadDir implements fsys.FS.
354+func (f *FS) ReadDir(dir string) ([]string, error) {
355+ dir = filepath.Clean(dir)
356+ f.mu.Lock()
357+ defer f.mu.Unlock()
358+ if !f.dirs[dir] {
359+ return nil, &fs.PathError{Op: "readdir", Path: dir, Err: fs.ErrNotExist}
360+ }
361+ var names []string
362+ for p := range f.files {
363+ if filepath.Dir(p) == dir {
364+ names = append(names, filepath.Base(p))
365+ }
366+ }
367+ for p := range f.dirs {
368+ if p != dir && filepath.Dir(p) == dir {
369+ names = append(names, filepath.Base(p))
370+ }
371+ }
372+ sort.Strings(names)
373+ return names, nil
374+}
375+
376+// MkdirAll implements fsys.FS.
377+func (f *FS) MkdirAll(dir string, _ os.FileMode) error {
378+ dir = filepath.Clean(dir)
379+ f.mu.Lock()
380+ defer f.mu.Unlock()
381+ if _, ok := f.files[dir]; ok {
382+ return &fs.PathError{Op: "mkdir", Path: dir, Err: errors.New("not a directory")}
383+ }
384+ f.mkdirAll(dir, false)
385+ return nil
386+}
387+
388+// SyncDir implements fsys.FS.
389+func (f *FS) SyncDir(dir string) error {
390+ dir = filepath.Clean(dir)
391+ f.mu.Lock()
392+ defer f.mu.Unlock()
393+ if !f.dirs[dir] {
394+ return &fs.PathError{Op: "sync", Path: dir, Err: fs.ErrNotExist}
395+ }
396+ keep := f.ns[:0]
397+ for _, op := range f.ns {
398+ delete(op.dirs, dir)
399+ if len(op.dirs) > 0 {
400+ keep = append(keep, op)
401+ }
402+ }
403+ clear(f.ns[len(keep):])
404+ f.ns = keep
405+ return nil
406+}
407+
408+// Lock implements fsys.FS. The lock is exclusive per path within this FS.
409+func (f *FS) Lock(path string) (io.Closer, error) {
410+ path = filepath.Clean(path)
411+ f.mu.Lock()
412+ defer f.mu.Unlock()
413+ if f.locks[path] {
414+ return nil, fsys.ErrLocked
415+ }
416+ if _, ok := f.files[path]; !ok {
417+ if !f.dirs[filepath.Dir(path)] {
418+ return nil, &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist}
419+ }
420+ n := &node{}
421+ f.files[path] = n
422+ f.journal(&nsOp{kind: 'c', path: path, n: n}, filepath.Dir(path))
423+ }
424+ f.locks[path] = true
425+ return &lock{fs: f, path: path, epoch: f.epoch}, nil
426+}
427+
428+type lock struct {
429+ fs *FS
430+ path string
431+ epoch uint64
432+ once sync.Once
433+}
434+
435+func (l *lock) Close() error {
436+ l.once.Do(func() {
437+ l.fs.mu.Lock()
438+ defer l.fs.mu.Unlock()
439+ if l.fs.epoch == l.epoch {
440+ delete(l.fs.locks, l.path)
441+ }
442+ })
443+ return nil
444+}
445+
446+func (f *FS) truncate(n *node, size int64) {
447+ if size < int64(len(n.data)) {
448+ n.data = n.data[:size]
449+ n.truncated = true
450+ } else {
451+ n.data = append(n.data, make([]byte, size-int64(len(n.data)))...)
452+ }
453+}
454+
455+type file struct {
456+ fs *FS
457+ n *node
458+ epoch uint64
459+ read, write bool
460+ closed bool
461+}
462+
463+// check must be called with fs.mu held.
464+func (h *file) check() error {
465+ switch {
466+ case h.closed:
467+ return os.ErrClosed
468+ case h.epoch != h.fs.epoch:
469+ return ErrPowerLoss
470+ }
471+ return nil
472+}
473+
474+func (h *file) Write(p []byte) (int, error) {
475+ f := h.fs
476+ f.mu.Lock()
477+ defer f.mu.Unlock()
478+ if err := h.check(); err != nil {
479+ return 0, err
480+ }
481+ if !h.write {
482+ return 0, &fs.PathError{Op: "write", Err: fs.ErrPermission}
483+ }
484+ f.writes++
485+ if wf, ok := f.failWrite[f.writes]; ok {
486+ delete(f.failWrite, f.writes)
487+ switch {
488+ case wf.keep < 0:
489+ return 0, wf.err
490+ case wf.keep < len(p):
491+ h.n.data = append(h.n.data, p[:wf.keep]...)
492+ return wf.keep, wf.err
493+ }
494+ }
495+ h.n.data = append(h.n.data, p...)
496+ return len(p), nil
497+}
498+
499+func (h *file) ReadAt(p []byte, off int64) (int, error) {
500+ h.fs.mu.Lock()
501+ defer h.fs.mu.Unlock()
502+ if err := h.check(); err != nil {
503+ return 0, err
504+ }
505+ if !h.read {
506+ return 0, &fs.PathError{Op: "read", Err: fs.ErrPermission}
507+ }
508+ if off < 0 {
509+ return 0, errors.New("faultfs: negative offset")
510+ }
511+ if off >= int64(len(h.n.data)) {
512+ return 0, io.EOF
513+ }
514+ n := copy(p, h.n.data[off:])
515+ if n < len(p) {
516+ return n, io.EOF
517+ }
518+ return n, nil
519+}
520+
521+func (h *file) Sync() error { return h.sync(false) }
522+func (h *file) SyncAll() error { return h.sync(true) }
523+
524+func (h *file) sync(all bool) error {
525+ f := h.fs
526+ f.mu.Lock()
527+ if err := h.check(); err != nil {
528+ f.mu.Unlock()
529+ return err
530+ }
531+ f.nSyncCalls++
532+ if all {
533+ f.syncAlls++
534+ } else {
535+ f.syncs++
536+ }
537+ if err, ok := f.failSync[f.nSyncCalls]; ok {
538+ delete(f.failSync, f.nSyncCalls)
539+ f.mu.Unlock()
540+ return err
541+ }
542+ d := f.syncDelay
543+ f.mu.Unlock()
544+ if d > 0 {
545+ time.Sleep(d)
546+ }
547+ f.mu.Lock()
548+ defer f.mu.Unlock()
549+ if err := h.check(); err != nil {
550+ return err
551+ }
552+ n := h.n
553+ if !n.truncated && len(n.data) >= len(n.durable) {
554+ n.durable = append(n.durable, n.data[len(n.durable):]...)
555+ } else {
556+ n.durable = append(n.durable[:0], n.data...)
557+ }
558+ n.truncated = false
559+ return nil
560+}
561+
562+func (h *file) Truncate(size int64) error {
563+ h.fs.mu.Lock()
564+ defer h.fs.mu.Unlock()
565+ if err := h.check(); err != nil {
566+ return err
567+ }
568+ if !h.write || size < 0 {
569+ return &fs.PathError{Op: "truncate", Err: fs.ErrInvalid}
570+ }
571+ h.fs.truncate(h.n, size)
572+ return nil
573+}
574+
575+func (h *file) Size() (int64, error) {
576+ h.fs.mu.Lock()
577+ defer h.fs.mu.Unlock()
578+ if err := h.check(); err != nil {
579+ return 0, err
580+ }
581+ return int64(len(h.n.data)), nil
582+}
583+
584+func (h *file) Close() error {
585+ h.fs.mu.Lock()
586+ defer h.fs.mu.Unlock()
587+ if h.closed {
588+ return os.ErrClosed
589+ }
590+ h.closed = true
591+ return nil
592+}
@@ -0,0 +1,592 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package faultfs is an in-memory fsys.FS for tests. It models a page cache in
4+// front of durable storage and can inject write and sync failures, delay
5+// syncs, count syncs and simulate a power loss.
6+//
7+// Durability model: file bytes become durable on Sync or SyncAll; directory
8+// operations (create, rename, remove, mkdir) become durable on SyncDir of the
9+// directory that contains them. PowerLoss discards everything that is not.
10+//
11+// All methods are safe for concurrent use.
12+package faultfs
13+
14+import (
15+ "errors"
16+ "io"
17+ "io/fs"
18+ "math/rand/v2"
19+ "os"
20+ "path/filepath"
21+ "sort"
22+ "sync"
23+ "time"
24+
25+ "bnema.dev/ermite-kv/internal/fsys"
26+)
27+
28+// ErrPowerLoss is returned by handles that were open when PowerLoss was called.
29+var ErrPowerLoss = errors.New("faultfs: handle invalidated by power loss")
30+
31+// ErrInjected is the default error of injected failures.
32+var ErrInjected = errors.New("faultfs: injected failure")
33+
34+const defaultPageSize = 4096
35+
36+type node struct {
37+ data []byte // current content (page cache)
38+ durable []byte // content as of the last successful sync
39+ truncated bool // data was truncated since the last sync
40+}
41+
42+type nsOp struct {
43+ kind byte // 'c' create, 'r' rename, 'd' remove, 'm' mkdir
44+ path string
45+ oldPath string // rename source
46+ n *node // created, removed or renamed node
47+ prev *node // node replaced by a rename
48+ dirs map[string]bool
49+}
50+
51+// FS is an in-memory file system with fault injection.
52+type FS struct {
53+ mu sync.Mutex
54+ files map[string]*node
55+ dirs map[string]bool
56+ locks map[string]bool
57+ ns []*nsOp // directory operations not yet made durable
58+ epoch uint64 // incremented by PowerLoss
59+
60+ writes, syncs, syncAlls int
61+ failWrite map[int]writeFault // by absolute write number
62+ failSync map[int]error // by absolute sync number (Sync+SyncAll)
63+ syncDelay time.Duration
64+ nSyncCalls int
65+}
66+
67+type writeFault struct {
68+ keep int // bytes stored before failing; <0 means a plain failure
69+ err error
70+}
71+
72+var _ fsys.FS = (*FS)(nil)
73+
74+// New returns an empty file system.
75+func New() *FS {
76+ return &FS{
77+ files: map[string]*node{},
78+ dirs: map[string]bool{"/": true, ".": true},
79+ locks: map[string]bool{},
80+ failWrite: map[int]writeFault{},
81+ failSync: map[int]error{},
82+ }
83+}
84+
85+// FailWrite makes the n-th Write call from now (1 = the next one) fail with
86+// err (ErrInjected if nil) without storing any byte.
87+func (f *FS) FailWrite(n int, err error) {
88+ if err == nil {
89+ err = ErrInjected
90+ }
91+ f.mu.Lock()
92+ defer f.mu.Unlock()
93+ f.failWrite[f.writes+n] = writeFault{keep: -1, err: err}
94+}
95+
96+// ShortWrite makes the n-th Write call from now store only its first keep
97+// bytes and return (keep, io.ErrShortWrite). A call shorter than keep+1 bytes
98+// is unaffected.
99+func (f *FS) ShortWrite(n, keep int) {
100+ f.mu.Lock()
101+ defer f.mu.Unlock()
102+ f.failWrite[f.writes+n] = writeFault{keep: keep, err: io.ErrShortWrite}
103+}
104+
105+// FailSync makes the n-th Sync or SyncAll call from now (1 = the next one)
106+// fail with err (ErrInjected if nil). Nothing becomes durable.
107+func (f *FS) FailSync(n int, err error) {
108+ if err == nil {
109+ err = ErrInjected
110+ }
111+ f.mu.Lock()
112+ defer f.mu.Unlock()
113+ f.failSync[f.nSyncCalls+n] = err
114+}
115+
116+// SetSyncDelay makes every Sync and SyncAll block for d before taking effect.
117+func (f *FS) SetSyncDelay(d time.Duration) {
118+ f.mu.Lock()
119+ f.syncDelay = d
120+ f.mu.Unlock()
121+}
122+
123+// Writes returns the number of Write calls so far.
124+func (f *FS) Writes() int { f.mu.Lock(); defer f.mu.Unlock(); return f.writes }
125+
126+// Syncs returns the number of Sync (fdatasync) calls so far.
127+func (f *FS) Syncs() int { f.mu.Lock(); defer f.mu.Unlock(); return f.syncs }
128+
129+// SyncAlls returns the number of SyncAll (fsync) calls so far.
130+func (f *FS) SyncAlls() int { f.mu.Lock(); defer f.mu.Unlock(); return f.syncAlls }
131+
132+// PowerLossOptions tunes PowerLoss.
133+type PowerLossOptions struct {
134+ // KeepPages keeps a random subset of the unsynced pages of each file.
135+ // Dropped pages inside the kept range read as zeros.
136+ KeepPages bool
137+ // ZeroFill keeps the unsynced size extension: the file keeps its length
138+ // and every unsynced page that is not kept reads as zeros.
139+ ZeroFill bool
140+ // PageSize is the page granularity; 0 means 4096.
141+ PageSize int
142+ // Rand drives KeepPages; nil uses a fixed seed.
143+ Rand *rand.Rand
144+}
145+
146+// PowerLoss simulates a power failure: file bytes written after their last
147+// sync are dropped (see PowerLossOptions) and directory operations done after
148+// the last SyncDir of their directory are undone. Open handles become invalid
149+// and locks are released.
150+func (f *FS) PowerLoss(o PowerLossOptions) {
151+ f.mu.Lock()
152+ defer f.mu.Unlock()
153+ if o.PageSize <= 0 {
154+ o.PageSize = defaultPageSize
155+ }
156+ if o.Rand == nil {
157+ o.Rand = rand.New(rand.NewPCG(1, 2))
158+ }
159+ for i := len(f.ns) - 1; i >= 0; i-- {
160+ f.undo(f.ns[i])
161+ }
162+ f.ns = nil
163+ // Drop entries whose parent directory did not survive.
164+ for again := true; again; {
165+ again = false
166+ for d := range f.dirs {
167+ if !f.dirs[filepath.Dir(d)] {
168+ delete(f.dirs, d)
169+ again = true
170+ }
171+ }
172+ }
173+ for p := range f.files {
174+ if !f.dirs[filepath.Dir(p)] {
175+ delete(f.files, p)
176+ }
177+ }
178+ seen := map[*node]bool{}
179+ for _, n := range f.files {
180+ if !seen[n] {
181+ seen[n] = true
182+ n.crash(o)
183+ }
184+ }
185+ f.locks = map[string]bool{}
186+ f.epoch++
187+}
188+
189+func (f *FS) undo(op *nsOp) {
190+ switch op.kind {
191+ case 'c':
192+ delete(f.files, op.path)
193+ case 'm':
194+ delete(f.dirs, op.path)
195+ case 'd':
196+ f.files[op.path] = op.n
197+ case 'r':
198+ f.files[op.oldPath] = op.n
199+ if op.prev != nil {
200+ f.files[op.path] = op.prev
201+ } else {
202+ delete(f.files, op.path)
203+ }
204+ }
205+}
206+
207+func (n *node) crash(o PowerLossOptions) {
208+ defer func() {
209+ n.durable = append([]byte(nil), n.data...)
210+ n.truncated = false
211+ }()
212+ if n.truncated || len(n.data) <= len(n.durable) {
213+ n.data = append([]byte(nil), n.durable...)
214+ return
215+ }
216+ off, end := len(n.durable), len(n.data)
217+ out := append(make([]byte, 0, end), n.durable...)
218+ out = append(out, make([]byte, end-off)...) // zero-filled extension
219+ kept := off
220+ for p := off / o.PageSize; p*o.PageSize < end; p++ {
221+ lo, hi := max(p*o.PageSize, off), min((p+1)*o.PageSize, end)
222+ if o.KeepPages && o.Rand.IntN(2) == 0 {
223+ copy(out[lo:hi], n.data[lo:hi])
224+ kept = hi
225+ }
226+ }
227+ if o.ZeroFill {
228+ kept = end
229+ }
230+ n.data = out[:kept]
231+}
232+
233+// WriteFile creates or replaces a durable file (and its parent directories)
234+// without going through fault injection. It is meant for test setup.
235+func (f *FS) WriteFile(name string, data []byte) {
236+ name = filepath.Clean(name)
237+ f.mu.Lock()
238+ defer f.mu.Unlock()
239+ f.mkdirAll(filepath.Dir(name), true)
240+ f.files[name] = &node{
241+ data: append([]byte(nil), data...),
242+ durable: append([]byte(nil), data...),
243+ }
244+}
245+
246+// ReadFile returns a copy of the current content of name.
247+func (f *FS) ReadFile(name string) ([]byte, bool) {
248+ f.mu.Lock()
249+ defer f.mu.Unlock()
250+ n, ok := f.files[filepath.Clean(name)]
251+ if !ok {
252+ return nil, false
253+ }
254+ return append([]byte(nil), n.data...), true
255+}
256+
257+// DurableFile returns a copy of the content of name that would survive a
258+// power loss right now, ignoring directory entries.
259+func (f *FS) DurableFile(name string) ([]byte, bool) {
260+ f.mu.Lock()
261+ defer f.mu.Unlock()
262+ n, ok := f.files[filepath.Clean(name)]
263+ if !ok {
264+ return nil, false
265+ }
266+ return append([]byte(nil), n.durable...), true
267+}
268+
269+func (f *FS) journal(op *nsOp, dirs ...string) {
270+ op.dirs = map[string]bool{}
271+ for _, d := range dirs {
272+ op.dirs[d] = true
273+ }
274+ f.ns = append(f.ns, op)
275+}
276+
277+// mkdirAll creates dir and parents; with durable set nothing is journaled.
278+func (f *FS) mkdirAll(dir string, durable bool) {
279+ for d := dir; !f.dirs[d]; d = filepath.Dir(d) {
280+ f.dirs[d] = true
281+ if !durable {
282+ f.journal(&nsOp{kind: 'm', path: d}, filepath.Dir(d))
283+ }
284+ }
285+}
286+
287+// OpenFile implements fsys.FS. Supported flags: O_RDONLY, O_WRONLY, O_RDWR,
288+// O_CREATE, O_EXCL, O_TRUNC. Writing handles append.
289+func (f *FS) OpenFile(name string, flag int, perm os.FileMode) (fsys.File, error) {
290+ name = filepath.Clean(name)
291+ f.mu.Lock()
292+ defer f.mu.Unlock()
293+ n, ok := f.files[name]
294+ switch {
295+ case f.dirs[name]:
296+ return nil, &fs.PathError{Op: "open", Path: name, Err: errors.New("is a directory")}
297+ case ok && flag&os.O_CREATE != 0 && flag&os.O_EXCL != 0:
298+ return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrExist}
299+ case !ok && flag&os.O_CREATE == 0:
300+ return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
301+ case !ok:
302+ if !f.dirs[filepath.Dir(name)] {
303+ return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
304+ }
305+ n = &node{}
306+ f.files[name] = n
307+ f.journal(&nsOp{kind: 'c', path: name, n: n}, filepath.Dir(name))
308+ }
309+ acc := flag & (os.O_WRONLY | os.O_RDWR)
310+ if flag&os.O_TRUNC != 0 && acc != 0 {
311+ f.truncate(n, 0)
312+ }
313+ return &file{fs: f, n: n, epoch: f.epoch, write: acc != 0, read: acc != os.O_WRONLY}, nil
314+}
315+
316+// Rename implements fsys.FS.
317+func (f *FS) Rename(oldpath, newpath string) error {
318+ oldpath, newpath = filepath.Clean(oldpath), filepath.Clean(newpath)
319+ f.mu.Lock()
320+ defer f.mu.Unlock()
321+ n, ok := f.files[oldpath]
322+ if !ok {
323+ return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: fs.ErrNotExist}
324+ }
325+ if !f.dirs[filepath.Dir(newpath)] {
326+ return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: fs.ErrNotExist}
327+ }
328+ if oldpath == newpath {
329+ return nil
330+ }
331+ prev := f.files[newpath]
332+ delete(f.files, oldpath)
333+ f.files[newpath] = n
334+ f.journal(&nsOp{kind: 'r', path: newpath, oldPath: oldpath, n: n, prev: prev},
335+ filepath.Dir(oldpath), filepath.Dir(newpath))
336+ return nil
337+}
338+
339+// Remove implements fsys.FS. It only removes files.
340+func (f *FS) Remove(name string) error {
341+ name = filepath.Clean(name)
342+ f.mu.Lock()
343+ defer f.mu.Unlock()
344+ n, ok := f.files[name]
345+ if !ok {
346+ return &fs.PathError{Op: "remove", Path: name, Err: fs.ErrNotExist}
347+ }
348+ delete(f.files, name)
349+ f.journal(&nsOp{kind: 'd', path: name, n: n}, filepath.Dir(name))
350+ return nil
351+}
352+
353+// ReadDir implements fsys.FS.
354+func (f *FS) ReadDir(dir string) ([]string, error) {
355+ dir = filepath.Clean(dir)
356+ f.mu.Lock()
357+ defer f.mu.Unlock()
358+ if !f.dirs[dir] {
359+ return nil, &fs.PathError{Op: "readdir", Path: dir, Err: fs.ErrNotExist}
360+ }
361+ var names []string
362+ for p := range f.files {
363+ if filepath.Dir(p) == dir {
364+ names = append(names, filepath.Base(p))
365+ }
366+ }
367+ for p := range f.dirs {
368+ if p != dir && filepath.Dir(p) == dir {
369+ names = append(names, filepath.Base(p))
370+ }
371+ }
372+ sort.Strings(names)
373+ return names, nil
374+}
375+
376+// MkdirAll implements fsys.FS.
377+func (f *FS) MkdirAll(dir string, _ os.FileMode) error {
378+ dir = filepath.Clean(dir)
379+ f.mu.Lock()
380+ defer f.mu.Unlock()
381+ if _, ok := f.files[dir]; ok {
382+ return &fs.PathError{Op: "mkdir", Path: dir, Err: errors.New("not a directory")}
383+ }
384+ f.mkdirAll(dir, false)
385+ return nil
386+}
387+
388+// SyncDir implements fsys.FS.
389+func (f *FS) SyncDir(dir string) error {
390+ dir = filepath.Clean(dir)
391+ f.mu.Lock()
392+ defer f.mu.Unlock()
393+ if !f.dirs[dir] {
394+ return &fs.PathError{Op: "sync", Path: dir, Err: fs.ErrNotExist}
395+ }
396+ keep := f.ns[:0]
397+ for _, op := range f.ns {
398+ delete(op.dirs, dir)
399+ if len(op.dirs) > 0 {
400+ keep = append(keep, op)
401+ }
402+ }
403+ clear(f.ns[len(keep):])
404+ f.ns = keep
405+ return nil
406+}
407+
408+// Lock implements fsys.FS. The lock is exclusive per path within this FS.
409+func (f *FS) Lock(path string) (io.Closer, error) {
410+ path = filepath.Clean(path)
411+ f.mu.Lock()
412+ defer f.mu.Unlock()
413+ if f.locks[path] {
414+ return nil, fsys.ErrLocked
415+ }
416+ if _, ok := f.files[path]; !ok {
417+ if !f.dirs[filepath.Dir(path)] {
418+ return nil, &fs.PathError{Op: "open", Path: path, Err: fs.ErrNotExist}
419+ }
420+ n := &node{}
421+ f.files[path] = n
422+ f.journal(&nsOp{kind: 'c', path: path, n: n}, filepath.Dir(path))
423+ }
424+ f.locks[path] = true
425+ return &lock{fs: f, path: path, epoch: f.epoch}, nil
426+}
427+
428+type lock struct {
429+ fs *FS
430+ path string
431+ epoch uint64
432+ once sync.Once
433+}
434+
435+func (l *lock) Close() error {
436+ l.once.Do(func() {
437+ l.fs.mu.Lock()
438+ defer l.fs.mu.Unlock()
439+ if l.fs.epoch == l.epoch {
440+ delete(l.fs.locks, l.path)
441+ }
442+ })
443+ return nil
444+}
445+
446+func (f *FS) truncate(n *node, size int64) {
447+ if size < int64(len(n.data)) {
448+ n.data = n.data[:size]
449+ n.truncated = true
450+ } else {
451+ n.data = append(n.data, make([]byte, size-int64(len(n.data)))...)
452+ }
453+}
454+
455+type file struct {
456+ fs *FS
457+ n *node
458+ epoch uint64
459+ read, write bool
460+ closed bool
461+}
462+
463+// check must be called with fs.mu held.
464+func (h *file) check() error {
465+ switch {
466+ case h.closed:
467+ return os.ErrClosed
468+ case h.epoch != h.fs.epoch:
469+ return ErrPowerLoss
470+ }
471+ return nil
472+}
473+
474+func (h *file) Write(p []byte) (int, error) {
475+ f := h.fs
476+ f.mu.Lock()
477+ defer f.mu.Unlock()
478+ if err := h.check(); err != nil {
479+ return 0, err
480+ }
481+ if !h.write {
482+ return 0, &fs.PathError{Op: "write", Err: fs.ErrPermission}
483+ }
484+ f.writes++
485+ if wf, ok := f.failWrite[f.writes]; ok {
486+ delete(f.failWrite, f.writes)
487+ switch {
488+ case wf.keep < 0:
489+ return 0, wf.err
490+ case wf.keep < len(p):
491+ h.n.data = append(h.n.data, p[:wf.keep]...)
492+ return wf.keep, wf.err
493+ }
494+ }
495+ h.n.data = append(h.n.data, p...)
496+ return len(p), nil
497+}
498+
499+func (h *file) ReadAt(p []byte, off int64) (int, error) {
500+ h.fs.mu.Lock()
501+ defer h.fs.mu.Unlock()
502+ if err := h.check(); err != nil {
503+ return 0, err
504+ }
505+ if !h.read {
506+ return 0, &fs.PathError{Op: "read", Err: fs.ErrPermission}
507+ }
508+ if off < 0 {
509+ return 0, errors.New("faultfs: negative offset")
510+ }
511+ if off >= int64(len(h.n.data)) {
512+ return 0, io.EOF
513+ }
514+ n := copy(p, h.n.data[off:])
515+ if n < len(p) {
516+ return n, io.EOF
517+ }
518+ return n, nil
519+}
520+
521+func (h *file) Sync() error { return h.sync(false) }
522+func (h *file) SyncAll() error { return h.sync(true) }
523+
524+func (h *file) sync(all bool) error {
525+ f := h.fs
526+ f.mu.Lock()
527+ if err := h.check(); err != nil {
528+ f.mu.Unlock()
529+ return err
530+ }
531+ f.nSyncCalls++
532+ if all {
533+ f.syncAlls++
534+ } else {
535+ f.syncs++
536+ }
537+ if err, ok := f.failSync[f.nSyncCalls]; ok {
538+ delete(f.failSync, f.nSyncCalls)
539+ f.mu.Unlock()
540+ return err
541+ }
542+ d := f.syncDelay
543+ f.mu.Unlock()
544+ if d > 0 {
545+ time.Sleep(d)
546+ }
547+ f.mu.Lock()
548+ defer f.mu.Unlock()
549+ if err := h.check(); err != nil {
550+ return err
551+ }
552+ n := h.n
553+ if !n.truncated && len(n.data) >= len(n.durable) {
554+ n.durable = append(n.durable, n.data[len(n.durable):]...)
555+ } else {
556+ n.durable = append(n.durable[:0], n.data...)
557+ }
558+ n.truncated = false
559+ return nil
560+}
561+
562+func (h *file) Truncate(size int64) error {
563+ h.fs.mu.Lock()
564+ defer h.fs.mu.Unlock()
565+ if err := h.check(); err != nil {
566+ return err
567+ }
568+ if !h.write || size < 0 {
569+ return &fs.PathError{Op: "truncate", Err: fs.ErrInvalid}
570+ }
571+ h.fs.truncate(h.n, size)
572+ return nil
573+}
574+
575+func (h *file) Size() (int64, error) {
576+ h.fs.mu.Lock()
577+ defer h.fs.mu.Unlock()
578+ if err := h.check(); err != nil {
579+ return 0, err
580+ }
581+ return int64(len(h.n.data)), nil
582+}
583+
584+func (h *file) Close() error {
585+ h.fs.mu.Lock()
586+ defer h.fs.mu.Unlock()
587+ if h.closed {
588+ return os.ErrClosed
589+ }
590+ h.closed = true
591+ return nil
592+}
A internal/fsys/faultfs/faultfs_test.go +384 -0
@@ -0,0 +1,384 @@
1+// SPDX-License-Identifier: MIT
2+
3+package faultfs
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "io"
9+ "math/rand/v2"
10+ "os"
11+ "sync"
12+ "testing"
13+ "time"
14+
15+ "bnema.dev/ermite-kv/internal/fsys"
16+)
17+
18+func create(t *testing.T, f *FS, name string) fsys.File {
19+ t.Helper()
20+ h, err := f.OpenFile(name, os.O_RDWR|os.O_CREATE, 0o600)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ return h
25+}
26+
27+func write(t *testing.T, h fsys.File, s string) {
28+ t.Helper()
29+ if _, err := h.Write([]byte(s)); err != nil {
30+ t.Fatal(err)
31+ }
32+}
33+
34+func mustRead(t *testing.T, f *FS, name string) string {
35+ t.Helper()
36+ b, ok := f.ReadFile(name)
37+ if !ok {
38+ t.Fatalf("%s missing", name)
39+ }
40+ return string(b)
41+}
42+
43+func TestBasicIO(t *testing.T) {
44+ f := New()
45+ if err := f.MkdirAll("/d/sub", 0o700); err != nil {
46+ t.Fatal(err)
47+ }
48+ h := create(t, f, "/d/a")
49+ write(t, h, "hello ")
50+ write(t, h, "world")
51+ buf := make([]byte, 5)
52+ if n, err := h.ReadAt(buf, 6); n != 5 || err != nil || string(buf) != "world" {
53+ t.Fatalf("ReadAt = %d %v %q", n, err, buf)
54+ }
55+ if n, err := h.ReadAt(make([]byte, 4), 9); n != 2 || err != io.EOF {
56+ t.Fatalf("short ReadAt = %d %v", n, err)
57+ }
58+ if n, err := h.ReadAt(buf, 100); n != 0 || err != io.EOF {
59+ t.Fatalf("ReadAt past end = %d %v", n, err)
60+ }
61+ if err := h.Truncate(5); err != nil {
62+ t.Fatal(err)
63+ }
64+ if sz, _ := h.Size(); sz != 5 {
65+ t.Fatalf("size %d", sz)
66+ }
67+ write(t, h, "!")
68+ if got := mustRead(t, f, "/d/a"); got != "hello!" {
69+ t.Fatalf("content %q", got)
70+ }
71+ if err := h.Close(); err != nil {
72+ t.Fatal(err)
73+ }
74+ if _, err := h.Write([]byte("x")); err == nil {
75+ t.Fatal("write on closed file succeeded")
76+ }
77+ names, _ := f.ReadDir("/d")
78+ if len(names) != 2 || names[0] != "a" || names[1] != "sub" {
79+ t.Fatalf("ReadDir = %v", names)
80+ }
81+ if _, err := f.OpenFile("/d/missing", os.O_RDONLY, 0); !errors.Is(err, os.ErrNotExist) {
82+ t.Fatalf("open missing: %v", err)
83+ }
84+ if _, err := f.OpenFile("/d/a", os.O_RDWR|os.O_CREATE|os.O_EXCL, 0); !errors.Is(err, os.ErrExist) {
85+ t.Fatalf("O_EXCL: %v", err)
86+ }
87+ if err := f.Rename("/d/a", "/d/b"); err != nil {
88+ t.Fatal(err)
89+ }
90+ if err := f.Remove("/d/b"); err != nil {
91+ t.Fatal(err)
92+ }
93+ if err := f.Remove("/d/b"); !errors.Is(err, os.ErrNotExist) {
94+ t.Fatalf("second remove: %v", err)
95+ }
96+}
97+
98+func TestFailWriteAndShortWrite(t *testing.T) {
99+ f := New()
100+ h := create(t, f, "/a")
101+ f.FailWrite(2, nil)
102+ write(t, h, "one")
103+ if n, err := h.Write([]byte("two")); n != 0 || !errors.Is(err, ErrInjected) {
104+ t.Fatalf("failed write = %d %v", n, err)
105+ }
106+ write(t, h, "three") // faults fire once
107+ f.ShortWrite(1, 2)
108+ if n, err := h.Write([]byte("abcdef")); n != 2 || !errors.Is(err, io.ErrShortWrite) {
109+ t.Fatalf("short write = %d %v", n, err)
110+ }
111+ if got := mustRead(t, f, "/a"); got != "onethreeab" {
112+ t.Fatalf("content %q", got)
113+ }
114+ if f.Writes() != 4 {
115+ t.Fatalf("Writes = %d", f.Writes())
116+ }
117+}
118+
119+func TestFailSyncAndCounters(t *testing.T) {
120+ f := New()
121+ h := create(t, f, "/a")
122+ write(t, h, "x")
123+ f.FailSync(2, nil)
124+ if err := h.Sync(); err != nil {
125+ t.Fatal(err)
126+ }
127+ write(t, h, "y")
128+ if err := h.SyncAll(); !errors.Is(err, ErrInjected) {
129+ t.Fatalf("SyncAll = %v", err)
130+ }
131+ if d, _ := f.DurableFile("/a"); string(d) != "x" {
132+ t.Fatalf("failed sync made data durable: %q", d)
133+ }
134+ if err := h.SyncAll(); err != nil {
135+ t.Fatal(err)
136+ }
137+ if f.Syncs() != 1 || f.SyncAlls() != 2 {
138+ t.Fatalf("Syncs=%d SyncAlls=%d", f.Syncs(), f.SyncAlls())
139+ }
140+}
141+
142+func TestSyncDelay(t *testing.T) {
143+ f := New()
144+ h := create(t, f, "/a")
145+ f.SetSyncDelay(20 * time.Millisecond)
146+ start := time.Now()
147+ if err := h.Sync(); err != nil {
148+ t.Fatal(err)
149+ }
150+ if time.Since(start) < 20*time.Millisecond {
151+ t.Fatal("sync returned before the delay")
152+ }
153+}
154+
155+func TestPowerLossDropsUnsyncedBytes(t *testing.T) {
156+ f := New()
157+ f.WriteFile("/d/a", nil)
158+ h, _ := f.OpenFile("/d/a", os.O_RDWR, 0)
159+ write(t, h, "synced")
160+ if err := h.Sync(); err != nil {
161+ t.Fatal(err)
162+ }
163+ write(t, h, "-lost")
164+ f.PowerLoss(PowerLossOptions{})
165+ if got := mustRead(t, f, "/d/a"); got != "synced" {
166+ t.Fatalf("after power loss %q", got)
167+ }
168+ if _, err := h.Write([]byte("x")); !errors.Is(err, ErrPowerLoss) {
169+ t.Fatalf("stale handle write: %v", err)
170+ }
171+ h2, _ := f.OpenFile("/d/a", os.O_RDWR, 0)
172+ write(t, h2, "+new")
173+ if sz, _ := h2.Size(); sz != 10 {
174+ t.Fatalf("size %d", sz)
175+ }
176+}
177+
178+func TestPowerLossTruncateNeedsSync(t *testing.T) {
179+ f := New()
180+ f.WriteFile("/d/a", []byte("0123456789"))
181+ h, _ := f.OpenFile("/d/a", os.O_RDWR, 0)
182+ if err := h.Truncate(4); err != nil {
183+ t.Fatal(err)
184+ }
185+ f.PowerLoss(PowerLossOptions{})
186+ if got := mustRead(t, f, "/d/a"); got != "0123456789" {
187+ t.Fatalf("unsynced truncate survived: %q", got)
188+ }
189+ h, _ = f.OpenFile("/d/a", os.O_RDWR, 0)
190+ _ = h.Truncate(4)
191+ _ = h.SyncAll()
192+ f.PowerLoss(PowerLossOptions{})
193+ if got := mustRead(t, f, "/d/a"); got != "0123" {
194+ t.Fatalf("synced truncate lost: %q", got)
195+ }
196+}
197+
198+func TestPowerLossZeroFillAndPages(t *testing.T) {
199+ data := bytes.Repeat([]byte{'x'}, 5*16)
200+ run := func(o PowerLossOptions) []byte {
201+ f := New()
202+ f.WriteFile("/d/a", []byte("head"))
203+ h, _ := f.OpenFile("/d/a", os.O_RDWR, 0)
204+ write(t, h, string(data))
205+ f.PowerLoss(o)
206+ b, _ := f.ReadFile("/d/a")
207+ return b
208+ }
209+ zf := run(PowerLossOptions{ZeroFill: true, PageSize: 16})
210+ if len(zf) != 4+len(data) || string(zf[:4]) != "head" || !bytes.Equal(zf[4:], make([]byte, len(data))) {
211+ t.Fatalf("zero fill: len=%d %q", len(zf), zf)
212+ }
213+ kept := map[int]bool{}
214+ for seed := uint64(0); seed < 40; seed++ {
215+ b := run(PowerLossOptions{KeepPages: true, PageSize: 16, Rand: rand.New(rand.NewPCG(seed, 1))})
216+ if len(b) < 4 || string(b[:4]) != "head" || len(b) > 4+len(data) {
217+ t.Fatalf("seed %d: bad result len %d", seed, len(b))
218+ }
219+ kept[len(b)] = true
220+ for i, c := range b[4:] {
221+ if c != 'x' && c != 0 {
222+ t.Fatalf("seed %d: byte %d = %d", seed, i, c)
223+ }
224+ }
225+ }
226+ if len(kept) < 3 {
227+ t.Fatalf("KeepPages gave too little variety: %v", kept)
228+ }
229+ if got := run(PowerLossOptions{}); string(got) != "head" {
230+ t.Fatalf("default power loss kept %q", got)
231+ }
232+}
233+
234+func TestPowerLossDirectoryOps(t *testing.T) {
235+ f := New()
236+ if err := f.MkdirAll("/db", 0o700); err != nil {
237+ t.Fatal(err)
238+ }
239+ if err := f.SyncDir("/db"); err != nil {
240+ t.Fatal(err)
241+ }
242+ if err := f.SyncDir("/"); err != nil {
243+ t.Fatal(err)
244+ }
245+
246+ // Create + sync data, but no SyncDir: the file vanishes.
247+ h := create(t, f, "/db/unsynced")
248+ write(t, h, "data")
249+ _ = h.SyncAll()
250+ // Create, sync data and dir: survives.
251+ h = create(t, f, "/db/kept.tmp")
252+ write(t, h, "kept")
253+ _ = h.SyncAll()
254+ if err := f.Rename("/db/kept.tmp", "/db/kept"); err != nil {
255+ t.Fatal(err)
256+ }
257+ // Remove without SyncDir: file comes back.
258+ f.WriteFile("/db/removed", []byte("old"))
259+ if err := f.Remove("/db/removed"); err != nil {
260+ t.Fatal(err)
261+ }
262+ if err := f.SyncDir("/other"); err == nil {
263+ t.Fatal("SyncDir of a missing dir succeeded")
264+ }
265+ f.PowerLoss(PowerLossOptions{})
266+ names, _ := f.ReadDir("/db")
267+ if len(names) != 1 || names[0] != "removed" {
268+ t.Fatalf("after power loss without SyncDir: %v", names)
269+ }
270+
271+ // Now with SyncDir after each step.
272+ h = create(t, f, "/db/new.tmp")
273+ write(t, h, "new")
274+ _ = h.SyncAll()
275+ _ = f.Rename("/db/new.tmp", "/db/new")
276+ _ = f.Remove("/db/removed")
277+ _ = f.SyncDir("/db")
278+ f.PowerLoss(PowerLossOptions{})
279+ names, _ = f.ReadDir("/db")
280+ if len(names) != 1 || names[0] != "new" {
281+ t.Fatalf("after power loss with SyncDir: %v", names)
282+ }
283+ if got := mustRead(t, f, "/db/new"); got != "new" {
284+ t.Fatalf("content %q", got)
285+ }
286+}
287+
288+func TestPowerLossRenameOverExisting(t *testing.T) {
289+ f := New()
290+ f.WriteFile("/db/x", []byte("old"))
291+ h := create(t, f, "/db/x.tmp")
292+ write(t, h, "new")
293+ _ = h.SyncAll()
294+ _ = f.Rename("/db/x.tmp", "/db/x")
295+ f.PowerLoss(PowerLossOptions{})
296+ if got := mustRead(t, f, "/db/x"); got != "old" {
297+ t.Fatalf("x = %q, want old", got)
298+ }
299+ if _, ok := f.ReadFile("/db/x.tmp"); ok {
300+ t.Fatal("x.tmp survived without SyncDir")
301+ }
302+}
303+
304+func TestPowerLossUncreatedDirectory(t *testing.T) {
305+ f := New()
306+ _ = f.MkdirAll("/a/b", 0o700)
307+ h := create(t, f, "/a/b/file")
308+ write(t, h, "x")
309+ _ = h.SyncAll()
310+ _ = f.SyncDir("/a/b") // the file entry is durable, its parents are not
311+ f.PowerLoss(PowerLossOptions{})
312+ if _, err := f.ReadDir("/a/b"); err == nil {
313+ t.Fatal("directory created without SyncDir survived")
314+ }
315+ if _, ok := f.ReadFile("/a/b/file"); ok {
316+ t.Fatal("file in a lost directory survived")
317+ }
318+}
319+
320+func TestLockExclusive(t *testing.T) {
321+ f := New()
322+ _ = f.MkdirAll("/db", 0o700)
323+ _ = f.SyncDir("/db")
324+ _ = f.SyncDir("/")
325+ l1, err := f.Lock("/db/LOCK")
326+ if err != nil {
327+ t.Fatal(err)
328+ }
329+ if _, err := f.Lock("/db/LOCK"); !errors.Is(err, fsys.ErrLocked) {
330+ t.Fatalf("second lock: %v", err)
331+ }
332+ other, err := f.Lock("/db/OTHER")
333+ if err != nil {
334+ t.Fatalf("lock on another path: %v", err)
335+ }
336+ _ = other.Close()
337+ _ = l1.Close()
338+ _ = l1.Close() // idempotent
339+ l2, err := f.Lock("/db/LOCK")
340+ if err != nil {
341+ t.Fatalf("lock after release: %v", err)
342+ }
343+ // A stale handle must not release a newer lock after power loss.
344+ f.PowerLoss(PowerLossOptions{})
345+ l3, err := f.Lock("/db/LOCK")
346+ if err != nil {
347+ t.Fatalf("lock after power loss: %v", err)
348+ }
349+ _ = l2.Close()
350+ if _, err := f.Lock("/db/LOCK"); !errors.Is(err, fsys.ErrLocked) {
351+ t.Fatalf("stale Close released the lock: %v", err)
352+ }
353+ _ = l3.Close()
354+}
355+
356+func TestConcurrentUse(t *testing.T) {
357+ f := New()
358+ _ = f.MkdirAll("/db", 0o700)
359+ var wg sync.WaitGroup
360+ for i := range 8 {
361+ wg.Add(1)
362+ go func() {
363+ defer wg.Done()
364+ name := "/db/f" + string(rune('a'+i))
365+ h, err := f.OpenFile(name, os.O_RDWR|os.O_CREATE, 0o600)
366+ if err != nil {
367+ t.Error(err)
368+ return
369+ }
370+ for range 100 {
371+ _, _ = h.Write([]byte("x"))
372+ _ = h.Sync()
373+ _, _ = h.Size()
374+ _, _ = f.ReadDir("/db")
375+ _ = f.SyncDir("/db")
376+ }
377+ _ = h.Close()
378+ }()
379+ }
380+ wg.Wait()
381+ if f.Syncs() != 800 || f.Writes() != 800 {
382+ t.Fatalf("Syncs=%d Writes=%d", f.Syncs(), f.Writes())
383+ }
384+}
@@ -0,0 +1,384 @@
1+// SPDX-License-Identifier: MIT
2+
3+package faultfs
4+
5+import (
6+ "bytes"
7+ "errors"
8+ "io"
9+ "math/rand/v2"
10+ "os"
11+ "sync"
12+ "testing"
13+ "time"
14+
15+ "bnema.dev/ermite-kv/internal/fsys"
16+)
17+
18+func create(t *testing.T, f *FS, name string) fsys.File {
19+ t.Helper()
20+ h, err := f.OpenFile(name, os.O_RDWR|os.O_CREATE, 0o600)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ return h
25+}
26+
27+func write(t *testing.T, h fsys.File, s string) {
28+ t.Helper()
29+ if _, err := h.Write([]byte(s)); err != nil {
30+ t.Fatal(err)
31+ }
32+}
33+
34+func mustRead(t *testing.T, f *FS, name string) string {
35+ t.Helper()
36+ b, ok := f.ReadFile(name)
37+ if !ok {
38+ t.Fatalf("%s missing", name)
39+ }
40+ return string(b)
41+}
42+
43+func TestBasicIO(t *testing.T) {
44+ f := New()
45+ if err := f.MkdirAll("/d/sub", 0o700); err != nil {
46+ t.Fatal(err)
47+ }
48+ h := create(t, f, "/d/a")
49+ write(t, h, "hello ")
50+ write(t, h, "world")
51+ buf := make([]byte, 5)
52+ if n, err := h.ReadAt(buf, 6); n != 5 || err != nil || string(buf) != "world" {
53+ t.Fatalf("ReadAt = %d %v %q", n, err, buf)
54+ }
55+ if n, err := h.ReadAt(make([]byte, 4), 9); n != 2 || err != io.EOF {
56+ t.Fatalf("short ReadAt = %d %v", n, err)
57+ }
58+ if n, err := h.ReadAt(buf, 100); n != 0 || err != io.EOF {
59+ t.Fatalf("ReadAt past end = %d %v", n, err)
60+ }
61+ if err := h.Truncate(5); err != nil {
62+ t.Fatal(err)
63+ }
64+ if sz, _ := h.Size(); sz != 5 {
65+ t.Fatalf("size %d", sz)
66+ }
67+ write(t, h, "!")
68+ if got := mustRead(t, f, "/d/a"); got != "hello!" {
69+ t.Fatalf("content %q", got)
70+ }
71+ if err := h.Close(); err != nil {
72+ t.Fatal(err)
73+ }
74+ if _, err := h.Write([]byte("x")); err == nil {
75+ t.Fatal("write on closed file succeeded")
76+ }
77+ names, _ := f.ReadDir("/d")
78+ if len(names) != 2 || names[0] != "a" || names[1] != "sub" {
79+ t.Fatalf("ReadDir = %v", names)
80+ }
81+ if _, err := f.OpenFile("/d/missing", os.O_RDONLY, 0); !errors.Is(err, os.ErrNotExist) {
82+ t.Fatalf("open missing: %v", err)
83+ }
84+ if _, err := f.OpenFile("/d/a", os.O_RDWR|os.O_CREATE|os.O_EXCL, 0); !errors.Is(err, os.ErrExist) {
85+ t.Fatalf("O_EXCL: %v", err)
86+ }
87+ if err := f.Rename("/d/a", "/d/b"); err != nil {
88+ t.Fatal(err)
89+ }
90+ if err := f.Remove("/d/b"); err != nil {
91+ t.Fatal(err)
92+ }
93+ if err := f.Remove("/d/b"); !errors.Is(err, os.ErrNotExist) {
94+ t.Fatalf("second remove: %v", err)
95+ }
96+}
97+
98+func TestFailWriteAndShortWrite(t *testing.T) {
99+ f := New()
100+ h := create(t, f, "/a")
101+ f.FailWrite(2, nil)
102+ write(t, h, "one")
103+ if n, err := h.Write([]byte("two")); n != 0 || !errors.Is(err, ErrInjected) {
104+ t.Fatalf("failed write = %d %v", n, err)
105+ }
106+ write(t, h, "three") // faults fire once
107+ f.ShortWrite(1, 2)
108+ if n, err := h.Write([]byte("abcdef")); n != 2 || !errors.Is(err, io.ErrShortWrite) {
109+ t.Fatalf("short write = %d %v", n, err)
110+ }
111+ if got := mustRead(t, f, "/a"); got != "onethreeab" {
112+ t.Fatalf("content %q", got)
113+ }
114+ if f.Writes() != 4 {
115+ t.Fatalf("Writes = %d", f.Writes())
116+ }
117+}
118+
119+func TestFailSyncAndCounters(t *testing.T) {
120+ f := New()
121+ h := create(t, f, "/a")
122+ write(t, h, "x")
123+ f.FailSync(2, nil)
124+ if err := h.Sync(); err != nil {
125+ t.Fatal(err)
126+ }
127+ write(t, h, "y")
128+ if err := h.SyncAll(); !errors.Is(err, ErrInjected) {
129+ t.Fatalf("SyncAll = %v", err)
130+ }
131+ if d, _ := f.DurableFile("/a"); string(d) != "x" {
132+ t.Fatalf("failed sync made data durable: %q", d)
133+ }
134+ if err := h.SyncAll(); err != nil {
135+ t.Fatal(err)
136+ }
137+ if f.Syncs() != 1 || f.SyncAlls() != 2 {
138+ t.Fatalf("Syncs=%d SyncAlls=%d", f.Syncs(), f.SyncAlls())
139+ }
140+}
141+
142+func TestSyncDelay(t *testing.T) {
143+ f := New()
144+ h := create(t, f, "/a")
145+ f.SetSyncDelay(20 * time.Millisecond)
146+ start := time.Now()
147+ if err := h.Sync(); err != nil {
148+ t.Fatal(err)
149+ }
150+ if time.Since(start) < 20*time.Millisecond {
151+ t.Fatal("sync returned before the delay")
152+ }
153+}
154+
155+func TestPowerLossDropsUnsyncedBytes(t *testing.T) {
156+ f := New()
157+ f.WriteFile("/d/a", nil)
158+ h, _ := f.OpenFile("/d/a", os.O_RDWR, 0)
159+ write(t, h, "synced")
160+ if err := h.Sync(); err != nil {
161+ t.Fatal(err)
162+ }
163+ write(t, h, "-lost")
164+ f.PowerLoss(PowerLossOptions{})
165+ if got := mustRead(t, f, "/d/a"); got != "synced" {
166+ t.Fatalf("after power loss %q", got)
167+ }
168+ if _, err := h.Write([]byte("x")); !errors.Is(err, ErrPowerLoss) {
169+ t.Fatalf("stale handle write: %v", err)
170+ }
171+ h2, _ := f.OpenFile("/d/a", os.O_RDWR, 0)
172+ write(t, h2, "+new")
173+ if sz, _ := h2.Size(); sz != 10 {
174+ t.Fatalf("size %d", sz)
175+ }
176+}
177+
178+func TestPowerLossTruncateNeedsSync(t *testing.T) {
179+ f := New()
180+ f.WriteFile("/d/a", []byte("0123456789"))
181+ h, _ := f.OpenFile("/d/a", os.O_RDWR, 0)
182+ if err := h.Truncate(4); err != nil {
183+ t.Fatal(err)
184+ }
185+ f.PowerLoss(PowerLossOptions{})
186+ if got := mustRead(t, f, "/d/a"); got != "0123456789" {
187+ t.Fatalf("unsynced truncate survived: %q", got)
188+ }
189+ h, _ = f.OpenFile("/d/a", os.O_RDWR, 0)
190+ _ = h.Truncate(4)
191+ _ = h.SyncAll()
192+ f.PowerLoss(PowerLossOptions{})
193+ if got := mustRead(t, f, "/d/a"); got != "0123" {
194+ t.Fatalf("synced truncate lost: %q", got)
195+ }
196+}
197+
198+func TestPowerLossZeroFillAndPages(t *testing.T) {
199+ data := bytes.Repeat([]byte{'x'}, 5*16)
200+ run := func(o PowerLossOptions) []byte {
201+ f := New()
202+ f.WriteFile("/d/a", []byte("head"))
203+ h, _ := f.OpenFile("/d/a", os.O_RDWR, 0)
204+ write(t, h, string(data))
205+ f.PowerLoss(o)
206+ b, _ := f.ReadFile("/d/a")
207+ return b
208+ }
209+ zf := run(PowerLossOptions{ZeroFill: true, PageSize: 16})
210+ if len(zf) != 4+len(data) || string(zf[:4]) != "head" || !bytes.Equal(zf[4:], make([]byte, len(data))) {
211+ t.Fatalf("zero fill: len=%d %q", len(zf), zf)
212+ }
213+ kept := map[int]bool{}
214+ for seed := uint64(0); seed < 40; seed++ {
215+ b := run(PowerLossOptions{KeepPages: true, PageSize: 16, Rand: rand.New(rand.NewPCG(seed, 1))})
216+ if len(b) < 4 || string(b[:4]) != "head" || len(b) > 4+len(data) {
217+ t.Fatalf("seed %d: bad result len %d", seed, len(b))
218+ }
219+ kept[len(b)] = true
220+ for i, c := range b[4:] {
221+ if c != 'x' && c != 0 {
222+ t.Fatalf("seed %d: byte %d = %d", seed, i, c)
223+ }
224+ }
225+ }
226+ if len(kept) < 3 {
227+ t.Fatalf("KeepPages gave too little variety: %v", kept)
228+ }
229+ if got := run(PowerLossOptions{}); string(got) != "head" {
230+ t.Fatalf("default power loss kept %q", got)
231+ }
232+}
233+
234+func TestPowerLossDirectoryOps(t *testing.T) {
235+ f := New()
236+ if err := f.MkdirAll("/db", 0o700); err != nil {
237+ t.Fatal(err)
238+ }
239+ if err := f.SyncDir("/db"); err != nil {
240+ t.Fatal(err)
241+ }
242+ if err := f.SyncDir("/"); err != nil {
243+ t.Fatal(err)
244+ }
245+
246+ // Create + sync data, but no SyncDir: the file vanishes.
247+ h := create(t, f, "/db/unsynced")
248+ write(t, h, "data")
249+ _ = h.SyncAll()
250+ // Create, sync data and dir: survives.
251+ h = create(t, f, "/db/kept.tmp")
252+ write(t, h, "kept")
253+ _ = h.SyncAll()
254+ if err := f.Rename("/db/kept.tmp", "/db/kept"); err != nil {
255+ t.Fatal(err)
256+ }
257+ // Remove without SyncDir: file comes back.
258+ f.WriteFile("/db/removed", []byte("old"))
259+ if err := f.Remove("/db/removed"); err != nil {
260+ t.Fatal(err)
261+ }
262+ if err := f.SyncDir("/other"); err == nil {
263+ t.Fatal("SyncDir of a missing dir succeeded")
264+ }
265+ f.PowerLoss(PowerLossOptions{})
266+ names, _ := f.ReadDir("/db")
267+ if len(names) != 1 || names[0] != "removed" {
268+ t.Fatalf("after power loss without SyncDir: %v", names)
269+ }
270+
271+ // Now with SyncDir after each step.
272+ h = create(t, f, "/db/new.tmp")
273+ write(t, h, "new")
274+ _ = h.SyncAll()
275+ _ = f.Rename("/db/new.tmp", "/db/new")
276+ _ = f.Remove("/db/removed")
277+ _ = f.SyncDir("/db")
278+ f.PowerLoss(PowerLossOptions{})
279+ names, _ = f.ReadDir("/db")
280+ if len(names) != 1 || names[0] != "new" {
281+ t.Fatalf("after power loss with SyncDir: %v", names)
282+ }
283+ if got := mustRead(t, f, "/db/new"); got != "new" {
284+ t.Fatalf("content %q", got)
285+ }
286+}
287+
288+func TestPowerLossRenameOverExisting(t *testing.T) {
289+ f := New()
290+ f.WriteFile("/db/x", []byte("old"))
291+ h := create(t, f, "/db/x.tmp")
292+ write(t, h, "new")
293+ _ = h.SyncAll()
294+ _ = f.Rename("/db/x.tmp", "/db/x")
295+ f.PowerLoss(PowerLossOptions{})
296+ if got := mustRead(t, f, "/db/x"); got != "old" {
297+ t.Fatalf("x = %q, want old", got)
298+ }
299+ if _, ok := f.ReadFile("/db/x.tmp"); ok {
300+ t.Fatal("x.tmp survived without SyncDir")
301+ }
302+}
303+
304+func TestPowerLossUncreatedDirectory(t *testing.T) {
305+ f := New()
306+ _ = f.MkdirAll("/a/b", 0o700)
307+ h := create(t, f, "/a/b/file")
308+ write(t, h, "x")
309+ _ = h.SyncAll()
310+ _ = f.SyncDir("/a/b") // the file entry is durable, its parents are not
311+ f.PowerLoss(PowerLossOptions{})
312+ if _, err := f.ReadDir("/a/b"); err == nil {
313+ t.Fatal("directory created without SyncDir survived")
314+ }
315+ if _, ok := f.ReadFile("/a/b/file"); ok {
316+ t.Fatal("file in a lost directory survived")
317+ }
318+}
319+
320+func TestLockExclusive(t *testing.T) {
321+ f := New()
322+ _ = f.MkdirAll("/db", 0o700)
323+ _ = f.SyncDir("/db")
324+ _ = f.SyncDir("/")
325+ l1, err := f.Lock("/db/LOCK")
326+ if err != nil {
327+ t.Fatal(err)
328+ }
329+ if _, err := f.Lock("/db/LOCK"); !errors.Is(err, fsys.ErrLocked) {
330+ t.Fatalf("second lock: %v", err)
331+ }
332+ other, err := f.Lock("/db/OTHER")
333+ if err != nil {
334+ t.Fatalf("lock on another path: %v", err)
335+ }
336+ _ = other.Close()
337+ _ = l1.Close()
338+ _ = l1.Close() // idempotent
339+ l2, err := f.Lock("/db/LOCK")
340+ if err != nil {
341+ t.Fatalf("lock after release: %v", err)
342+ }
343+ // A stale handle must not release a newer lock after power loss.
344+ f.PowerLoss(PowerLossOptions{})
345+ l3, err := f.Lock("/db/LOCK")
346+ if err != nil {
347+ t.Fatalf("lock after power loss: %v", err)
348+ }
349+ _ = l2.Close()
350+ if _, err := f.Lock("/db/LOCK"); !errors.Is(err, fsys.ErrLocked) {
351+ t.Fatalf("stale Close released the lock: %v", err)
352+ }
353+ _ = l3.Close()
354+}
355+
356+func TestConcurrentUse(t *testing.T) {
357+ f := New()
358+ _ = f.MkdirAll("/db", 0o700)
359+ var wg sync.WaitGroup
360+ for i := range 8 {
361+ wg.Add(1)
362+ go func() {
363+ defer wg.Done()
364+ name := "/db/f" + string(rune('a'+i))
365+ h, err := f.OpenFile(name, os.O_RDWR|os.O_CREATE, 0o600)
366+ if err != nil {
367+ t.Error(err)
368+ return
369+ }
370+ for range 100 {
371+ _, _ = h.Write([]byte("x"))
372+ _ = h.Sync()
373+ _, _ = h.Size()
374+ _, _ = f.ReadDir("/db")
375+ _ = f.SyncDir("/db")
376+ }
377+ _ = h.Close()
378+ }()
379+ }
380+ wg.Wait()
381+ if f.Syncs() != 800 || f.Writes() != 800 {
382+ t.Fatalf("Syncs=%d Writes=%d", f.Syncs(), f.Writes())
383+ }
384+}
A internal/fsys/fsys.go +154 -0
@@ -0,0 +1,154 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package fsys is the minimal file-system abstraction used by the store, with
4+// an os-backed implementation. The faultfs sub-package provides an in-memory
5+// implementation with fault injection for tests.
6+package fsys
7+
8+import (
9+ "errors"
10+ "io"
11+ "os"
12+ "sort"
13+ "syscall"
14+)
15+
16+// ErrLocked is returned by FS.Lock when the lock is already held.
17+var ErrLocked = errors.New("fsys: locked")
18+
19+// FS is a file system. Paths are used as given; implementations do not
20+// resolve them against a working directory other than the OS one.
21+type FS interface {
22+ // OpenFile opens name with os.OpenFile flags. Files opened for writing
23+ // always append: Write adds bytes at the current end of the file.
24+ OpenFile(name string, flag int, perm os.FileMode) (File, error)
25+ // Rename atomically replaces newpath with oldpath.
26+ Rename(oldpath, newpath string) error
27+ // Remove deletes a file.
28+ Remove(name string) error
29+ // ReadDir returns the sorted entry names of dir.
30+ ReadDir(dir string) ([]string, error)
31+ // MkdirAll creates dir and any missing parents.
32+ MkdirAll(dir string, perm os.FileMode) error
33+ // SyncDir makes the entries of dir (creations, renames, removals)
34+ // durable.
35+ SyncDir(dir string) error
36+ // Lock takes an exclusive, non-blocking lock on the file at path,
37+ // creating it if needed. It returns ErrLocked if the lock is held,
38+ // including by the same process. Closing the result releases the lock.
39+ Lock(path string) (io.Closer, error)
40+}
41+
42+// File is an open file.
43+type File interface {
44+ // Write appends p to the end of the file.
45+ Write(p []byte) (int, error)
46+ ReadAt(p []byte, off int64) (int, error)
47+ // Sync flushes data and the size needed to read it (fdatasync).
48+ Sync() error
49+ // SyncAll flushes data and all metadata (fsync).
50+ SyncAll() error
51+ Truncate(size int64) error
52+ Size() (int64, error)
53+ Close() error
54+}
55+
56+// OS returns the FS backed by the operating system.
57+func OS() FS { return osFS{} }
58+
59+type osFS struct{}
60+
61+func (osFS) OpenFile(name string, flag int, perm os.FileMode) (File, error) {
62+ if flag&(os.O_WRONLY|os.O_RDWR) != 0 {
63+ flag |= os.O_APPEND
64+ }
65+ f, err := os.OpenFile(name, flag, perm)
66+ if err != nil {
67+ return nil, err
68+ }
69+ return osFile{f}, nil
70+}
71+
72+func (osFS) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) }
73+
74+func (osFS) Remove(name string) error { return os.Remove(name) }
75+
76+func (osFS) ReadDir(dir string) ([]string, error) {
77+ entries, err := os.ReadDir(dir)
78+ if err != nil {
79+ return nil, err
80+ }
81+ names := make([]string, len(entries))
82+ for i, e := range entries {
83+ names[i] = e.Name()
84+ }
85+ sort.Strings(names)
86+ return names, nil
87+}
88+
89+func (osFS) MkdirAll(dir string, perm os.FileMode) error { return os.MkdirAll(dir, perm) }
90+
91+func (osFS) SyncDir(dir string) error {
92+ d, err := os.Open(dir)
93+ if err != nil {
94+ return err
95+ }
96+ err = d.Sync()
97+ if cerr := d.Close(); err == nil {
98+ err = cerr
99+ }
100+ return err
101+}
102+
103+func (osFS) Lock(path string) (io.Closer, error) {
104+ f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
105+ if err != nil {
106+ return nil, err
107+ }
108+ if err := control(f, func(fd int) error {
109+ return syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB)
110+ }); err != nil {
111+ _ = f.Close()
112+ if errors.Is(err, syscall.EWOULDBLOCK) {
113+ return nil, ErrLocked
114+ }
115+ return nil, err
116+ }
117+ return f, nil // closing the descriptor releases the flock
118+}
119+
120+type osFile struct{ *os.File }
121+
122+func (f osFile) Sync() error {
123+ return control(f.File, syscall.Fdatasync)
124+}
125+
126+func (f osFile) SyncAll() error { return f.File.Sync() }
127+
128+func (f osFile) Size() (int64, error) {
129+ fi, err := f.Stat()
130+ if err != nil {
131+ return 0, err
132+ }
133+ return fi.Size(), nil
134+}
135+
136+// control runs fn on the file descriptor, retrying on EINTR.
137+func control(f *os.File, fn func(fd int) error) error {
138+ rc, err := f.SyscallConn()
139+ if err != nil {
140+ return err
141+ }
142+ var ferr error
143+ if err := rc.Control(func(fd uintptr) {
144+ for {
145+ ferr = fn(int(fd))
146+ if !errors.Is(ferr, syscall.EINTR) {
147+ return
148+ }
149+ }
150+ }); err != nil {
151+ return err
152+ }
153+ return ferr
154+}
@@ -0,0 +1,154 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package fsys is the minimal file-system abstraction used by the store, with
4+// an os-backed implementation. The faultfs sub-package provides an in-memory
5+// implementation with fault injection for tests.
6+package fsys
7+
8+import (
9+ "errors"
10+ "io"
11+ "os"
12+ "sort"
13+ "syscall"
14+)
15+
16+// ErrLocked is returned by FS.Lock when the lock is already held.
17+var ErrLocked = errors.New("fsys: locked")
18+
19+// FS is a file system. Paths are used as given; implementations do not
20+// resolve them against a working directory other than the OS one.
21+type FS interface {
22+ // OpenFile opens name with os.OpenFile flags. Files opened for writing
23+ // always append: Write adds bytes at the current end of the file.
24+ OpenFile(name string, flag int, perm os.FileMode) (File, error)
25+ // Rename atomically replaces newpath with oldpath.
26+ Rename(oldpath, newpath string) error
27+ // Remove deletes a file.
28+ Remove(name string) error
29+ // ReadDir returns the sorted entry names of dir.
30+ ReadDir(dir string) ([]string, error)
31+ // MkdirAll creates dir and any missing parents.
32+ MkdirAll(dir string, perm os.FileMode) error
33+ // SyncDir makes the entries of dir (creations, renames, removals)
34+ // durable.
35+ SyncDir(dir string) error
36+ // Lock takes an exclusive, non-blocking lock on the file at path,
37+ // creating it if needed. It returns ErrLocked if the lock is held,
38+ // including by the same process. Closing the result releases the lock.
39+ Lock(path string) (io.Closer, error)
40+}
41+
42+// File is an open file.
43+type File interface {
44+ // Write appends p to the end of the file.
45+ Write(p []byte) (int, error)
46+ ReadAt(p []byte, off int64) (int, error)
47+ // Sync flushes data and the size needed to read it (fdatasync).
48+ Sync() error
49+ // SyncAll flushes data and all metadata (fsync).
50+ SyncAll() error
51+ Truncate(size int64) error
52+ Size() (int64, error)
53+ Close() error
54+}
55+
56+// OS returns the FS backed by the operating system.
57+func OS() FS { return osFS{} }
58+
59+type osFS struct{}
60+
61+func (osFS) OpenFile(name string, flag int, perm os.FileMode) (File, error) {
62+ if flag&(os.O_WRONLY|os.O_RDWR) != 0 {
63+ flag |= os.O_APPEND
64+ }
65+ f, err := os.OpenFile(name, flag, perm)
66+ if err != nil {
67+ return nil, err
68+ }
69+ return osFile{f}, nil
70+}
71+
72+func (osFS) Rename(oldpath, newpath string) error { return os.Rename(oldpath, newpath) }
73+
74+func (osFS) Remove(name string) error { return os.Remove(name) }
75+
76+func (osFS) ReadDir(dir string) ([]string, error) {
77+ entries, err := os.ReadDir(dir)
78+ if err != nil {
79+ return nil, err
80+ }
81+ names := make([]string, len(entries))
82+ for i, e := range entries {
83+ names[i] = e.Name()
84+ }
85+ sort.Strings(names)
86+ return names, nil
87+}
88+
89+func (osFS) MkdirAll(dir string, perm os.FileMode) error { return os.MkdirAll(dir, perm) }
90+
91+func (osFS) SyncDir(dir string) error {
92+ d, err := os.Open(dir)
93+ if err != nil {
94+ return err
95+ }
96+ err = d.Sync()
97+ if cerr := d.Close(); err == nil {
98+ err = cerr
99+ }
100+ return err
101+}
102+
103+func (osFS) Lock(path string) (io.Closer, error) {
104+ f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
105+ if err != nil {
106+ return nil, err
107+ }
108+ if err := control(f, func(fd int) error {
109+ return syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB)
110+ }); err != nil {
111+ _ = f.Close()
112+ if errors.Is(err, syscall.EWOULDBLOCK) {
113+ return nil, ErrLocked
114+ }
115+ return nil, err
116+ }
117+ return f, nil // closing the descriptor releases the flock
118+}
119+
120+type osFile struct{ *os.File }
121+
122+func (f osFile) Sync() error {
123+ return control(f.File, syscall.Fdatasync)
124+}
125+
126+func (f osFile) SyncAll() error { return f.File.Sync() }
127+
128+func (f osFile) Size() (int64, error) {
129+ fi, err := f.Stat()
130+ if err != nil {
131+ return 0, err
132+ }
133+ return fi.Size(), nil
134+}
135+
136+// control runs fn on the file descriptor, retrying on EINTR.
137+func control(f *os.File, fn func(fd int) error) error {
138+ rc, err := f.SyscallConn()
139+ if err != nil {
140+ return err
141+ }
142+ var ferr error
143+ if err := rc.Control(func(fd uintptr) {
144+ for {
145+ ferr = fn(int(fd))
146+ if !errors.Is(ferr, syscall.EINTR) {
147+ return
148+ }
149+ }
150+ }); err != nil {
151+ return err
152+ }
153+ return ferr
154+}
A internal/fsys/fsys_test.go +86 -0
@@ -0,0 +1,86 @@
1+// SPDX-License-Identifier: MIT
2+
3+package fsys
4+
5+import (
6+ "errors"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "testing"
11+)
12+
13+func TestOS(t *testing.T) {
14+ fx := OS()
15+ dir := filepath.Join(t.TempDir(), "a", "b")
16+ if err := fx.MkdirAll(dir, 0o700); err != nil {
17+ t.Fatal(err)
18+ }
19+ if err := fx.SyncDir(dir); err != nil {
20+ t.Fatal(err)
21+ }
22+ name := filepath.Join(dir, "f.tmp")
23+ f, err := fx.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ for _, s := range []string{"hello ", "world"} {
28+ if _, err := f.Write([]byte(s)); err != nil {
29+ t.Fatal(err)
30+ }
31+ }
32+ if err := f.Sync(); err != nil {
33+ t.Fatal(err)
34+ }
35+ if err := f.SyncAll(); err != nil {
36+ t.Fatal(err)
37+ }
38+ if err := f.Truncate(8); err != nil {
39+ t.Fatal(err)
40+ }
41+ if _, err := f.Write([]byte("!")); err != nil { // appends after truncation
42+ t.Fatal(err)
43+ }
44+ if sz, err := f.Size(); err != nil || sz != 9 {
45+ t.Fatalf("Size = %d, %v", sz, err)
46+ }
47+ buf := make([]byte, 9)
48+ if n, err := f.ReadAt(buf, 0); n != 9 || (err != nil && !errors.Is(err, io.EOF)) || string(buf) != "hello wo!" {
49+ t.Fatalf("ReadAt = %d %v %q", n, err, buf)
50+ }
51+ if err := f.Close(); err != nil {
52+ t.Fatal(err)
53+ }
54+
55+ final := filepath.Join(dir, "f")
56+ if err := fx.Rename(name, final); err != nil {
57+ t.Fatal(err)
58+ }
59+ names, err := fx.ReadDir(dir)
60+ if err != nil || len(names) != 1 || names[0] != "f" {
61+ t.Fatalf("ReadDir = %v, %v", names, err)
62+ }
63+ if err := fx.Remove(final); err != nil {
64+ t.Fatal(err)
65+ }
66+}
67+
68+func TestOSLock(t *testing.T) {
69+ fx := OS()
70+ path := filepath.Join(t.TempDir(), "LOCK")
71+ l, err := fx.Lock(path)
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ if _, err := fx.Lock(path); !errors.Is(err, ErrLocked) {
76+ t.Fatalf("second lock: %v", err)
77+ }
78+ if err := l.Close(); err != nil {
79+ t.Fatal(err)
80+ }
81+ l, err = fx.Lock(path)
82+ if err != nil {
83+ t.Fatalf("lock after release: %v", err)
84+ }
85+ _ = l.Close()
86+}
@@ -0,0 +1,86 @@
1+// SPDX-License-Identifier: MIT
2+
3+package fsys
4+
5+import (
6+ "errors"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "testing"
11+)
12+
13+func TestOS(t *testing.T) {
14+ fx := OS()
15+ dir := filepath.Join(t.TempDir(), "a", "b")
16+ if err := fx.MkdirAll(dir, 0o700); err != nil {
17+ t.Fatal(err)
18+ }
19+ if err := fx.SyncDir(dir); err != nil {
20+ t.Fatal(err)
21+ }
22+ name := filepath.Join(dir, "f.tmp")
23+ f, err := fx.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ for _, s := range []string{"hello ", "world"} {
28+ if _, err := f.Write([]byte(s)); err != nil {
29+ t.Fatal(err)
30+ }
31+ }
32+ if err := f.Sync(); err != nil {
33+ t.Fatal(err)
34+ }
35+ if err := f.SyncAll(); err != nil {
36+ t.Fatal(err)
37+ }
38+ if err := f.Truncate(8); err != nil {
39+ t.Fatal(err)
40+ }
41+ if _, err := f.Write([]byte("!")); err != nil { // appends after truncation
42+ t.Fatal(err)
43+ }
44+ if sz, err := f.Size(); err != nil || sz != 9 {
45+ t.Fatalf("Size = %d, %v", sz, err)
46+ }
47+ buf := make([]byte, 9)
48+ if n, err := f.ReadAt(buf, 0); n != 9 || (err != nil && !errors.Is(err, io.EOF)) || string(buf) != "hello wo!" {
49+ t.Fatalf("ReadAt = %d %v %q", n, err, buf)
50+ }
51+ if err := f.Close(); err != nil {
52+ t.Fatal(err)
53+ }
54+
55+ final := filepath.Join(dir, "f")
56+ if err := fx.Rename(name, final); err != nil {
57+ t.Fatal(err)
58+ }
59+ names, err := fx.ReadDir(dir)
60+ if err != nil || len(names) != 1 || names[0] != "f" {
61+ t.Fatalf("ReadDir = %v, %v", names, err)
62+ }
63+ if err := fx.Remove(final); err != nil {
64+ t.Fatal(err)
65+ }
66+}
67+
68+func TestOSLock(t *testing.T) {
69+ fx := OS()
70+ path := filepath.Join(t.TempDir(), "LOCK")
71+ l, err := fx.Lock(path)
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ if _, err := fx.Lock(path); !errors.Is(err, ErrLocked) {
76+ t.Fatalf("second lock: %v", err)
77+ }
78+ if err := l.Close(); err != nil {
79+ t.Fatal(err)
80+ }
81+ l, err = fx.Lock(path)
82+ if err != nil {
83+ t.Fatalf("lock after release: %v", err)
84+ }
85+ _ = l.Close()
86+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 2

↑↓ moveenter openesc close