detached b799b92

feat(faultfs): inject failures in dir and file operations

FailOp fails the n-th SyncDir, Rename, Remove, Truncate or OpenFile call.
DropOnSyncFailure models pages dropped after a failed fsync.
commit
b799b920f9aee8e319e77501d91c6234e0be2669
author
brice · · 2026-10-10 02:40 UTC
committer
brice · · 2026-10-10 02:52 UTC
parent
19d4f5e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +211 -2
M internal/fsys/faultfs/faultfs.go +106 -2
@@ -1,8 +1,9 @@
11 // SPDX-License-Identifier: MIT
22
33 // 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.
4+// front of durable storage and can inject failures in writes, syncs and
5+// directory and file operations, delay syncs, count syncs and simulate a power
6+// loss.
67 //
78 // Durability model: file bytes become durable on Sync or SyncAll; directory
89 // operations (create, rename, remove, mkdir) become durable on SyncDir of the
@@ -35,6 +36,42 @@ var ErrInjected = errors.New("faultfs: injected failure")
3536
3637 const defaultPageSize = 4096
3738
39+// OpKind identifies a file-system operation other than Write and Sync, whose
40+// failures are injected with FailOp.
41+type OpKind uint8
42+
43+// Operations that FailOp can fail.
44+const (
45+ OpSyncDir OpKind = iota
46+ OpRename
47+ OpRemove
48+ OpTruncate
49+ OpOpenFile
50+ numOps
51+)
52+
53+func (k OpKind) String() string {
54+ switch k {
55+ case OpSyncDir:
56+ return "SyncDir"
57+ case OpRename:
58+ return "Rename"
59+ case OpRemove:
60+ return "Remove"
61+ case OpTruncate:
62+ return "Truncate"
63+ case OpOpenFile:
64+ return "OpenFile"
65+ }
66+ return "unknown"
67+}
68+
69+// opCall designates the n-th call of an operation kind.
70+type opCall struct {
71+ kind OpKind
72+ n int
73+}
74+
3875 type node struct {
3976 data []byte // current content (page cache)
4077 durable []byte // content as of the last successful sync
@@ -64,6 +101,10 @@ type FS struct {
64101 failSync map[int]error // by absolute sync number (Sync+SyncAll)
65102 syncDelay time.Duration
66103 nSyncCalls int
104+ dropOnSyncFail bool
105+
106+ opCalls [numOps]int // calls so far, by kind
107+ failOps map[opCall]error // by kind and absolute call number
67108 }
68109
69110 type writeFault struct {
@@ -81,6 +122,7 @@ func New() *FS {
81122 locks: map[string]bool{},
82123 failWrite: map[int]writeFault{},
83124 failSync: map[int]error{},
125+ failOps: map[opCall]error{},
84126 }
85127 }
86128
@@ -115,6 +157,49 @@ func (f *FS) FailSync(n int, err error) {
115157 f.failSync[f.nSyncCalls+n] = err
116158 }
117159
160+// FailOp makes the n-th call from now (1 = the next one) of the operation
161+// kind fail with err (ErrInjected if nil). A failed call has no effect. Every
162+// call counts, whether or not it would have failed anyway.
163+func (f *FS) FailOp(kind OpKind, n int, err error) {
164+ if err == nil {
165+ err = ErrInjected
166+ }
167+ f.mu.Lock()
168+ defer f.mu.Unlock()
169+ f.failOps[opCall{kind, f.opCalls[kind] + n}] = err
170+}
171+
172+// OpCalls returns the number of calls of the operation kind so far.
173+func (f *FS) OpCalls(kind OpKind) int {
174+ f.mu.Lock()
175+ defer f.mu.Unlock()
176+ return f.opCalls[kind]
177+}
178+
179+// injected counts a call of kind and returns the error to fail it with, if
180+// any. It must be called with f.mu held.
181+func (f *FS) injected(kind OpKind) error {
182+ f.opCalls[kind]++
183+ c := opCall{kind, f.opCalls[kind]}
184+ err, ok := f.failOps[c]
185+ if ok {
186+ delete(f.failOps, c)
187+ }
188+ return err
189+}
190+
191+// DropOnSyncFailure selects what a failed Sync or SyncAll does to the data
192+// that was not yet durable. By default it stays in the page cache and a later
193+// successful sync makes it durable. When enabled, it is dropped, as on
194+// systems that mark the pages clean after a write-back error ("fsyncgate"):
195+// the file reverts to its last durable content, and the data is gone even if
196+// a later sync succeeds.
197+func (f *FS) DropOnSyncFailure(drop bool) {
198+ f.mu.Lock()
199+ f.dropOnSyncFail = drop
200+ f.mu.Unlock()
201+}
202+
118203 // SetSyncDelay makes every Sync and SyncAll block for d before taking effect.
119204 func (f *FS) SetSyncDelay(d time.Duration) {
120205 f.mu.Lock()
@@ -294,6 +379,9 @@ func (f *FS) OpenFile(name string, flag int, perm os.FileMode) (fsys.File, error
294379 name = filepath.Clean(name)
295380 f.mu.Lock()
296381 defer f.mu.Unlock()
382+ if err := f.injected(OpOpenFile); err != nil {
383+ return nil, &fs.PathError{Op: "open", Path: name, Err: err}
384+ }
297385 n, ok := f.files[name]
298386 switch {
299387 case f.dirs[name]:
@@ -322,6 +410,9 @@ func (f *FS) Rename(oldpath, newpath string) error {
322410 oldpath, newpath = filepath.Clean(oldpath), filepath.Clean(newpath)
323411 f.mu.Lock()
324412 defer f.mu.Unlock()
413+ if err := f.injected(OpRename); err != nil {
414+ return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: err}
415+ }
325416 n, ok := f.files[oldpath]
326417 if !ok {
327418 return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: fs.ErrNotExist}
@@ -345,6 +436,9 @@ func (f *FS) Remove(name string) error {
345436 name = filepath.Clean(name)
346437 f.mu.Lock()
347438 defer f.mu.Unlock()
439+ if err := f.injected(OpRemove); err != nil {
440+ return &fs.PathError{Op: "remove", Path: name, Err: err}
441+ }
348442 n, ok := f.files[name]
349443 if !ok {
350444 return &fs.PathError{Op: "remove", Path: name, Err: fs.ErrNotExist}
@@ -394,6 +488,9 @@ func (f *FS) SyncDir(dir string) error {
394488 dir = filepath.Clean(dir)
395489 f.mu.Lock()
396490 defer f.mu.Unlock()
491+ if err := f.injected(OpSyncDir); err != nil {
492+ return &fs.PathError{Op: "sync", Path: dir, Err: err}
493+ }
397494 if !f.dirs[dir] {
398495 return &fs.PathError{Op: "sync", Path: dir, Err: fs.ErrNotExist}
399496 }
@@ -540,6 +637,10 @@ func (h *file) sync(all bool) error {
540637 }
541638 if err, ok := f.failSync[f.nSyncCalls]; ok {
542639 delete(f.failSync, f.nSyncCalls)
640+ if f.dropOnSyncFail {
641+ h.n.data = append([]byte(nil), h.n.durable...)
642+ h.n.truncated = false
643+ }
543644 f.mu.Unlock()
544645 return err
545646 }
@@ -569,6 +670,9 @@ func (h *file) Truncate(size int64) error {
569670 if err := h.check(); err != nil {
570671 return err
571672 }
673+ if err := h.fs.injected(OpTruncate); err != nil {
674+ return &fs.PathError{Op: "truncate", Err: err}
675+ }
572676 if !h.write || size < 0 {
573677 return &fs.PathError{Op: "truncate", Err: fs.ErrInvalid}
574678 }
@@ -1,8 +1,9 @@
1 // SPDX-License-Identifier: MIT1 // SPDX-License-Identifier: MIT
2 2
3 // Package faultfs is an in-memory fsys.FS for tests. It models a page cache in3 // 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, delay4+// front of durable storage and can inject failures in writes, syncs and
5-// syncs, count syncs and simulate a power loss.5+// directory and file operations, delay syncs, count syncs and simulate a power
6+// loss.
6 //7 //
7 // Durability model: file bytes become durable on Sync or SyncAll; directory8 // Durability model: file bytes become durable on Sync or SyncAll; directory
8 // operations (create, rename, remove, mkdir) become durable on SyncDir of the9 // operations (create, rename, remove, mkdir) become durable on SyncDir of the
@@ -35,6 +36,42 @@ var ErrInjected = errors.New("faultfs: injected failure")
35 36
36 const defaultPageSize = 409637 const defaultPageSize = 4096
37 38
39+// OpKind identifies a file-system operation other than Write and Sync, whose
40+// failures are injected with FailOp.
41+type OpKind uint8
42+
43+// Operations that FailOp can fail.
44+const (
45+ OpSyncDir OpKind = iota
46+ OpRename
47+ OpRemove
48+ OpTruncate
49+ OpOpenFile
50+ numOps
51+)
52+
53+func (k OpKind) String() string {
54+ switch k {
55+ case OpSyncDir:
56+ return "SyncDir"
57+ case OpRename:
58+ return "Rename"
59+ case OpRemove:
60+ return "Remove"
61+ case OpTruncate:
62+ return "Truncate"
63+ case OpOpenFile:
64+ return "OpenFile"
65+ }
66+ return "unknown"
67+}
68+
69+// opCall designates the n-th call of an operation kind.
70+type opCall struct {
71+ kind OpKind
72+ n int
73+}
74+
38 type node struct {75 type node struct {
39 data []byte // current content (page cache)76 data []byte // current content (page cache)
40 durable []byte // content as of the last successful sync77 durable []byte // content as of the last successful sync
@@ -64,6 +101,10 @@ type FS struct {
64 failSync map[int]error // by absolute sync number (Sync+SyncAll)101 failSync map[int]error // by absolute sync number (Sync+SyncAll)
65 syncDelay time.Duration102 syncDelay time.Duration
66 nSyncCalls int103 nSyncCalls int
104+ dropOnSyncFail bool
105+
106+ opCalls [numOps]int // calls so far, by kind
107+ failOps map[opCall]error // by kind and absolute call number
67 }108 }
68 109
69 type writeFault struct {110 type writeFault struct {
@@ -81,6 +122,7 @@ func New() *FS {
81 locks: map[string]bool{},122 locks: map[string]bool{},
82 failWrite: map[int]writeFault{},123 failWrite: map[int]writeFault{},
83 failSync: map[int]error{},124 failSync: map[int]error{},
125+ failOps: map[opCall]error{},
84 }126 }
85 }127 }
86 128
@@ -115,6 +157,49 @@ func (f *FS) FailSync(n int, err error) {
115 f.failSync[f.nSyncCalls+n] = err157 f.failSync[f.nSyncCalls+n] = err
116 }158 }
117 159
160+// FailOp makes the n-th call from now (1 = the next one) of the operation
161+// kind fail with err (ErrInjected if nil). A failed call has no effect. Every
162+// call counts, whether or not it would have failed anyway.
163+func (f *FS) FailOp(kind OpKind, n int, err error) {
164+ if err == nil {
165+ err = ErrInjected
166+ }
167+ f.mu.Lock()
168+ defer f.mu.Unlock()
169+ f.failOps[opCall{kind, f.opCalls[kind] + n}] = err
170+}
171+
172+// OpCalls returns the number of calls of the operation kind so far.
173+func (f *FS) OpCalls(kind OpKind) int {
174+ f.mu.Lock()
175+ defer f.mu.Unlock()
176+ return f.opCalls[kind]
177+}
178+
179+// injected counts a call of kind and returns the error to fail it with, if
180+// any. It must be called with f.mu held.
181+func (f *FS) injected(kind OpKind) error {
182+ f.opCalls[kind]++
183+ c := opCall{kind, f.opCalls[kind]}
184+ err, ok := f.failOps[c]
185+ if ok {
186+ delete(f.failOps, c)
187+ }
188+ return err
189+}
190+
191+// DropOnSyncFailure selects what a failed Sync or SyncAll does to the data
192+// that was not yet durable. By default it stays in the page cache and a later
193+// successful sync makes it durable. When enabled, it is dropped, as on
194+// systems that mark the pages clean after a write-back error ("fsyncgate"):
195+// the file reverts to its last durable content, and the data is gone even if
196+// a later sync succeeds.
197+func (f *FS) DropOnSyncFailure(drop bool) {
198+ f.mu.Lock()
199+ f.dropOnSyncFail = drop
200+ f.mu.Unlock()
201+}
202+
118 // SetSyncDelay makes every Sync and SyncAll block for d before taking effect.203 // SetSyncDelay makes every Sync and SyncAll block for d before taking effect.
119 func (f *FS) SetSyncDelay(d time.Duration) {204 func (f *FS) SetSyncDelay(d time.Duration) {
120 f.mu.Lock()205 f.mu.Lock()
@@ -294,6 +379,9 @@ func (f *FS) OpenFile(name string, flag int, perm os.FileMode) (fsys.File, error
294 name = filepath.Clean(name)379 name = filepath.Clean(name)
295 f.mu.Lock()380 f.mu.Lock()
296 defer f.mu.Unlock()381 defer f.mu.Unlock()
382+ if err := f.injected(OpOpenFile); err != nil {
383+ return nil, &fs.PathError{Op: "open", Path: name, Err: err}
384+ }
297 n, ok := f.files[name]385 n, ok := f.files[name]
298 switch {386 switch {
299 case f.dirs[name]:387 case f.dirs[name]:
@@ -322,6 +410,9 @@ func (f *FS) Rename(oldpath, newpath string) error {
322 oldpath, newpath = filepath.Clean(oldpath), filepath.Clean(newpath)410 oldpath, newpath = filepath.Clean(oldpath), filepath.Clean(newpath)
323 f.mu.Lock()411 f.mu.Lock()
324 defer f.mu.Unlock()412 defer f.mu.Unlock()
413+ if err := f.injected(OpRename); err != nil {
414+ return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: err}
415+ }
325 n, ok := f.files[oldpath]416 n, ok := f.files[oldpath]
326 if !ok {417 if !ok {
327 return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: fs.ErrNotExist}418 return &os.LinkError{Op: "rename", Old: oldpath, New: newpath, Err: fs.ErrNotExist}
@@ -345,6 +436,9 @@ func (f *FS) Remove(name string) error {
345 name = filepath.Clean(name)436 name = filepath.Clean(name)
346 f.mu.Lock()437 f.mu.Lock()
347 defer f.mu.Unlock()438 defer f.mu.Unlock()
439+ if err := f.injected(OpRemove); err != nil {
440+ return &fs.PathError{Op: "remove", Path: name, Err: err}
441+ }
348 n, ok := f.files[name]442 n, ok := f.files[name]
349 if !ok {443 if !ok {
350 return &fs.PathError{Op: "remove", Path: name, Err: fs.ErrNotExist}444 return &fs.PathError{Op: "remove", Path: name, Err: fs.ErrNotExist}
@@ -394,6 +488,9 @@ func (f *FS) SyncDir(dir string) error {
394 dir = filepath.Clean(dir)488 dir = filepath.Clean(dir)
395 f.mu.Lock()489 f.mu.Lock()
396 defer f.mu.Unlock()490 defer f.mu.Unlock()
491+ if err := f.injected(OpSyncDir); err != nil {
492+ return &fs.PathError{Op: "sync", Path: dir, Err: err}
493+ }
397 if !f.dirs[dir] {494 if !f.dirs[dir] {
398 return &fs.PathError{Op: "sync", Path: dir, Err: fs.ErrNotExist}495 return &fs.PathError{Op: "sync", Path: dir, Err: fs.ErrNotExist}
399 }496 }
@@ -540,6 +637,10 @@ func (h *file) sync(all bool) error {
540 }637 }
541 if err, ok := f.failSync[f.nSyncCalls]; ok {638 if err, ok := f.failSync[f.nSyncCalls]; ok {
542 delete(f.failSync, f.nSyncCalls)639 delete(f.failSync, f.nSyncCalls)
640+ if f.dropOnSyncFail {
641+ h.n.data = append([]byte(nil), h.n.durable...)
642+ h.n.truncated = false
643+ }
543 f.mu.Unlock()644 f.mu.Unlock()
544 return err645 return err
545 }646 }
@@ -569,6 +670,9 @@ func (h *file) Truncate(size int64) error {
569 if err := h.check(); err != nil {670 if err := h.check(); err != nil {
570 return err671 return err
571 }672 }
673+ if err := h.fs.injected(OpTruncate); err != nil {
674+ return &fs.PathError{Op: "truncate", Err: err}
675+ }
572 if !h.write || size < 0 {676 if !h.write || size < 0 {
573 return &fs.PathError{Op: "truncate", Err: fs.ErrInvalid}677 return &fs.PathError{Op: "truncate", Err: fs.ErrInvalid}
574 }678 }
M internal/fsys/faultfs/faultfs_test.go +105 -0
@@ -382,3 +382,108 @@ func TestConcurrentUse(t *testing.T) {
382382 t.Fatalf("Syncs=%d Writes=%d", f.Syncs(), f.Writes())
383383 }
384384 }
385+
386+func TestFailOp(t *testing.T) {
387+ f := New()
388+ f.WriteFile("/d/a", []byte("x"))
389+ f.WriteFile("/d/b", nil)
390+
391+ f.FailOp(OpOpenFile, 2, nil)
392+ h, err := f.OpenFile("/d/a", os.O_RDWR, 0)
393+ if err != nil {
394+ t.Fatal(err)
395+ }
396+ if _, err := f.OpenFile("/d/a", os.O_RDWR, 0); !errors.Is(err, ErrInjected) {
397+ t.Fatalf("OpenFile = %v", err)
398+ }
399+ if _, err := f.OpenFile("/d/a", os.O_RDWR, 0); err != nil {
400+ t.Fatalf("faults fire once: %v", err)
401+ }
402+
403+ boom := errors.New("boom")
404+ f.FailOp(OpTruncate, 1, boom)
405+ if err := h.Truncate(0); !errors.Is(err, boom) {
406+ t.Fatalf("Truncate = %v", err)
407+ }
408+ if sz, _ := h.Size(); sz != 1 {
409+ t.Fatalf("failed Truncate changed the file: size %d", sz)
410+ }
411+ if err := h.Truncate(0); err != nil {
412+ t.Fatal(err)
413+ }
414+
415+ f.FailOp(OpRename, 1, nil)
416+ if err := f.Rename("/d/b", "/d/c"); !errors.Is(err, ErrInjected) {
417+ t.Fatalf("Rename = %v", err)
418+ }
419+ if _, ok := f.ReadFile("/d/b"); !ok {
420+ t.Fatal("failed Rename moved the file")
421+ }
422+ if err := f.Rename("/d/b", "/d/c"); err != nil {
423+ t.Fatal(err)
424+ }
425+
426+ f.FailOp(OpRemove, 1, nil)
427+ if err := f.Remove("/d/c"); !errors.Is(err, ErrInjected) {
428+ t.Fatalf("Remove = %v", err)
429+ }
430+ if _, ok := f.ReadFile("/d/c"); !ok {
431+ t.Fatal("failed Remove deleted the file")
432+ }
433+
434+ f.FailOp(OpSyncDir, 1, nil)
435+ if err := f.SyncDir("/d"); !errors.Is(err, ErrInjected) {
436+ t.Fatalf("SyncDir = %v", err)
437+ }
438+ if err := f.SyncDir("/d"); err != nil {
439+ t.Fatal(err)
440+ }
441+ if got := f.OpCalls(OpSyncDir); got != 2 {
442+ t.Fatalf("OpCalls(SyncDir) = %d", got)
443+ }
444+}
445+
446+func TestFailedSyncDirKeepsOpsVolatile(t *testing.T) {
447+ f := New()
448+ _ = f.MkdirAll("/d", 0o700)
449+ _ = f.SyncDir("/d")
450+ create(t, f, "/d/a")
451+ f.FailOp(OpSyncDir, 1, nil)
452+ if err := f.SyncDir("/d"); err == nil {
453+ t.Fatal("SyncDir succeeded")
454+ }
455+ f.PowerLoss(PowerLossOptions{})
456+ if _, ok := f.ReadFile("/d/a"); ok {
457+ t.Fatal("create survived a failed SyncDir")
458+ }
459+}
460+
461+func TestDropOnSyncFailure(t *testing.T) {
462+ for _, drop := range []bool{false, true} {
463+ f := New()
464+ f.DropOnSyncFailure(drop)
465+ h := create(t, f, "/a")
466+ write(t, h, "kept")
467+ if err := h.Sync(); err != nil {
468+ t.Fatal(err)
469+ }
470+ write(t, h, "-lost?")
471+ f.FailSync(1, nil)
472+ if err := h.Sync(); !errors.Is(err, ErrInjected) {
473+ t.Fatalf("Sync = %v", err)
474+ }
475+ want := "kept-lost?"
476+ if drop {
477+ want = "kept"
478+ }
479+ if got := mustRead(t, f, "/a"); got != want {
480+ t.Fatalf("drop=%v: content %q, want %q", drop, got, want)
481+ }
482+ if err := h.Sync(); err != nil {
483+ t.Fatal(err)
484+ }
485+ if d, _ := f.DurableFile("/a"); string(d) != want {
486+ t.Fatalf("drop=%v: durable %q, want %q", drop, d, want)
487+ }
488+ }
489+}
@@ -382,3 +382,108 @@ func TestConcurrentUse(t *testing.T) {
382 t.Fatalf("Syncs=%d Writes=%d", f.Syncs(), f.Writes())382 t.Fatalf("Syncs=%d Writes=%d", f.Syncs(), f.Writes())
383 }383 }
384 }384 }
385+
386+func TestFailOp(t *testing.T) {
387+ f := New()
388+ f.WriteFile("/d/a", []byte("x"))
389+ f.WriteFile("/d/b", nil)
390+
391+ f.FailOp(OpOpenFile, 2, nil)
392+ h, err := f.OpenFile("/d/a", os.O_RDWR, 0)
393+ if err != nil {
394+ t.Fatal(err)
395+ }
396+ if _, err := f.OpenFile("/d/a", os.O_RDWR, 0); !errors.Is(err, ErrInjected) {
397+ t.Fatalf("OpenFile = %v", err)
398+ }
399+ if _, err := f.OpenFile("/d/a", os.O_RDWR, 0); err != nil {
400+ t.Fatalf("faults fire once: %v", err)
401+ }
402+
403+ boom := errors.New("boom")
404+ f.FailOp(OpTruncate, 1, boom)
405+ if err := h.Truncate(0); !errors.Is(err, boom) {
406+ t.Fatalf("Truncate = %v", err)
407+ }
408+ if sz, _ := h.Size(); sz != 1 {
409+ t.Fatalf("failed Truncate changed the file: size %d", sz)
410+ }
411+ if err := h.Truncate(0); err != nil {
412+ t.Fatal(err)
413+ }
414+
415+ f.FailOp(OpRename, 1, nil)
416+ if err := f.Rename("/d/b", "/d/c"); !errors.Is(err, ErrInjected) {
417+ t.Fatalf("Rename = %v", err)
418+ }
419+ if _, ok := f.ReadFile("/d/b"); !ok {
420+ t.Fatal("failed Rename moved the file")
421+ }
422+ if err := f.Rename("/d/b", "/d/c"); err != nil {
423+ t.Fatal(err)
424+ }
425+
426+ f.FailOp(OpRemove, 1, nil)
427+ if err := f.Remove("/d/c"); !errors.Is(err, ErrInjected) {
428+ t.Fatalf("Remove = %v", err)
429+ }
430+ if _, ok := f.ReadFile("/d/c"); !ok {
431+ t.Fatal("failed Remove deleted the file")
432+ }
433+
434+ f.FailOp(OpSyncDir, 1, nil)
435+ if err := f.SyncDir("/d"); !errors.Is(err, ErrInjected) {
436+ t.Fatalf("SyncDir = %v", err)
437+ }
438+ if err := f.SyncDir("/d"); err != nil {
439+ t.Fatal(err)
440+ }
441+ if got := f.OpCalls(OpSyncDir); got != 2 {
442+ t.Fatalf("OpCalls(SyncDir) = %d", got)
443+ }
444+}
445+
446+func TestFailedSyncDirKeepsOpsVolatile(t *testing.T) {
447+ f := New()
448+ _ = f.MkdirAll("/d", 0o700)
449+ _ = f.SyncDir("/d")
450+ create(t, f, "/d/a")
451+ f.FailOp(OpSyncDir, 1, nil)
452+ if err := f.SyncDir("/d"); err == nil {
453+ t.Fatal("SyncDir succeeded")
454+ }
455+ f.PowerLoss(PowerLossOptions{})
456+ if _, ok := f.ReadFile("/d/a"); ok {
457+ t.Fatal("create survived a failed SyncDir")
458+ }
459+}
460+
461+func TestDropOnSyncFailure(t *testing.T) {
462+ for _, drop := range []bool{false, true} {
463+ f := New()
464+ f.DropOnSyncFailure(drop)
465+ h := create(t, f, "/a")
466+ write(t, h, "kept")
467+ if err := h.Sync(); err != nil {
468+ t.Fatal(err)
469+ }
470+ write(t, h, "-lost?")
471+ f.FailSync(1, nil)
472+ if err := h.Sync(); !errors.Is(err, ErrInjected) {
473+ t.Fatalf("Sync = %v", err)
474+ }
475+ want := "kept-lost?"
476+ if drop {
477+ want = "kept"
478+ }
479+ if got := mustRead(t, f, "/a"); got != want {
480+ t.Fatalf("drop=%v: content %q, want %q", drop, got, want)
481+ }
482+ if err := h.Sync(); err != nil {
483+ t.Fatal(err)
484+ }
485+ if d, _ := f.DurableFile("/a"); string(d) != want {
486+ t.Fatalf("drop=%v: durable %q, want %q", drop, d, want)
487+ }
488+ }
489+}

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