detached 3faaee9

test(persistence): cover compaction failure boundaries

commit
3faaee9126d085b9873aac84661bdc404f72fd71
author
brice · · 2026-07-25 20:08 UTC
parent
d6f7eb5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +231 -41
M internal/app/offline_kill_retry_test.go +4 -4
@@ -7,6 +7,7 @@ import (
77 "testing"
88 "time"
99
10+ "github.com/bnema/vev/internal/domain"
1011 "github.com/bnema/vev/internal/persist"
1112 "github.com/stretchr/testify/require"
1213 )
@@ -54,10 +55,9 @@ func TestRunKillOfflineRetriesSnapshotDeletion(t *testing.T) {
5455 t.Setenv("XDG_STATE_HOME", stateRoot)
5556 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
5657
57- p, err := persist.Open(filepath.Join(stateRoot, "vev"))
58- require.NoError(t, err)
58+ p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
5959 now := time.Now().UnixNano()
60- require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
60+ require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}))
6161 require.NoError(t, p.Close())
6262
6363 fake := &offlineSnapshotFake{deleteErr: tt.incrementalErr, deleteLegacyErr: tt.legacyErr}
@@ -65,7 +65,7 @@ func TestRunKillOfflineRetriesSnapshotDeletion(t *testing.T) {
6565 newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake }
6666 t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory })
6767
68- err = runKill(context.Background(), "named", false, false)
68+ err := runKill(context.Background(), "named", false, false)
6969 if tt.incrementalErr != nil {
7070 require.ErrorIs(t, err, tt.incrementalErr)
7171 }
@@ -7,6 +7,7 @@ import (
7 "testing"7 "testing"
8 "time"8 "time"
9 9
10+ "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/persist"11 "github.com/bnema/vev/internal/persist"
11 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
12 )13 )
@@ -54,10 +55,9 @@ func TestRunKillOfflineRetriesSnapshotDeletion(t *testing.T) {
54 t.Setenv("XDG_STATE_HOME", stateRoot)55 t.Setenv("XDG_STATE_HOME", stateRoot)
55 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)56 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
56 57
57- p, err := persist.Open(filepath.Join(stateRoot, "vev"))58+ p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
58- require.NoError(t, err)
59 now := time.Now().UnixNano()59 now := time.Now().UnixNano()
60- require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))60+ require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}))
61 require.NoError(t, p.Close())61 require.NoError(t, p.Close())
62 62
63 fake := &offlineSnapshotFake{deleteErr: tt.incrementalErr, deleteLegacyErr: tt.legacyErr}63 fake := &offlineSnapshotFake{deleteErr: tt.incrementalErr, deleteLegacyErr: tt.legacyErr}
@@ -65,7 +65,7 @@ func TestRunKillOfflineRetriesSnapshotDeletion(t *testing.T) {
65 newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake }65 newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake }
66 t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory })66 t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory })
67 67
68- err = runKill(context.Background(), "named", false, false)68+ err := runKill(context.Background(), "named", false, false)
69 if tt.incrementalErr != nil {69 if tt.incrementalErr != nil {
70 require.ErrorIs(t, err, tt.incrementalErr)70 require.ErrorIs(t, err, tt.incrementalErr)
71 }71 }
M internal/app/run_test.go +11 -10
@@ -429,17 +429,21 @@ func TestPrintSessionsMarksEphemeral(t *testing.T) {
429429 }
430430 }
431431
432+func newTestPersister(t *testing.T, stateDir string) *persist.Persister {
433+ t.Helper()
434+ store, err := persist.OpenStore(persist.StorePath(stateDir))
435+ require.NoError(t, err)
436+ return persist.New(store)
437+}
438+
432439 func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
433440 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
434441 t.Setenv("XDG_STATE_HOME", stateRoot)
435442 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
436443
437- p, err := persist.Open(filepath.Join(stateRoot, "vev"))
438- if err != nil {
439- t.Fatalf("persist.Open error = %v", err)
440- }
444+ p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
441445 now := time.Now().UnixNano()
442- if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
446+ if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}); err != nil {
443447 t.Fatalf("Save error = %v", err)
444448 }
445449 if err := p.Close(); err != nil {
@@ -477,12 +481,9 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
477481 t.Setenv("XDG_STATE_HOME", stateRoot)
478482 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
479483
480- p, err := persist.Open(filepath.Join(stateRoot, "vev"))
481- if err != nil {
482- t.Fatalf("persist.Open error = %v", err)
483- }
484+ p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
484485 now := time.Now().UnixNano()
485- if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
486+ if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}); err != nil {
486487 t.Fatalf("Save error = %v", err)
487488 }
488489 if err := p.Close(); err != nil {
@@ -429,17 +429,21 @@ func TestPrintSessionsMarksEphemeral(t *testing.T) {
429 }429 }
430 }430 }
431 431
432+func newTestPersister(t *testing.T, stateDir string) *persist.Persister {
433+ t.Helper()
434+ store, err := persist.OpenStore(persist.StorePath(stateDir))
435+ require.NoError(t, err)
436+ return persist.New(store)
437+}
438+
432 func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {439 func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
433 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()440 stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
434 t.Setenv("XDG_STATE_HOME", stateRoot)441 t.Setenv("XDG_STATE_HOME", stateRoot)
435 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)442 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
436 443
437- p, err := persist.Open(filepath.Join(stateRoot, "vev"))444+ p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
438- if err != nil {
439- t.Fatalf("persist.Open error = %v", err)
440- }
441 now := time.Now().UnixNano()445 now := time.Now().UnixNano()
442- if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {446+ if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}); err != nil {
443 t.Fatalf("Save error = %v", err)447 t.Fatalf("Save error = %v", err)
444 }448 }
445 if err := p.Close(); err != nil {449 if err := p.Close(); err != nil {
@@ -477,12 +481,9 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
477 t.Setenv("XDG_STATE_HOME", stateRoot)481 t.Setenv("XDG_STATE_HOME", stateRoot)
478 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)482 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
479 483
480- p, err := persist.Open(filepath.Join(stateRoot, "vev"))484+ p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
481- if err != nil {
482- t.Fatalf("persist.Open error = %v", err)
483- }
484 now := time.Now().UnixNano()485 now := time.Now().UnixNano()
485- if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {486+ if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}); err != nil {
486 t.Fatalf("Save error = %v", err)487 t.Fatalf("Save error = %v", err)
487 }488 }
488 if err := p.Close(); err != nil {489 if err := p.Close(); err != nil {
M internal/persist/persist.go +8 -5
@@ -102,7 +102,7 @@ func LoadCatalogueReadOnly(dir string) ([]domain.CatalogueRecord, error) {
102102 func (p *Persister) Save(record domain.CatalogueRecord) error { return p.Replace(record.Name, record) }
103103 func (p *Persister) Apply(records map[string]*domain.CatalogueRecord) error {
104104 if p == nil || p.store == nil {
105- return errPersistenceUnavailable
105+ return nil
106106 }
107107 p.mu.Lock()
108108 defer p.mu.Unlock()
@@ -165,6 +165,9 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord) erro
165165 return p.store.Sync()
166166 }
167167 func (p *Persister) Rename(oldName string, next domain.CatalogueRecord) error {
168+ if p == nil || p.store == nil {
169+ return nil
170+ }
168171 if oldName == next.Name {
169172 return errors.New("persist: rename requires distinct names")
170173 }
@@ -177,7 +180,7 @@ func (p *Persister) Replace(name string, next domain.CatalogueRecord) error {
177180 // Touch updates metadata while retaining the durable identity and recovery state.
178181 func (p *Persister) Touch(name, cwd string, at int64) error {
179182 if p == nil || p.store == nil {
180- return errPersistenceUnavailable
183+ return nil
181184 }
182185 p.mu.Lock()
183186 defer p.mu.Unlock()
@@ -198,7 +201,7 @@ func (p *Persister) Touch(name, cwd string, at int64) error {
198201 }
199202 func (p *Persister) TouchMRU(name string, sequence uint64) error {
200203 if p == nil || p.store == nil {
201- return errPersistenceUnavailable
204+ return nil
202205 }
203206 p.mu.Lock()
204207 defer p.mu.Unlock()
@@ -223,7 +226,7 @@ func (p *Persister) Delete(name string) error {
223226 func (p *Persister) LoadAll() ([]domain.CatalogueRecord, error) { return p.LoadCatalogue() }
224227 func (p *Persister) LoadCatalogue() ([]domain.CatalogueRecord, error) {
225228 if p == nil || p.store == nil {
226- return nil, errPersistenceUnavailable
229+ return []domain.CatalogueRecord{}, nil
227230 }
228231 p.mu.Lock()
229232 defer p.mu.Unlock()
@@ -233,7 +236,7 @@ func (p *Persister) LoadCatalogue() ([]domain.CatalogueRecord, error) {
233236 }
234237 func (p *Persister) Close() error {
235238 if p == nil || p.store == nil {
236- return errPersistenceUnavailable
239+ return nil
237240 }
238241 p.mu.Lock()
239242 defer p.mu.Unlock()
@@ -102,7 +102,7 @@ func LoadCatalogueReadOnly(dir string) ([]domain.CatalogueRecord, error) {
102 func (p *Persister) Save(record domain.CatalogueRecord) error { return p.Replace(record.Name, record) }102 func (p *Persister) Save(record domain.CatalogueRecord) error { return p.Replace(record.Name, record) }
103 func (p *Persister) Apply(records map[string]*domain.CatalogueRecord) error {103 func (p *Persister) Apply(records map[string]*domain.CatalogueRecord) error {
104 if p == nil || p.store == nil {104 if p == nil || p.store == nil {
105- return errPersistenceUnavailable105+ return nil
106 }106 }
107 p.mu.Lock()107 p.mu.Lock()
108 defer p.mu.Unlock()108 defer p.mu.Unlock()
@@ -165,6 +165,9 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord) erro
165 return p.store.Sync()165 return p.store.Sync()
166 }166 }
167 func (p *Persister) Rename(oldName string, next domain.CatalogueRecord) error {167 func (p *Persister) Rename(oldName string, next domain.CatalogueRecord) error {
168+ if p == nil || p.store == nil {
169+ return nil
170+ }
168 if oldName == next.Name {171 if oldName == next.Name {
169 return errors.New("persist: rename requires distinct names")172 return errors.New("persist: rename requires distinct names")
170 }173 }
@@ -177,7 +180,7 @@ func (p *Persister) Replace(name string, next domain.CatalogueRecord) error {
177 // Touch updates metadata while retaining the durable identity and recovery state.180 // Touch updates metadata while retaining the durable identity and recovery state.
178 func (p *Persister) Touch(name, cwd string, at int64) error {181 func (p *Persister) Touch(name, cwd string, at int64) error {
179 if p == nil || p.store == nil {182 if p == nil || p.store == nil {
180- return errPersistenceUnavailable183+ return nil
181 }184 }
182 p.mu.Lock()185 p.mu.Lock()
183 defer p.mu.Unlock()186 defer p.mu.Unlock()
@@ -198,7 +201,7 @@ func (p *Persister) Touch(name, cwd string, at int64) error {
198 }201 }
199 func (p *Persister) TouchMRU(name string, sequence uint64) error {202 func (p *Persister) TouchMRU(name string, sequence uint64) error {
200 if p == nil || p.store == nil {203 if p == nil || p.store == nil {
201- return errPersistenceUnavailable204+ return nil
202 }205 }
203 p.mu.Lock()206 p.mu.Lock()
204 defer p.mu.Unlock()207 defer p.mu.Unlock()
@@ -223,7 +226,7 @@ func (p *Persister) Delete(name string) error {
223 func (p *Persister) LoadAll() ([]domain.CatalogueRecord, error) { return p.LoadCatalogue() }226 func (p *Persister) LoadAll() ([]domain.CatalogueRecord, error) { return p.LoadCatalogue() }
224 func (p *Persister) LoadCatalogue() ([]domain.CatalogueRecord, error) {227 func (p *Persister) LoadCatalogue() ([]domain.CatalogueRecord, error) {
225 if p == nil || p.store == nil {228 if p == nil || p.store == nil {
226- return nil, errPersistenceUnavailable229+ return []domain.CatalogueRecord{}, nil
227 }230 }
228 p.mu.Lock()231 p.mu.Lock()
229 defer p.mu.Unlock()232 defer p.mu.Unlock()
@@ -233,7 +236,7 @@ func (p *Persister) LoadCatalogue() ([]domain.CatalogueRecord, error) {
233 }236 }
234 func (p *Persister) Close() error {237 func (p *Persister) Close() error {
235 if p == nil || p.store == nil {238 if p == nil || p.store == nil {
236- return errPersistenceUnavailable239+ return nil
237 }240 }
238 p.mu.Lock()241 p.mu.Lock()
239 defer p.mu.Unlock()242 defer p.mu.Unlock()
M internal/persist/persist_test.go +13 -6
@@ -64,10 +64,17 @@ func TestCatalogueBatchFailureDoesNotSync(t *testing.T) {
6464 require.ErrorIs(t, err, batchErr)
6565 }
6666
67-func TestCatalogueNilPersisterFailsClosed(t *testing.T) {
68- var p *Persister
69- require.ErrorIs(t, p.Save(validRecord("one", 1)), errPersistenceUnavailable)
70- _, err := p.LoadAll()
71- require.ErrorIs(t, err, errPersistenceUnavailable)
72- require.ErrorIs(t, p.Close(), errPersistenceUnavailable)
67+func TestCatalogueNilStoreIsNoOp(t *testing.T) {
68+ persisters := []*Persister{nil, New(nil)}
69+ for _, p := range persisters {
70+ require.NoError(t, p.Save(validRecord("one", 1)))
71+ require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": nil}))
72+ require.NoError(t, p.Touch("one", "/tmp", 1))
73+ require.NoError(t, p.TouchMRU("one", 1))
74+ require.NoError(t, p.Delete("one"))
75+ records, err := p.LoadAll()
76+ require.NoError(t, err)
77+ require.Empty(t, records)
78+ require.NoError(t, p.Close())
79+ }
7380 }
@@ -64,10 +64,17 @@ func TestCatalogueBatchFailureDoesNotSync(t *testing.T) {
64 require.ErrorIs(t, err, batchErr)64 require.ErrorIs(t, err, batchErr)
65 }65 }
66 66
67-func TestCatalogueNilPersisterFailsClosed(t *testing.T) {67+func TestCatalogueNilStoreIsNoOp(t *testing.T) {
68- var p *Persister68+ persisters := []*Persister{nil, New(nil)}
69- require.ErrorIs(t, p.Save(validRecord("one", 1)), errPersistenceUnavailable)69+ for _, p := range persisters {
70- _, err := p.LoadAll()70+ require.NoError(t, p.Save(validRecord("one", 1)))
71- require.ErrorIs(t, err, errPersistenceUnavailable)71+ require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": nil}))
72- require.ErrorIs(t, p.Close(), errPersistenceUnavailable)72+ require.NoError(t, p.Touch("one", "/tmp", 1))
73+ require.NoError(t, p.TouchMRU("one", 1))
74+ require.NoError(t, p.Delete("one"))
75+ records, err := p.LoadAll()
76+ require.NoError(t, err)
77+ require.Empty(t, records)
78+ require.NoError(t, p.Close())
79+ }
73 }80 }
M internal/usecase/daemon/session_test.go +24 -1
@@ -703,22 +703,33 @@ func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
703703 }).Maybe()
704704 store.EXPECT().Batch(mock.Anything).RunAndReturn(func(changes []ports.StoreChange) error {
705705 state.mu.Lock()
706- defer state.mu.Unlock()
706+ deleted := false
707707 for _, change := range changes {
708708 key := string(change.Key)
709709 if change.Delete {
710710 state.dels = append(state.dels, key)
711711 if state.deleteErr != nil {
712712 if err := state.deleteErr(key); err != nil {
713+ state.mu.Unlock()
713714 return err
714715 }
715716 }
716717 delete(state.data, key)
718+ deleted = true
717719 continue
718720 }
719721 state.sets++
720722 state.data[key] = append([]byte(nil), change.Value...)
721723 }
724+ var deleteDone chan struct{}
725+ if deleted {
726+ deleteDone = state.deleteDone
727+ state.deleteDone = nil
728+ }
729+ state.mu.Unlock()
730+ if deleteDone != nil {
731+ close(deleteDone)
732+ }
722733 return nil
723734 }).Maybe()
724735 store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {
@@ -814,6 +825,18 @@ func (s newStaticStore) Range(fn func(k, v []byte) bool) {
814825 func (s newStaticStore) Sync() error { return nil }
815826 func (s newStaticStore) Close() error { return nil }
816827
828+func TestNamedSessionCreationIsAllowedWithNilStore(t *testing.T) {
829+ pty, release := newBlockingPTY(t)
830+ defer release()
831+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
832+ WithStore(nil)(d)
833+
834+ sess, err := d.createSessionLocked("work", false, "/tmp/work", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
835+ require.NoError(t, err)
836+ require.Equal(t, "work", sess.name)
837+ require.False(t, d.persistEnabled)
838+}
839+
817840 func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
818841 store, _ := newMockStore(t)
819842 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
@@ -703,22 +703,33 @@ func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
703 }).Maybe()703 }).Maybe()
704 store.EXPECT().Batch(mock.Anything).RunAndReturn(func(changes []ports.StoreChange) error {704 store.EXPECT().Batch(mock.Anything).RunAndReturn(func(changes []ports.StoreChange) error {
705 state.mu.Lock()705 state.mu.Lock()
706- defer state.mu.Unlock()706+ deleted := false
707 for _, change := range changes {707 for _, change := range changes {
708 key := string(change.Key)708 key := string(change.Key)
709 if change.Delete {709 if change.Delete {
710 state.dels = append(state.dels, key)710 state.dels = append(state.dels, key)
711 if state.deleteErr != nil {711 if state.deleteErr != nil {
712 if err := state.deleteErr(key); err != nil {712 if err := state.deleteErr(key); err != nil {
713+ state.mu.Unlock()
713 return err714 return err
714 }715 }
715 }716 }
716 delete(state.data, key)717 delete(state.data, key)
718+ deleted = true
717 continue719 continue
718 }720 }
719 state.sets++721 state.sets++
720 state.data[key] = append([]byte(nil), change.Value...)722 state.data[key] = append([]byte(nil), change.Value...)
721 }723 }
724+ var deleteDone chan struct{}
725+ if deleted {
726+ deleteDone = state.deleteDone
727+ state.deleteDone = nil
728+ }
729+ state.mu.Unlock()
730+ if deleteDone != nil {
731+ close(deleteDone)
732+ }
722 return nil733 return nil
723 }).Maybe()734 }).Maybe()
724 store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {735 store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {
@@ -814,6 +825,18 @@ func (s newStaticStore) Range(fn func(k, v []byte) bool) {
814 func (s newStaticStore) Sync() error { return nil }825 func (s newStaticStore) Sync() error { return nil }
815 func (s newStaticStore) Close() error { return nil }826 func (s newStaticStore) Close() error { return nil }
816 827
828+func TestNamedSessionCreationIsAllowedWithNilStore(t *testing.T) {
829+ pty, release := newBlockingPTY(t)
830+ defer release()
831+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
832+ WithStore(nil)(d)
833+
834+ sess, err := d.createSessionLocked("work", false, "/tmp/work", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
835+ require.NoError(t, err)
836+ require.Equal(t, "work", sess.name)
837+ require.False(t, d.persistEnabled)
838+}
839+
817 func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {840 func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
818 store, _ := newMockStore(t)841 store, _ := newMockStore(t)
819 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))842 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
M pkg/kv/kv.go +26 -15
@@ -27,6 +27,9 @@ type ReplayResult struct {
2727
2828 type fsHooks struct {
2929 Open func(string, int, os.FileMode) (*os.File, error)
30+ Write func(*os.File, []byte) (int, error)
31+ Sync func(*os.File) error
32+ Close func(*os.File) error
3033 Remove func(string) error
3134 Rename func(string, string) error
3235 SyncDir func(string) error
@@ -34,7 +37,15 @@ type fsHooks struct {
3437 }
3538
3639 func defaultFSHooks() fsHooks {
37- return fsHooks{Open: os.OpenFile, Remove: os.Remove, Rename: os.Rename, SyncDir: syncDir}
40+ return fsHooks{
41+ Open: os.OpenFile,
42+ Write: func(f *os.File, p []byte) (int, error) { return f.Write(p) },
43+ Sync: func(f *os.File) error { return f.Sync() },
44+ Close: func(f *os.File) error { return f.Close() },
45+ Remove: os.Remove,
46+ Rename: os.Rename,
47+ SyncDir: syncDir,
48+ }
3849 }
3950
4051 // Store is an append-only WAL-backed key/value store.
@@ -263,11 +274,11 @@ func (s *Store) compactLocked() (retErr error) {
263274 for _, key := range keys {
264275 buf, err := encodeRecord(opSet, []byte(key), s.data[key])
265276 if err != nil {
266- _ = f.Close()
277+ _ = s.hooks.Close(f)
267278 return err
268279 }
269- if n, err := f.Write(buf); err != nil || n != len(buf) {
270- _ = f.Close()
280+ if n, err := s.hooks.Write(f, buf); err != nil || n != len(buf) {
281+ _ = s.hooks.Close(f)
271282 if err == nil {
272283 err = io.ErrShortWrite
273284 }
@@ -275,22 +286,22 @@ func (s *Store) compactLocked() (retErr error) {
275286 }
276287 }
277288 if err := injectFault(s.hooks, "after-write-next"); err != nil {
278- _ = f.Close()
289+ _ = s.hooks.Close(f)
279290 return err
280291 }
281292 if err := injectFault(s.hooks, "before-sync-next"); err != nil {
282- _ = f.Close()
293+ _ = s.hooks.Close(f)
283294 return err
284295 }
285- if err := f.Sync(); err != nil {
286- _ = f.Close()
296+ if err := s.hooks.Sync(f); err != nil {
297+ _ = s.hooks.Close(f)
287298 return err
288299 }
289300 if err := injectFault(s.hooks, "after-sync-next"); err != nil {
290- _ = f.Close()
301+ _ = s.hooks.Close(f)
291302 return err
292303 }
293- if err := f.Close(); err != nil {
304+ if err := s.hooks.Close(f); err != nil {
294305 return err
295306 }
296307 if err := validatePath(next); err != nil {
@@ -341,25 +352,25 @@ func (s *Store) compactLocked() (retErr error) {
341352 return err
342353 }
343354 if err := injectFault(s.hooks, "after-reopen-current"); err != nil {
344- _ = current.Close()
355+ _ = s.hooks.Close(current)
345356 return err
346357 }
347358 result, err := replayFile(current)
348359 if err != nil || result.TornTail {
349- _ = current.Close()
360+ _ = s.hooks.Close(current)
350361 if err == nil {
351362 err = ErrCorruptWAL
352363 }
353364 return err
354365 }
355366 if _, err := current.Seek(0, io.SeekEnd); err != nil {
356- _ = current.Close()
367+ _ = s.hooks.Close(current)
357368 return err
358369 }
359370 old := s.file
360371 s.file = current
361372 s.data, s.entrySize, s.total, s.live = result.Data, result.EntrySize, result.Total, result.Live
362- if err := old.Close(); err != nil {
373+ if err := s.hooks.Close(old); err != nil {
363374 return err
364375 }
365376 if err := removeIfExists(s.hooks, prev); err != nil {
@@ -386,7 +397,7 @@ func (s *Store) poisonLocked() error {
386397 s.file, s.lockFile = nil, nil
387398 var fileErr error
388399 if file != nil {
389- fileErr = file.Close()
400+ fileErr = s.hooks.Close(file)
390401 }
391402 return errors.Join(fileErr, releaseLock(lockFile))
392403 }
@@ -27,6 +27,9 @@ type ReplayResult struct {
27 27
28 type fsHooks struct {28 type fsHooks struct {
29 Open func(string, int, os.FileMode) (*os.File, error)29 Open func(string, int, os.FileMode) (*os.File, error)
30+ Write func(*os.File, []byte) (int, error)
31+ Sync func(*os.File) error
32+ Close func(*os.File) error
30 Remove func(string) error33 Remove func(string) error
31 Rename func(string, string) error34 Rename func(string, string) error
32 SyncDir func(string) error35 SyncDir func(string) error
@@ -34,7 +37,15 @@ type fsHooks struct {
34 }37 }
35 38
36 func defaultFSHooks() fsHooks {39 func defaultFSHooks() fsHooks {
37- return fsHooks{Open: os.OpenFile, Remove: os.Remove, Rename: os.Rename, SyncDir: syncDir}40+ return fsHooks{
41+ Open: os.OpenFile,
42+ Write: func(f *os.File, p []byte) (int, error) { return f.Write(p) },
43+ Sync: func(f *os.File) error { return f.Sync() },
44+ Close: func(f *os.File) error { return f.Close() },
45+ Remove: os.Remove,
46+ Rename: os.Rename,
47+ SyncDir: syncDir,
48+ }
38 }49 }
39 50
40 // Store is an append-only WAL-backed key/value store.51 // Store is an append-only WAL-backed key/value store.
@@ -263,11 +274,11 @@ func (s *Store) compactLocked() (retErr error) {
263 for _, key := range keys {274 for _, key := range keys {
264 buf, err := encodeRecord(opSet, []byte(key), s.data[key])275 buf, err := encodeRecord(opSet, []byte(key), s.data[key])
265 if err != nil {276 if err != nil {
266- _ = f.Close()277+ _ = s.hooks.Close(f)
267 return err278 return err
268 }279 }
269- if n, err := f.Write(buf); err != nil || n != len(buf) {280+ if n, err := s.hooks.Write(f, buf); err != nil || n != len(buf) {
270- _ = f.Close()281+ _ = s.hooks.Close(f)
271 if err == nil {282 if err == nil {
272 err = io.ErrShortWrite283 err = io.ErrShortWrite
273 }284 }
@@ -275,22 +286,22 @@ func (s *Store) compactLocked() (retErr error) {
275 }286 }
276 }287 }
277 if err := injectFault(s.hooks, "after-write-next"); err != nil {288 if err := injectFault(s.hooks, "after-write-next"); err != nil {
278- _ = f.Close()289+ _ = s.hooks.Close(f)
279 return err290 return err
280 }291 }
281 if err := injectFault(s.hooks, "before-sync-next"); err != nil {292 if err := injectFault(s.hooks, "before-sync-next"); err != nil {
282- _ = f.Close()293+ _ = s.hooks.Close(f)
283 return err294 return err
284 }295 }
285- if err := f.Sync(); err != nil {296+ if err := s.hooks.Sync(f); err != nil {
286- _ = f.Close()297+ _ = s.hooks.Close(f)
287 return err298 return err
288 }299 }
289 if err := injectFault(s.hooks, "after-sync-next"); err != nil {300 if err := injectFault(s.hooks, "after-sync-next"); err != nil {
290- _ = f.Close()301+ _ = s.hooks.Close(f)
291 return err302 return err
292 }303 }
293- if err := f.Close(); err != nil {304+ if err := s.hooks.Close(f); err != nil {
294 return err305 return err
295 }306 }
296 if err := validatePath(next); err != nil {307 if err := validatePath(next); err != nil {
@@ -341,25 +352,25 @@ func (s *Store) compactLocked() (retErr error) {
341 return err352 return err
342 }353 }
343 if err := injectFault(s.hooks, "after-reopen-current"); err != nil {354 if err := injectFault(s.hooks, "after-reopen-current"); err != nil {
344- _ = current.Close()355+ _ = s.hooks.Close(current)
345 return err356 return err
346 }357 }
347 result, err := replayFile(current)358 result, err := replayFile(current)
348 if err != nil || result.TornTail {359 if err != nil || result.TornTail {
349- _ = current.Close()360+ _ = s.hooks.Close(current)
350 if err == nil {361 if err == nil {
351 err = ErrCorruptWAL362 err = ErrCorruptWAL
352 }363 }
353 return err364 return err
354 }365 }
355 if _, err := current.Seek(0, io.SeekEnd); err != nil {366 if _, err := current.Seek(0, io.SeekEnd); err != nil {
356- _ = current.Close()367+ _ = s.hooks.Close(current)
357 return err368 return err
358 }369 }
359 old := s.file370 old := s.file
360 s.file = current371 s.file = current
361 s.data, s.entrySize, s.total, s.live = result.Data, result.EntrySize, result.Total, result.Live372 s.data, s.entrySize, s.total, s.live = result.Data, result.EntrySize, result.Total, result.Live
362- if err := old.Close(); err != nil {373+ if err := s.hooks.Close(old); err != nil {
363 return err374 return err
364 }375 }
365 if err := removeIfExists(s.hooks, prev); err != nil {376 if err := removeIfExists(s.hooks, prev); err != nil {
@@ -386,7 +397,7 @@ func (s *Store) poisonLocked() error {
386 s.file, s.lockFile = nil, nil397 s.file, s.lockFile = nil, nil
387 var fileErr error398 var fileErr error
388 if file != nil {399 if file != nil {
389- fileErr = file.Close()400+ fileErr = s.hooks.Close(file)
390 }401 }
391 return errors.Join(fileErr, releaseLock(lockFile))402 return errors.Join(fileErr, releaseLock(lockFile))
392 }403 }
M pkg/kv/kv_test.go +145 -0
@@ -416,6 +416,151 @@ func TestCompactionRecoveryMatrix(t *testing.T) {
416416 }
417417 }
418418
419+func TestCompactionFilesystemOperationFailuresPoisonAndRecover(t *testing.T) {
420+ fault := errors.New("injected filesystem failure")
421+ tests := []struct {
422+ name string
423+ configure func(fsHooks, string) fsHooks
424+ }{
425+ {name: "write-next", configure: func(h fsHooks, _ string) fsHooks {
426+ h.Write = func(*os.File, []byte) (int, error) { return 0, fault }
427+ return h
428+ }},
429+ {name: "sync-next", configure: func(h fsHooks, _ string) fsHooks {
430+ h.Sync = func(*os.File) error { return fault }
431+ return h
432+ }},
433+ {name: "close-next", configure: func(h fsHooks, _ string) fsHooks {
434+ calls := 0
435+ h.Close = func(f *os.File) error {
436+ calls++
437+ if calls == 1 {
438+ return errors.Join(f.Close(), fault)
439+ }
440+ return f.Close()
441+ }
442+ return h
443+ }},
444+ {name: "remove-stale-prev", configure: failRemoveCall(fault, 1)},
445+ {name: "rename-current-prev", configure: failRenameCall(fault, 1)},
446+ {name: "sync-dir-next", configure: failSyncDirCall(fault, 1)},
447+ {name: "sync-dir-prev", configure: failSyncDirCall(fault, 2)},
448+ {name: "rename-next-current", configure: failRenameCall(fault, 2)},
449+ {name: "sync-dir-current", configure: failSyncDirCall(fault, 3)},
450+ {name: "reopen-current", configure: func(h fsHooks, path string) fsHooks {
451+ open := h.Open
452+ h.Open = func(name string, flags int, mode os.FileMode) (*os.File, error) {
453+ if name == path && flags == os.O_RDWR {
454+ return nil, fault
455+ }
456+ return open(name, flags, mode)
457+ }
458+ return h
459+ }},
460+ {name: "close-old-current", configure: func(h fsHooks, _ string) fsHooks {
461+ calls := 0
462+ h.Close = func(f *os.File) error {
463+ calls++
464+ if calls == 2 {
465+ return errors.Join(f.Close(), fault)
466+ }
467+ return f.Close()
468+ }
469+ return h
470+ }},
471+ {name: "remove-prev", configure: failRemoveCall(fault, 2)},
472+ {name: "sync-dir-final", configure: failSyncDirCall(fault, 4)},
473+ }
474+ oldRecord, err := encodeRecord(opSet, []byte("version"), []byte("old"))
475+ if err != nil {
476+ t.Fatal(err)
477+ }
478+ for _, tt := range tests {
479+ t.Run(tt.name, func(t *testing.T) {
480+ path := privatePath(t)
481+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
482+ t.Fatal(err)
483+ }
484+ if err := os.WriteFile(path, oldRecord, 0o600); err != nil {
485+ t.Fatal(err)
486+ }
487+ s, err := Open(path)
488+ if err != nil {
489+ t.Fatal(err)
490+ }
491+ s.data = map[string][]byte{"version": []byte("new")}
492+ s.hooks = tt.configure(s.hooks, path)
493+
494+ err = s.compactLocked()
495+ if !errors.Is(err, fault) {
496+ t.Fatalf("compaction error = %v, want injected fault", err)
497+ }
498+ beforeAppend := candidateBytes(t, path)
499+ if err := s.Set([]byte("later"), []byte("must-not-append")); !errors.Is(err, os.ErrClosed) {
500+ t.Fatalf("append after compaction fault = %v, want closed store", err)
501+ }
502+ requireCandidateBytes(t, path, beforeAppend)
503+
504+ if err := recoverCompaction(path, defaultFSHooks()); err != nil {
505+ t.Fatalf("recovery: %v", err)
506+ }
507+ got, err := Replay(path)
508+ if err != nil {
509+ t.Fatal(err)
510+ }
511+ version := string(got["version"])
512+ if (version != "old" && version != "new") || len(got) != 1 {
513+ t.Fatalf("mixed or empty recovered map: %#v", got)
514+ }
515+ })
516+ }
517+}
518+
519+func failRemoveCall(fault error, target int) func(fsHooks, string) fsHooks {
520+ return func(h fsHooks, _ string) fsHooks {
521+ remove := h.Remove
522+ calls := 0
523+ h.Remove = func(path string) error {
524+ calls++
525+ if calls == target {
526+ return fault
527+ }
528+ return remove(path)
529+ }
530+ return h
531+ }
532+}
533+
534+func failRenameCall(fault error, target int) func(fsHooks, string) fsHooks {
535+ return func(h fsHooks, _ string) fsHooks {
536+ rename := h.Rename
537+ calls := 0
538+ h.Rename = func(oldPath, newPath string) error {
539+ calls++
540+ if calls == target {
541+ return fault
542+ }
543+ return rename(oldPath, newPath)
544+ }
545+ return h
546+ }
547+}
548+
549+func failSyncDirCall(fault error, target int) func(fsHooks, string) fsHooks {
550+ return func(h fsHooks, _ string) fsHooks {
551+ sync := h.SyncDir
552+ calls := 0
553+ h.SyncDir = func(path string) error {
554+ calls++
555+ if calls == target {
556+ return fault
557+ }
558+ return sync(path)
559+ }
560+ return h
561+ }
562+}
563+
419564 func candidateBytes(t *testing.T, path string) map[string][]byte {
420565 t.Helper()
421566 got := make(map[string][]byte, 3)
@@ -416,6 +416,151 @@ func TestCompactionRecoveryMatrix(t *testing.T) {
416 }416 }
417 }417 }
418 418
419+func TestCompactionFilesystemOperationFailuresPoisonAndRecover(t *testing.T) {
420+ fault := errors.New("injected filesystem failure")
421+ tests := []struct {
422+ name string
423+ configure func(fsHooks, string) fsHooks
424+ }{
425+ {name: "write-next", configure: func(h fsHooks, _ string) fsHooks {
426+ h.Write = func(*os.File, []byte) (int, error) { return 0, fault }
427+ return h
428+ }},
429+ {name: "sync-next", configure: func(h fsHooks, _ string) fsHooks {
430+ h.Sync = func(*os.File) error { return fault }
431+ return h
432+ }},
433+ {name: "close-next", configure: func(h fsHooks, _ string) fsHooks {
434+ calls := 0
435+ h.Close = func(f *os.File) error {
436+ calls++
437+ if calls == 1 {
438+ return errors.Join(f.Close(), fault)
439+ }
440+ return f.Close()
441+ }
442+ return h
443+ }},
444+ {name: "remove-stale-prev", configure: failRemoveCall(fault, 1)},
445+ {name: "rename-current-prev", configure: failRenameCall(fault, 1)},
446+ {name: "sync-dir-next", configure: failSyncDirCall(fault, 1)},
447+ {name: "sync-dir-prev", configure: failSyncDirCall(fault, 2)},
448+ {name: "rename-next-current", configure: failRenameCall(fault, 2)},
449+ {name: "sync-dir-current", configure: failSyncDirCall(fault, 3)},
450+ {name: "reopen-current", configure: func(h fsHooks, path string) fsHooks {
451+ open := h.Open
452+ h.Open = func(name string, flags int, mode os.FileMode) (*os.File, error) {
453+ if name == path && flags == os.O_RDWR {
454+ return nil, fault
455+ }
456+ return open(name, flags, mode)
457+ }
458+ return h
459+ }},
460+ {name: "close-old-current", configure: func(h fsHooks, _ string) fsHooks {
461+ calls := 0
462+ h.Close = func(f *os.File) error {
463+ calls++
464+ if calls == 2 {
465+ return errors.Join(f.Close(), fault)
466+ }
467+ return f.Close()
468+ }
469+ return h
470+ }},
471+ {name: "remove-prev", configure: failRemoveCall(fault, 2)},
472+ {name: "sync-dir-final", configure: failSyncDirCall(fault, 4)},
473+ }
474+ oldRecord, err := encodeRecord(opSet, []byte("version"), []byte("old"))
475+ if err != nil {
476+ t.Fatal(err)
477+ }
478+ for _, tt := range tests {
479+ t.Run(tt.name, func(t *testing.T) {
480+ path := privatePath(t)
481+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
482+ t.Fatal(err)
483+ }
484+ if err := os.WriteFile(path, oldRecord, 0o600); err != nil {
485+ t.Fatal(err)
486+ }
487+ s, err := Open(path)
488+ if err != nil {
489+ t.Fatal(err)
490+ }
491+ s.data = map[string][]byte{"version": []byte("new")}
492+ s.hooks = tt.configure(s.hooks, path)
493+
494+ err = s.compactLocked()
495+ if !errors.Is(err, fault) {
496+ t.Fatalf("compaction error = %v, want injected fault", err)
497+ }
498+ beforeAppend := candidateBytes(t, path)
499+ if err := s.Set([]byte("later"), []byte("must-not-append")); !errors.Is(err, os.ErrClosed) {
500+ t.Fatalf("append after compaction fault = %v, want closed store", err)
501+ }
502+ requireCandidateBytes(t, path, beforeAppend)
503+
504+ if err := recoverCompaction(path, defaultFSHooks()); err != nil {
505+ t.Fatalf("recovery: %v", err)
506+ }
507+ got, err := Replay(path)
508+ if err != nil {
509+ t.Fatal(err)
510+ }
511+ version := string(got["version"])
512+ if (version != "old" && version != "new") || len(got) != 1 {
513+ t.Fatalf("mixed or empty recovered map: %#v", got)
514+ }
515+ })
516+ }
517+}
518+
519+func failRemoveCall(fault error, target int) func(fsHooks, string) fsHooks {
520+ return func(h fsHooks, _ string) fsHooks {
521+ remove := h.Remove
522+ calls := 0
523+ h.Remove = func(path string) error {
524+ calls++
525+ if calls == target {
526+ return fault
527+ }
528+ return remove(path)
529+ }
530+ return h
531+ }
532+}
533+
534+func failRenameCall(fault error, target int) func(fsHooks, string) fsHooks {
535+ return func(h fsHooks, _ string) fsHooks {
536+ rename := h.Rename
537+ calls := 0
538+ h.Rename = func(oldPath, newPath string) error {
539+ calls++
540+ if calls == target {
541+ return fault
542+ }
543+ return rename(oldPath, newPath)
544+ }
545+ return h
546+ }
547+}
548+
549+func failSyncDirCall(fault error, target int) func(fsHooks, string) fsHooks {
550+ return func(h fsHooks, _ string) fsHooks {
551+ sync := h.SyncDir
552+ calls := 0
553+ h.SyncDir = func(path string) error {
554+ calls++
555+ if calls == target {
556+ return fault
557+ }
558+ return sync(path)
559+ }
560+ return h
561+ }
562+}
563+
419 func candidateBytes(t *testing.T, path string) map[string][]byte {564 func candidateBytes(t *testing.T, path string) map[string][]byte {
420 t.Helper()565 t.Helper()
421 got := make(map[string][]byte, 3)566 got := make(map[string][]byte, 3)

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 12

Tags 11

↑↓ moveenter openesc close