detached bd4a6ac

fix(app): make offline snapshot deletion retryable

commit
bd4a6ac8ab944c61f021dd1b40276abd3458df3a
author
brice · · 2026-07-21 23:01 UTC
parent
6c78be9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +323 -11
M internal/adapters/snapshot/legacy.go +90 -1
@@ -63,6 +63,13 @@ func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, er
6363 if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) {
6464 name = ""
6565 }
66+ killed, tombstoneErr := r.tombstoned(name)
67+ if tombstoneErr != nil {
68+ return nil, fmt.Errorf("read killed session marker: %w", safeFilesystemError(tombstoneErr))
69+ }
70+ if killed {
71+ continue
72+ }
6673 out = append(out, ports.LegacySnapshot{Name: name, Data: data})
6774 }
6875 if errors.Is(readErr, io.EOF) {
@@ -97,7 +104,17 @@ func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {
97104
98105 path := filepath.Join(r.dir, filename)
99106 if err := r.remove(path); errors.Is(err, os.ErrNotExist) {
100- return nil
107+ // Tombstones make a process-restart retry distinguish an already-unlinked
108+ // legacy source from a source that was absent before the kill. Re-sync the
109+ // root to complete the original unlink's durability boundary.
110+ killed, tombstoneErr := r.tombstoned(name)
111+ if tombstoneErr != nil {
112+ return fmt.Errorf("read killed session marker: %w", safeFilesystemError(tombstoneErr))
113+ }
114+ if !killed {
115+ return nil
116+ }
117+ return r.syncPendingLegacy(ctx, filename)
101118 } else if err != nil {
102119 return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err))
103120 }
@@ -105,6 +122,78 @@ func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {
105122 return r.syncPendingLegacy(ctx, filename)
106123 }
107124
125+// Tombstone durably marks name as logically killed before either snapshot
126+// source is deleted. Startup consults this marker before importing legacy or
127+// restoring incremental state, so a partial offline kill cannot resurrect it.
128+func (r *Repository) Tombstone(ctx context.Context, name string) error {
129+ if err := ctx.Err(); err != nil {
130+ return err
131+ }
132+ key := sessionKey(name)
133+ lock := r.sessionLock(key)
134+ lock.Lock()
135+ defer lock.Unlock()
136+ if err := ctx.Err(); err != nil {
137+ return err
138+ }
139+ if err := r.ensurePrivateDirectory(r.dir); err != nil {
140+ return fmt.Errorf("create killed session marker directory: %w", safeFilesystemError(err))
141+ }
142+ path := filepath.Join(r.dir, tombstoneFilename(key))
143+ if err := r.writeImmutable(path, []byte(name), func(data []byte) error {
144+ if string(data) != name {
145+ return fmt.Errorf("killed session marker identity mismatch")
146+ }
147+ return nil
148+ }); err != nil {
149+ return fmt.Errorf("write killed session marker: %w", safeFilesystemError(err))
150+ }
151+ return nil
152+}
153+
154+// DeleteTombstone removes a marker only after both sources have been durably
155+// deleted. A missing marker is idempotent for offline-kill retries.
156+func (r *Repository) DeleteTombstone(ctx context.Context, name string) error {
157+ if err := ctx.Err(); err != nil {
158+ return err
159+ }
160+ key := sessionKey(name)
161+ lock := r.sessionLock(key)
162+ lock.Lock()
163+ defer lock.Unlock()
164+ if err := ctx.Err(); err != nil {
165+ return err
166+ }
167+ if err := r.remove(filepath.Join(r.dir, tombstoneFilename(key))); errors.Is(err, os.ErrNotExist) {
168+ return nil
169+ } else if err != nil {
170+ return fmt.Errorf("delete killed session marker: %w", safeFilesystemError(err))
171+ }
172+ if err := r.syncDirectory(r.dir); err != nil {
173+ return fmt.Errorf("sync killed session marker directory: %w", safeFilesystemError(err))
174+ }
175+ return nil
176+}
177+
178+func tombstoneFilename(key string) string { return ".killed-" + key }
179+
180+func (r *Repository) tombstoned(name string) (bool, error) {
181+ if name == "" {
182+ return false, nil
183+ }
184+ data, err := readBounded(filepath.Join(r.dir, tombstoneFilename(sessionKey(name))))
185+ if errors.Is(err, os.ErrNotExist) {
186+ return false, nil
187+ }
188+ if err != nil {
189+ return false, err
190+ }
191+ if string(data) != name {
192+ return false, fmt.Errorf("killed session marker identity mismatch")
193+ }
194+ return true, nil
195+}
196+
108197 func (r *Repository) syncPendingLegacy(ctx context.Context, filename string) error {
109198 if err := ctx.Err(); err != nil {
110199 return err
@@ -63,6 +63,13 @@ func (r *Repository) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, er
63 if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) {63 if strings.HasPrefix(name, "@") || !safeNameRE.MatchString(name) {
64 name = ""64 name = ""
65 }65 }
66+ killed, tombstoneErr := r.tombstoned(name)
67+ if tombstoneErr != nil {
68+ return nil, fmt.Errorf("read killed session marker: %w", safeFilesystemError(tombstoneErr))
69+ }
70+ if killed {
71+ continue
72+ }
66 out = append(out, ports.LegacySnapshot{Name: name, Data: data})73 out = append(out, ports.LegacySnapshot{Name: name, Data: data})
67 }74 }
68 if errors.Is(readErr, io.EOF) {75 if errors.Is(readErr, io.EOF) {
@@ -97,7 +104,17 @@ func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {
97 104
98 path := filepath.Join(r.dir, filename)105 path := filepath.Join(r.dir, filename)
99 if err := r.remove(path); errors.Is(err, os.ErrNotExist) {106 if err := r.remove(path); errors.Is(err, os.ErrNotExist) {
100- return nil107+ // Tombstones make a process-restart retry distinguish an already-unlinked
108+ // legacy source from a source that was absent before the kill. Re-sync the
109+ // root to complete the original unlink's durability boundary.
110+ killed, tombstoneErr := r.tombstoned(name)
111+ if tombstoneErr != nil {
112+ return fmt.Errorf("read killed session marker: %w", safeFilesystemError(tombstoneErr))
113+ }
114+ if !killed {
115+ return nil
116+ }
117+ return r.syncPendingLegacy(ctx, filename)
101 } else if err != nil {118 } else if err != nil {
102 return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err))119 return fmt.Errorf("delete legacy snapshot: %w", safeFilesystemError(err))
103 }120 }
@@ -105,6 +122,78 @@ func (r *Repository) DeleteLegacy(ctx context.Context, name string) error {
105 return r.syncPendingLegacy(ctx, filename)122 return r.syncPendingLegacy(ctx, filename)
106 }123 }
107 124
125+// Tombstone durably marks name as logically killed before either snapshot
126+// source is deleted. Startup consults this marker before importing legacy or
127+// restoring incremental state, so a partial offline kill cannot resurrect it.
128+func (r *Repository) Tombstone(ctx context.Context, name string) error {
129+ if err := ctx.Err(); err != nil {
130+ return err
131+ }
132+ key := sessionKey(name)
133+ lock := r.sessionLock(key)
134+ lock.Lock()
135+ defer lock.Unlock()
136+ if err := ctx.Err(); err != nil {
137+ return err
138+ }
139+ if err := r.ensurePrivateDirectory(r.dir); err != nil {
140+ return fmt.Errorf("create killed session marker directory: %w", safeFilesystemError(err))
141+ }
142+ path := filepath.Join(r.dir, tombstoneFilename(key))
143+ if err := r.writeImmutable(path, []byte(name), func(data []byte) error {
144+ if string(data) != name {
145+ return fmt.Errorf("killed session marker identity mismatch")
146+ }
147+ return nil
148+ }); err != nil {
149+ return fmt.Errorf("write killed session marker: %w", safeFilesystemError(err))
150+ }
151+ return nil
152+}
153+
154+// DeleteTombstone removes a marker only after both sources have been durably
155+// deleted. A missing marker is idempotent for offline-kill retries.
156+func (r *Repository) DeleteTombstone(ctx context.Context, name string) error {
157+ if err := ctx.Err(); err != nil {
158+ return err
159+ }
160+ key := sessionKey(name)
161+ lock := r.sessionLock(key)
162+ lock.Lock()
163+ defer lock.Unlock()
164+ if err := ctx.Err(); err != nil {
165+ return err
166+ }
167+ if err := r.remove(filepath.Join(r.dir, tombstoneFilename(key))); errors.Is(err, os.ErrNotExist) {
168+ return nil
169+ } else if err != nil {
170+ return fmt.Errorf("delete killed session marker: %w", safeFilesystemError(err))
171+ }
172+ if err := r.syncDirectory(r.dir); err != nil {
173+ return fmt.Errorf("sync killed session marker directory: %w", safeFilesystemError(err))
174+ }
175+ return nil
176+}
177+
178+func tombstoneFilename(key string) string { return ".killed-" + key }
179+
180+func (r *Repository) tombstoned(name string) (bool, error) {
181+ if name == "" {
182+ return false, nil
183+ }
184+ data, err := readBounded(filepath.Join(r.dir, tombstoneFilename(sessionKey(name))))
185+ if errors.Is(err, os.ErrNotExist) {
186+ return false, nil
187+ }
188+ if err != nil {
189+ return false, err
190+ }
191+ if string(data) != name {
192+ return false, fmt.Errorf("killed session marker identity mismatch")
193+ }
194+ return true, nil
195+}
196+
108 func (r *Repository) syncPendingLegacy(ctx context.Context, filename string) error {197 func (r *Repository) syncPendingLegacy(ctx context.Context, filename string) error {
109 if err := ctx.Err(); err != nil {198 if err := ctx.Err(); err != nil {
110 return err199 return err
M internal/adapters/snapshot/load.go +14 -0
@@ -46,6 +46,13 @@ func (r *Repository) List(ctx context.Context) ([]string, error) {
4646 if err != nil || sessionKey(manifest.Name) != entry.Name() {
4747 continue
4848 }
49+ killed, tombstoneErr := r.tombstoned(manifest.Name)
50+ if tombstoneErr != nil {
51+ return nil, fmt.Errorf("read killed session marker: %w", tombstoneErr)
52+ }
53+ if killed {
54+ break
55+ }
4956 generation, err := r.loadGeneration(ctx, manifest.Name, entry.Name(), number)
5057 if err == nil {
5158 out = append(out, generation.Name)
@@ -62,6 +69,13 @@ func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGener
6269 return ports.SnapshotGeneration{}, err
6370 }
6471 key := sessionKey(name)
72+ killed, err := r.tombstoned(name)
73+ if err != nil {
74+ return ports.SnapshotGeneration{}, fmt.Errorf("read killed session marker: %w", err)
75+ }
76+ if killed {
77+ return ports.SnapshotGeneration{}, fmt.Errorf("snapshot session %q is killed", name)
78+ }
6579 lock := r.sessionLock(key)
6680 lock.Lock()
6781 defer lock.Unlock()
@@ -46,6 +46,13 @@ func (r *Repository) List(ctx context.Context) ([]string, error) {
46 if err != nil || sessionKey(manifest.Name) != entry.Name() {46 if err != nil || sessionKey(manifest.Name) != entry.Name() {
47 continue47 continue
48 }48 }
49+ killed, tombstoneErr := r.tombstoned(manifest.Name)
50+ if tombstoneErr != nil {
51+ return nil, fmt.Errorf("read killed session marker: %w", tombstoneErr)
52+ }
53+ if killed {
54+ break
55+ }
49 generation, err := r.loadGeneration(ctx, manifest.Name, entry.Name(), number)56 generation, err := r.loadGeneration(ctx, manifest.Name, entry.Name(), number)
50 if err == nil {57 if err == nil {
51 out = append(out, generation.Name)58 out = append(out, generation.Name)
@@ -62,6 +69,13 @@ func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGener
62 return ports.SnapshotGeneration{}, err69 return ports.SnapshotGeneration{}, err
63 }70 }
64 key := sessionKey(name)71 key := sessionKey(name)
72+ killed, err := r.tombstoned(name)
73+ if err != nil {
74+ return ports.SnapshotGeneration{}, fmt.Errorf("read killed session marker: %w", err)
75+ }
76+ if killed {
77+ return ports.SnapshotGeneration{}, fmt.Errorf("snapshot session %q is killed", name)
78+ }
65 lock := r.sessionLock(key)79 lock := r.sessionLock(key)
66 lock.Lock()80 lock.Lock()
67 defer lock.Unlock()81 defer lock.Unlock()
A internal/adapters/snapshot/offline_kill_tombstone_test.go +53 -0
@@ -0,0 +1,53 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestRepositoryTombstoneBlocksIncrementalRestoreAndLegacyImport(t *testing.T) {
13+ repo := NewRepository(privateDir(t))
14+ require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
15+ require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600))
16+
17+ require.NoError(t, repo.Tombstone(context.Background(), "named"))
18+
19+ names, err := repo.List(context.Background())
20+ require.NoError(t, err)
21+ require.NotContains(t, names, "named")
22+ legacy, err := repo.LoadLegacy(context.Background())
23+ require.NoError(t, err)
24+ for _, snapshot := range legacy {
25+ require.NotEqual(t, "named", snapshot.Name)
26+ }
27+}
28+
29+func TestRepositoryDeleteLegacyRetriesDurabilityAfterTombstone(t *testing.T) {
30+ repo := NewRepository(privateDir(t))
31+ require.NoError(t, os.Mkdir(repo.dir, 0o700))
32+ require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600))
33+ require.NoError(t, repo.Tombstone(context.Background(), "named"))
34+
35+ calls := 0
36+ repo.hooks.syncDirectory = func(string) error {
37+ calls++
38+ if calls == 1 {
39+ return os.ErrPermission
40+ }
41+ return nil
42+ }
43+ require.ErrorIs(t, repo.DeleteLegacy(context.Background(), "named"), os.ErrPermission)
44+
45+ // A retry after process restart has no in-memory pending-sync state.
46+ repo = NewRepository(repo.dir)
47+ repo.hooks.syncDirectory = func(string) error {
48+ calls++
49+ return nil
50+ }
51+ require.NoError(t, repo.DeleteLegacy(context.Background(), "named"))
52+ require.Equal(t, 2, calls, "a tombstoned missing legacy source must resync its unlink")
53+}
@@ -0,0 +1,53 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestRepositoryTombstoneBlocksIncrementalRestoreAndLegacyImport(t *testing.T) {
13+ repo := NewRepository(privateDir(t))
14+ require.NoError(t, repo.Publish(context.Background(), repositoryPublication(t, "named", 1, []byte("state"))))
15+ require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600))
16+
17+ require.NoError(t, repo.Tombstone(context.Background(), "named"))
18+
19+ names, err := repo.List(context.Background())
20+ require.NoError(t, err)
21+ require.NotContains(t, names, "named")
22+ legacy, err := repo.LoadLegacy(context.Background())
23+ require.NoError(t, err)
24+ for _, snapshot := range legacy {
25+ require.NotEqual(t, "named", snapshot.Name)
26+ }
27+}
28+
29+func TestRepositoryDeleteLegacyRetriesDurabilityAfterTombstone(t *testing.T) {
30+ repo := NewRepository(privateDir(t))
31+ require.NoError(t, os.Mkdir(repo.dir, 0o700))
32+ require.NoError(t, os.WriteFile(filepath.Join(repo.dir, filenameForName("named")), []byte("legacy"), 0o600))
33+ require.NoError(t, repo.Tombstone(context.Background(), "named"))
34+
35+ calls := 0
36+ repo.hooks.syncDirectory = func(string) error {
37+ calls++
38+ if calls == 1 {
39+ return os.ErrPermission
40+ }
41+ return nil
42+ }
43+ require.ErrorIs(t, repo.DeleteLegacy(context.Background(), "named"), os.ErrPermission)
44+
45+ // A retry after process restart has no in-memory pending-sync state.
46+ repo = NewRepository(repo.dir)
47+ repo.hooks.syncDirectory = func(string) error {
48+ calls++
49+ return nil
50+ }
51+ require.NoError(t, repo.DeleteLegacy(context.Background(), "named"))
52+ require.Equal(t, 2, calls, "a tombstoned missing legacy source must resync its unlink")
53+}
A internal/app/offline_kill_retry_test.go +124 -0
@@ -0,0 +1,124 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/persist"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type offlineSnapshotFake struct {
15+ calls []string
16+ deleteErr error
17+ deleteLegacyErr error
18+ tombstoneErr error
19+ deleteTombstoneErr error
20+}
21+
22+func (f *offlineSnapshotFake) Tombstone(context.Context, string) error {
23+ f.calls = append(f.calls, "tombstone")
24+ return f.tombstoneErr
25+}
26+
27+func (f *offlineSnapshotFake) Delete(context.Context, string) error {
28+ f.calls = append(f.calls, "incremental")
29+ return f.deleteErr
30+}
31+
32+func (f *offlineSnapshotFake) DeleteLegacy(context.Context, string) error {
33+ f.calls = append(f.calls, "legacy")
34+ return f.deleteLegacyErr
35+}
36+
37+func (f *offlineSnapshotFake) DeleteTombstone(context.Context, string) error {
38+ f.calls = append(f.calls, "delete tombstone")
39+ return f.deleteTombstoneErr
40+}
41+
42+func TestRunKillOfflineRetainsTombstoneAndMetadataUntilBothSourcesAreDeleted(t *testing.T) {
43+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
44+ t.Setenv("XDG_STATE_HOME", stateRoot)
45+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
46+
47+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
48+ require.NoError(t, err)
49+ now := time.Now().UnixNano()
50+ require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
51+ require.NoError(t, p.Close())
52+
53+ injected := errors.New("legacy unlink failed")
54+ fake := &offlineSnapshotFake{deleteLegacyErr: injected}
55+ originalFactory := newOfflineSnapshotRepository
56+ newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake }
57+ t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory })
58+
59+ err = runKill(context.Background(), "named", false, false)
60+ require.ErrorIs(t, err, injected)
61+ require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls)
62+ records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
63+ require.NoError(t, err)
64+ require.Len(t, records, 1, "a failed source delete must retain retry identity")
65+
66+ fake.deleteLegacyErr = nil
67+ require.NoError(t, runKill(context.Background(), "named", false, false))
68+ require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "delete tombstone"}, fake.calls)
69+ records, err = persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
70+ require.NoError(t, err)
71+ require.Empty(t, records)
72+}
73+
74+func TestRunKillOfflineRetriesAfterIncrementalDeleteFailure(t *testing.T) {
75+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
76+ t.Setenv("XDG_STATE_HOME", stateRoot)
77+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
78+
79+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
80+ require.NoError(t, err)
81+ now := time.Now().UnixNano()
82+ require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
83+ require.NoError(t, p.Close())
84+
85+ injected := errors.New("incremental quarantine failed")
86+ fake := &offlineSnapshotFake{deleteErr: injected}
87+ originalFactory := newOfflineSnapshotRepository
88+ newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake }
89+ t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory })
90+
91+ require.ErrorIs(t, runKill(context.Background(), "named", false, false), injected)
92+ require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls)
93+
94+ fake.deleteErr = nil
95+ require.NoError(t, runKill(context.Background(), "named", false, false))
96+ require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "delete tombstone"}, fake.calls)
97+ records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
98+ require.NoError(t, err)
99+ require.Empty(t, records)
100+}
101+
102+func TestRunKillOfflineReportsBothSnapshotDeletionFailures(t *testing.T) {
103+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
104+ t.Setenv("XDG_STATE_HOME", stateRoot)
105+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
106+
107+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
108+ require.NoError(t, err)
109+ now := time.Now().UnixNano()
110+ require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
111+ require.NoError(t, p.Close())
112+
113+ incrementalErr := errors.New("incremental quarantine failed")
114+ legacyErr := errors.New("legacy unlink failed")
115+ fake := &offlineSnapshotFake{deleteErr: incrementalErr, deleteLegacyErr: legacyErr}
116+ originalFactory := newOfflineSnapshotRepository
117+ newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake }
118+ t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory })
119+
120+ err = runKill(context.Background(), "named", false, false)
121+ require.ErrorIs(t, err, incrementalErr)
122+ require.ErrorIs(t, err, legacyErr)
123+ require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls)
124+}
@@ -0,0 +1,124 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/persist"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type offlineSnapshotFake struct {
15+ calls []string
16+ deleteErr error
17+ deleteLegacyErr error
18+ tombstoneErr error
19+ deleteTombstoneErr error
20+}
21+
22+func (f *offlineSnapshotFake) Tombstone(context.Context, string) error {
23+ f.calls = append(f.calls, "tombstone")
24+ return f.tombstoneErr
25+}
26+
27+func (f *offlineSnapshotFake) Delete(context.Context, string) error {
28+ f.calls = append(f.calls, "incremental")
29+ return f.deleteErr
30+}
31+
32+func (f *offlineSnapshotFake) DeleteLegacy(context.Context, string) error {
33+ f.calls = append(f.calls, "legacy")
34+ return f.deleteLegacyErr
35+}
36+
37+func (f *offlineSnapshotFake) DeleteTombstone(context.Context, string) error {
38+ f.calls = append(f.calls, "delete tombstone")
39+ return f.deleteTombstoneErr
40+}
41+
42+func TestRunKillOfflineRetainsTombstoneAndMetadataUntilBothSourcesAreDeleted(t *testing.T) {
43+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
44+ t.Setenv("XDG_STATE_HOME", stateRoot)
45+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
46+
47+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
48+ require.NoError(t, err)
49+ now := time.Now().UnixNano()
50+ require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
51+ require.NoError(t, p.Close())
52+
53+ injected := errors.New("legacy unlink failed")
54+ fake := &offlineSnapshotFake{deleteLegacyErr: injected}
55+ originalFactory := newOfflineSnapshotRepository
56+ newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake }
57+ t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory })
58+
59+ err = runKill(context.Background(), "named", false, false)
60+ require.ErrorIs(t, err, injected)
61+ require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls)
62+ records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
63+ require.NoError(t, err)
64+ require.Len(t, records, 1, "a failed source delete must retain retry identity")
65+
66+ fake.deleteLegacyErr = nil
67+ require.NoError(t, runKill(context.Background(), "named", false, false))
68+ require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "delete tombstone"}, fake.calls)
69+ records, err = persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
70+ require.NoError(t, err)
71+ require.Empty(t, records)
72+}
73+
74+func TestRunKillOfflineRetriesAfterIncrementalDeleteFailure(t *testing.T) {
75+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
76+ t.Setenv("XDG_STATE_HOME", stateRoot)
77+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
78+
79+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
80+ require.NoError(t, err)
81+ now := time.Now().UnixNano()
82+ require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
83+ require.NoError(t, p.Close())
84+
85+ injected := errors.New("incremental quarantine failed")
86+ fake := &offlineSnapshotFake{deleteErr: injected}
87+ originalFactory := newOfflineSnapshotRepository
88+ newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake }
89+ t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory })
90+
91+ require.ErrorIs(t, runKill(context.Background(), "named", false, false), injected)
92+ require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls)
93+
94+ fake.deleteErr = nil
95+ require.NoError(t, runKill(context.Background(), "named", false, false))
96+ require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "delete tombstone"}, fake.calls)
97+ records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
98+ require.NoError(t, err)
99+ require.Empty(t, records)
100+}
101+
102+func TestRunKillOfflineReportsBothSnapshotDeletionFailures(t *testing.T) {
103+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
104+ t.Setenv("XDG_STATE_HOME", stateRoot)
105+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
106+
107+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
108+ require.NoError(t, err)
109+ now := time.Now().UnixNano()
110+ require.NoError(t, p.Save(persist.Record{Name: "named", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
111+ require.NoError(t, p.Close())
112+
113+ incrementalErr := errors.New("incremental quarantine failed")
114+ legacyErr := errors.New("legacy unlink failed")
115+ fake := &offlineSnapshotFake{deleteErr: incrementalErr, deleteLegacyErr: legacyErr}
116+ originalFactory := newOfflineSnapshotRepository
117+ newOfflineSnapshotRepository = func(string) offlineSnapshotSource { return fake }
118+ t.Cleanup(func() { newOfflineSnapshotRepository = originalFactory })
119+
120+ err = runKill(context.Background(), "named", false, false)
121+ require.ErrorIs(t, err, incrementalErr)
122+ require.ErrorIs(t, err, legacyErr)
123+ require.Equal(t, []string{"tombstone", "incremental", "legacy"}, fake.calls)
124+}
M internal/app/run.go +42 -10
@@ -1186,6 +1186,27 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
11861186 _ = tw.Flush()
11871187 }
11881188
1189+// offlineSnapshotSource owns the durable source and tombstone transitions for
1190+// a daemon-less kill. It is intentionally local to the composition root: the
1191+// daemon uses the repository through its narrower ports.
1192+type offlineSnapshotSource interface {
1193+ Tombstone(context.Context, string) error
1194+ Delete(context.Context, string) error
1195+ DeleteLegacy(context.Context, string) error
1196+ DeleteTombstone(context.Context, string) error
1197+}
1198+
1199+var newOfflineSnapshotRepository = func(dir string) offlineSnapshotSource {
1200+ return snapshotadapter.NewRepository(dir)
1201+}
1202+
1203+func phaseError(phase string, err error) error {
1204+ if err == nil {
1205+ return nil
1206+ }
1207+ return fmt.Errorf("vev: %s: %w", phase, err)
1208+}
1209+
11891210 // runKill asks the daemon to terminate a named session, every session, or the daemon.
11901211 func runKill(ctx context.Context, name string, all, daemon bool) error {
11911212 transport, err := realDial(ctx, ipc.SocketDir())
@@ -1215,6 +1236,27 @@ func runKill(ctx context.Context, name string, all, daemon bool) error {
12151236 if openErr != nil {
12161237 return fmt.Errorf("vev: opening stored sessions: %w", openErr)
12171238 }
1239+ snapshots := newOfflineSnapshotRepository(snapshotDir())
1240+ // The durable marker is the commit point for a logical kill. Keep it and
1241+ // the persisted record until both independently recoverable sources have
1242+ // crossed their deletion durability boundaries.
1243+ if tombstoneErr := snapshots.Tombstone(ctx, name); tombstoneErr != nil {
1244+ _ = p.Close()
1245+ return fmt.Errorf("vev: marking session killed: %w", tombstoneErr)
1246+ }
1247+ incrementalErr := snapshots.Delete(ctx, name)
1248+ legacyErr := snapshots.DeleteLegacy(ctx, name)
1249+ if incrementalErr != nil || legacyErr != nil {
1250+ _ = p.Close()
1251+ return errors.Join(
1252+ phaseError("deleting incremental session snapshot", incrementalErr),
1253+ phaseError("deleting legacy session snapshot", legacyErr),
1254+ )
1255+ }
1256+ if tombstoneErr := snapshots.DeleteTombstone(ctx, name); tombstoneErr != nil {
1257+ _ = p.Close()
1258+ return fmt.Errorf("vev: clearing killed session marker: %w", tombstoneErr)
1259+ }
12181260 if deleteErr := p.Delete(name); deleteErr != nil {
12191261 _ = p.Close()
12201262 return fmt.Errorf("vev: deleting stored session: %w", deleteErr)
@@ -1222,16 +1264,6 @@ func runKill(ctx context.Context, name string, all, daemon bool) error {
12221264 if closeErr := p.Close(); closeErr != nil {
12231265 return fmt.Errorf("vev: closing stored sessions: %w", closeErr)
12241266 }
1225- snapshots := snapshotadapter.NewRepository(snapshotDir())
1226- if deleteErr := snapshots.Delete(ctx, name); deleteErr != nil {
1227- return fmt.Errorf("vev: deleting session snapshot: %w", deleteErr)
1228- }
1229- // A daemon-less delete may be the first command after upgrade, before
1230- // startup has had a chance to import v3. Remove that one-way source as
1231- // well so a later daemon cannot resurrect an explicitly killed session.
1232- if deleteErr := snapshots.DeleteLegacy(ctx, name); deleteErr != nil {
1233- return fmt.Errorf("vev: deleting legacy session snapshot: %w", deleteErr)
1234- }
12351267 printKillSuccess(name, all, daemon)
12361268 return nil
12371269 }
@@ -1186,6 +1186,27 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
1186 _ = tw.Flush()1186 _ = tw.Flush()
1187 }1187 }
1188 1188
1189+// offlineSnapshotSource owns the durable source and tombstone transitions for
1190+// a daemon-less kill. It is intentionally local to the composition root: the
1191+// daemon uses the repository through its narrower ports.
1192+type offlineSnapshotSource interface {
1193+ Tombstone(context.Context, string) error
1194+ Delete(context.Context, string) error
1195+ DeleteLegacy(context.Context, string) error
1196+ DeleteTombstone(context.Context, string) error
1197+}
1198+
1199+var newOfflineSnapshotRepository = func(dir string) offlineSnapshotSource {
1200+ return snapshotadapter.NewRepository(dir)
1201+}
1202+
1203+func phaseError(phase string, err error) error {
1204+ if err == nil {
1205+ return nil
1206+ }
1207+ return fmt.Errorf("vev: %s: %w", phase, err)
1208+}
1209+
1189 // runKill asks the daemon to terminate a named session, every session, or the daemon.1210 // runKill asks the daemon to terminate a named session, every session, or the daemon.
1190 func runKill(ctx context.Context, name string, all, daemon bool) error {1211 func runKill(ctx context.Context, name string, all, daemon bool) error {
1191 transport, err := realDial(ctx, ipc.SocketDir())1212 transport, err := realDial(ctx, ipc.SocketDir())
@@ -1215,6 +1236,27 @@ func runKill(ctx context.Context, name string, all, daemon bool) error {
1215 if openErr != nil {1236 if openErr != nil {
1216 return fmt.Errorf("vev: opening stored sessions: %w", openErr)1237 return fmt.Errorf("vev: opening stored sessions: %w", openErr)
1217 }1238 }
1239+ snapshots := newOfflineSnapshotRepository(snapshotDir())
1240+ // The durable marker is the commit point for a logical kill. Keep it and
1241+ // the persisted record until both independently recoverable sources have
1242+ // crossed their deletion durability boundaries.
1243+ if tombstoneErr := snapshots.Tombstone(ctx, name); tombstoneErr != nil {
1244+ _ = p.Close()
1245+ return fmt.Errorf("vev: marking session killed: %w", tombstoneErr)
1246+ }
1247+ incrementalErr := snapshots.Delete(ctx, name)
1248+ legacyErr := snapshots.DeleteLegacy(ctx, name)
1249+ if incrementalErr != nil || legacyErr != nil {
1250+ _ = p.Close()
1251+ return errors.Join(
1252+ phaseError("deleting incremental session snapshot", incrementalErr),
1253+ phaseError("deleting legacy session snapshot", legacyErr),
1254+ )
1255+ }
1256+ if tombstoneErr := snapshots.DeleteTombstone(ctx, name); tombstoneErr != nil {
1257+ _ = p.Close()
1258+ return fmt.Errorf("vev: clearing killed session marker: %w", tombstoneErr)
1259+ }
1218 if deleteErr := p.Delete(name); deleteErr != nil {1260 if deleteErr := p.Delete(name); deleteErr != nil {
1219 _ = p.Close()1261 _ = p.Close()
1220 return fmt.Errorf("vev: deleting stored session: %w", deleteErr)1262 return fmt.Errorf("vev: deleting stored session: %w", deleteErr)
@@ -1222,16 +1264,6 @@ func runKill(ctx context.Context, name string, all, daemon bool) error {
1222 if closeErr := p.Close(); closeErr != nil {1264 if closeErr := p.Close(); closeErr != nil {
1223 return fmt.Errorf("vev: closing stored sessions: %w", closeErr)1265 return fmt.Errorf("vev: closing stored sessions: %w", closeErr)
1224 }1266 }
1225- snapshots := snapshotadapter.NewRepository(snapshotDir())
1226- if deleteErr := snapshots.Delete(ctx, name); deleteErr != nil {
1227- return fmt.Errorf("vev: deleting session snapshot: %w", deleteErr)
1228- }
1229- // A daemon-less delete may be the first command after upgrade, before
1230- // startup has had a chance to import v3. Remove that one-way source as
1231- // well so a later daemon cannot resurrect an explicitly killed session.
1232- if deleteErr := snapshots.DeleteLegacy(ctx, name); deleteErr != nil {
1233- return fmt.Errorf("vev: deleting legacy session snapshot: %w", deleteErr)
1234- }
1235 printKillSuccess(name, all, daemon)1267 printKillSuccess(name, all, daemon)
1236 return nil1268 return nil
1237 }1269 }

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