detached 59f7072

test(daemon): verify move persistence

commit
59f7072a1b47764fc498f508d81f43527af43338
author
brice · · 2026-07-28 12:30 UTC
parent
96740f3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +247 -0
A internal/usecase/daemon/move_persistence_test.go +247 -0
@@ -0,0 +1,247 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
14+)
15+
16+type movePersistenceCatalogue struct {
17+ mu sync.Mutex
18+ records map[string]domain.CatalogueRecord
19+ events []string
20+ source *session
21+ destination *session
22+ requireSourceDirty bool
23+ requireDestDirty bool
24+ dirtyBeforeUpdate bool
25+}
26+
27+func (c *movePersistenceCatalogue) Records() ([]domain.CatalogueRecord, error) { return nil, nil }
28+func (c *movePersistenceCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
29+ c.mu.Lock()
30+ defer c.mu.Unlock()
31+ record, ok := c.records[name]
32+ return record, ok, nil
33+}
34+func (c *movePersistenceCatalogue) Create(domain.CatalogueRecord) error { return nil }
35+func (c *movePersistenceCatalogue) Replace(string, domain.CatalogueRecord) error { return nil }
36+func (c *movePersistenceCatalogue) Rename(string, domain.CatalogueRecord) error { return nil }
37+func (c *movePersistenceCatalogue) Delete(name string) error {
38+ c.mu.Lock()
39+ defer c.mu.Unlock()
40+ delete(c.records, name)
41+ c.events = append(c.events, "delete:"+name)
42+ return nil
43+}
44+func (c *movePersistenceCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
45+ sourceDirty := true
46+ if c.requireSourceDirty {
47+ c.source.snapshotMu.Lock()
48+ sourceDirty = c.source.snapDirty.Load() && c.source.snapshotGeneration > 0
49+ c.source.snapshotMu.Unlock()
50+ }
51+ destinationDirty := true
52+ if c.requireDestDirty {
53+ c.destination.snapshotMu.Lock()
54+ destinationDirty = c.destination.snapDirty.Load() && c.destination.snapshotGeneration > 0
55+ c.destination.snapshotMu.Unlock()
56+ }
57+ c.mu.Lock()
58+ defer c.mu.Unlock()
59+ c.dirtyBeforeUpdate = c.dirtyBeforeUpdate && sourceDirty && destinationDirty
60+ c.events = append(c.events, "metadata:"+update.Name)
61+ return nil
62+}
63+func (c *movePersistenceCatalogue) Sync() error { return nil }
64+func (c *movePersistenceCatalogue) Close() error { return nil }
65+
66+type movePersistenceRepository struct {
67+ noOpSnapshotRepository
68+ mu sync.Mutex
69+ events []string
70+ publish error
71+ daemon *Daemon
72+ source *session
73+ destination *session
74+ moved *tab
75+ pane *pane
76+ outsideLocks bool
77+}
78+
79+func (r *movePersistenceRepository) Publish(context.Context, ports.SnapshotPublication) error {
80+ outsideLocks := true
81+ unlocks := make([]func(), 0, 7)
82+ for _, candidate := range []struct {
83+ tryLock func() bool
84+ unlock func()
85+ }{
86+ {r.daemon.mu.TryLock, r.daemon.mu.Unlock},
87+ {r.source.mu.TryLock, r.source.mu.Unlock},
88+ {r.destination.mu.TryLock, r.destination.mu.Unlock},
89+ {r.moved.mu.TryLock, r.moved.mu.Unlock},
90+ {r.pane.mu.TryLock, r.pane.mu.Unlock},
91+ {r.source.layoutApplyMu.TryLock, r.source.layoutApplyMu.Unlock},
92+ {r.destination.layoutApplyMu.TryLock, r.destination.layoutApplyMu.Unlock},
93+ } {
94+ if candidate.tryLock() {
95+ unlocks = append(unlocks, candidate.unlock)
96+ } else {
97+ outsideLocks = false
98+ }
99+ }
100+ for i := len(unlocks) - 1; i >= 0; i-- {
101+ unlocks[i]()
102+ }
103+ r.mu.Lock()
104+ r.events = append(r.events, "publish")
105+ r.outsideLocks = outsideLocks
106+ r.mu.Unlock()
107+ return r.publish
108+}
109+
110+func (r *movePersistenceRepository) DeleteIncarnation(_ context.Context, _ domain.IncarnationID) error {
111+ r.mu.Lock()
112+ r.events = append(r.events, "purge")
113+ r.mu.Unlock()
114+ return nil
115+}
116+
117+func TestMoveTabPersistenceMatrix(t *testing.T) {
118+ tests := []struct {
119+ name string
120+ sourceNamed bool
121+ destinationNamed bool
122+ finalSource bool
123+ wantSourceGeneration bool
124+ wantDestinationGeneration bool
125+ wantMetadata []string
126+ wantPurge bool
127+ }{
128+ {name: "named to named", sourceNamed: true, destinationNamed: true, wantSourceGeneration: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:work", "metadata:destination"}},
129+ {name: "ephemeral to named", destinationNamed: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:destination"}},
130+ {name: "named to named final source", sourceNamed: true, destinationNamed: true, finalSource: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:destination"}, wantPurge: true},
131+ {name: "named to ephemeral final source", sourceNamed: true, finalSource: true, wantPurge: true},
132+ {name: "ephemeral to ephemeral", finalSource: true},
133+ }
134+
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ ptyCount := 2
138+ if tt.finalSource {
139+ ptyCount = 1
140+ }
141+ ptys := make([]ports.PTY, ptyCount)
142+ for i := range ptys {
143+ ptys[i] = newQuietPTY()
144+ }
145+ d, source, client, _ := newManualSessionWithPTYs(t, ptys...)
146+ if tt.finalSource {
147+ source.client = nil
148+ client.setSession(nil)
149+ }
150+ source.incarnation = domain.IncarnationID{1}
151+ source.ephemeral = !tt.sourceNamed
152+ source.snapEligible.Store(tt.sourceNamed)
153+ source.snapshotWake = d.snapshotWake
154+ moved := source.tabs[0]
155+ moved.stableID = "moved-tab"
156+
157+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
158+ destination.ephemeral = !tt.destinationNamed
159+ destination.snapEligible.Store(tt.destinationNamed)
160+ destination.snapshotWake = d.snapshotWake
161+
162+ catalogue := &movePersistenceCatalogue{
163+ records: map[string]domain.CatalogueRecord{},
164+ source: source,
165+ destination: destination,
166+ requireSourceDirty: tt.sourceNamed && !tt.finalSource,
167+ requireDestDirty: tt.destinationNamed,
168+ dirtyBeforeUpdate: true,
169+ }
170+ if tt.sourceNamed {
171+ catalogue.records[source.name] = domain.CatalogueRecord{Name: source.name, IncarnationID: source.incarnation}
172+ }
173+ if tt.destinationNamed {
174+ catalogue.records[destination.name] = domain.CatalogueRecord{Name: destination.name, IncarnationID: destination.incarnation}
175+ }
176+ repository := &movePersistenceRepository{}
177+ WithCatalogue(catalogue, nil)(d)
178+ WithSnapshotRepository(repository)(d)
179+ WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
180+
181+ require.NoError(t, d.moveTab(moveTabRequest{
182+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
183+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
184+ }))
185+
186+ source.snapshotMu.Lock()
187+ sourceGeneration := source.snapshotGeneration
188+ source.snapshotMu.Unlock()
189+ destination.snapshotMu.Lock()
190+ destinationGeneration := destination.snapshotGeneration
191+ destination.snapshotMu.Unlock()
192+ require.Equal(t, tt.wantSourceGeneration, sourceGeneration > 0)
193+ require.Equal(t, tt.wantDestinationGeneration, destinationGeneration > 0)
194+ catalogue.mu.Lock()
195+ metadata := append([]string(nil), catalogue.events...)
196+ dirtyBeforeUpdate := catalogue.dirtyBeforeUpdate
197+ catalogue.mu.Unlock()
198+ require.True(t, dirtyBeforeUpdate, "snapshot dirtiness must be admitted before catalogue metadata")
199+ if tt.wantPurge {
200+ require.Equal(t, append(append([]string(nil), tt.wantMetadata...), "delete:"+source.name), metadata)
201+ } else {
202+ require.ElementsMatch(t, tt.wantMetadata, metadata)
203+ }
204+ repository.mu.Lock()
205+ purged := len(repository.events) == 1 && repository.events[0] == "purge"
206+ repository.mu.Unlock()
207+ require.Equal(t, tt.wantPurge, purged)
208+ require.Same(t, moved, destination.tabs[len(destination.tabs)-1])
209+ })
210+ }
211+}
212+
213+func TestMoveSnapshotPublicationFailureReportsWithoutRollback(t *testing.T) {
214+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
215+ source.ephemeral = true
216+ moved := source.tabs[0]
217+ moved.stableID = "moved-tab"
218+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
219+ destination.ephemeral = false
220+ destination.snapEligible.Store(true)
221+ destination.snapshotWake = d.snapshotWake
222+
223+ repository := &movePersistenceRepository{
224+ publish: errors.New("injected publication failure"),
225+ daemon: d, source: source, destination: destination, moved: moved, pane: moved.focusedPane(),
226+ }
227+ WithSnapshotRepository(repository)(d)
228+ startSnapshotEncodeWorker(t, d)
229+
230+ require.NoError(t, d.moveTab(moveTabRequest{
231+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
232+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
233+ }))
234+ d.scheduleEligibleRepositorySnapshots()
235+ awaitSnapshotIdle(t, destination)
236+
237+ require.Same(t, moved, destination.tabs[len(destination.tabs)-1], "repository failure must not roll back live membership")
238+ require.Same(t, destination, moved.focusedPane().ownerSnapshot().session)
239+ require.True(t, destination.snapDirty.Load(), "failed publication remains scheduler-retryable")
240+ repository.mu.Lock()
241+ require.Equal(t, []string{"publish"}, repository.events, "move persistence uses only the existing snapshot publication format")
242+ require.True(t, repository.outsideLocks, "repository publication must run outside architecture locks")
243+ repository.mu.Unlock()
244+ history := d.notices.history()
245+ require.NotEmpty(t, history)
246+ require.Equal(t, domain.NoticeSnapshotWrite, history[len(history)-1].Code)
247+}
@@ -0,0 +1,247 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
14+)
15+
16+type movePersistenceCatalogue struct {
17+ mu sync.Mutex
18+ records map[string]domain.CatalogueRecord
19+ events []string
20+ source *session
21+ destination *session
22+ requireSourceDirty bool
23+ requireDestDirty bool
24+ dirtyBeforeUpdate bool
25+}
26+
27+func (c *movePersistenceCatalogue) Records() ([]domain.CatalogueRecord, error) { return nil, nil }
28+func (c *movePersistenceCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
29+ c.mu.Lock()
30+ defer c.mu.Unlock()
31+ record, ok := c.records[name]
32+ return record, ok, nil
33+}
34+func (c *movePersistenceCatalogue) Create(domain.CatalogueRecord) error { return nil }
35+func (c *movePersistenceCatalogue) Replace(string, domain.CatalogueRecord) error { return nil }
36+func (c *movePersistenceCatalogue) Rename(string, domain.CatalogueRecord) error { return nil }
37+func (c *movePersistenceCatalogue) Delete(name string) error {
38+ c.mu.Lock()
39+ defer c.mu.Unlock()
40+ delete(c.records, name)
41+ c.events = append(c.events, "delete:"+name)
42+ return nil
43+}
44+func (c *movePersistenceCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
45+ sourceDirty := true
46+ if c.requireSourceDirty {
47+ c.source.snapshotMu.Lock()
48+ sourceDirty = c.source.snapDirty.Load() && c.source.snapshotGeneration > 0
49+ c.source.snapshotMu.Unlock()
50+ }
51+ destinationDirty := true
52+ if c.requireDestDirty {
53+ c.destination.snapshotMu.Lock()
54+ destinationDirty = c.destination.snapDirty.Load() && c.destination.snapshotGeneration > 0
55+ c.destination.snapshotMu.Unlock()
56+ }
57+ c.mu.Lock()
58+ defer c.mu.Unlock()
59+ c.dirtyBeforeUpdate = c.dirtyBeforeUpdate && sourceDirty && destinationDirty
60+ c.events = append(c.events, "metadata:"+update.Name)
61+ return nil
62+}
63+func (c *movePersistenceCatalogue) Sync() error { return nil }
64+func (c *movePersistenceCatalogue) Close() error { return nil }
65+
66+type movePersistenceRepository struct {
67+ noOpSnapshotRepository
68+ mu sync.Mutex
69+ events []string
70+ publish error
71+ daemon *Daemon
72+ source *session
73+ destination *session
74+ moved *tab
75+ pane *pane
76+ outsideLocks bool
77+}
78+
79+func (r *movePersistenceRepository) Publish(context.Context, ports.SnapshotPublication) error {
80+ outsideLocks := true
81+ unlocks := make([]func(), 0, 7)
82+ for _, candidate := range []struct {
83+ tryLock func() bool
84+ unlock func()
85+ }{
86+ {r.daemon.mu.TryLock, r.daemon.mu.Unlock},
87+ {r.source.mu.TryLock, r.source.mu.Unlock},
88+ {r.destination.mu.TryLock, r.destination.mu.Unlock},
89+ {r.moved.mu.TryLock, r.moved.mu.Unlock},
90+ {r.pane.mu.TryLock, r.pane.mu.Unlock},
91+ {r.source.layoutApplyMu.TryLock, r.source.layoutApplyMu.Unlock},
92+ {r.destination.layoutApplyMu.TryLock, r.destination.layoutApplyMu.Unlock},
93+ } {
94+ if candidate.tryLock() {
95+ unlocks = append(unlocks, candidate.unlock)
96+ } else {
97+ outsideLocks = false
98+ }
99+ }
100+ for i := len(unlocks) - 1; i >= 0; i-- {
101+ unlocks[i]()
102+ }
103+ r.mu.Lock()
104+ r.events = append(r.events, "publish")
105+ r.outsideLocks = outsideLocks
106+ r.mu.Unlock()
107+ return r.publish
108+}
109+
110+func (r *movePersistenceRepository) DeleteIncarnation(_ context.Context, _ domain.IncarnationID) error {
111+ r.mu.Lock()
112+ r.events = append(r.events, "purge")
113+ r.mu.Unlock()
114+ return nil
115+}
116+
117+func TestMoveTabPersistenceMatrix(t *testing.T) {
118+ tests := []struct {
119+ name string
120+ sourceNamed bool
121+ destinationNamed bool
122+ finalSource bool
123+ wantSourceGeneration bool
124+ wantDestinationGeneration bool
125+ wantMetadata []string
126+ wantPurge bool
127+ }{
128+ {name: "named to named", sourceNamed: true, destinationNamed: true, wantSourceGeneration: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:work", "metadata:destination"}},
129+ {name: "ephemeral to named", destinationNamed: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:destination"}},
130+ {name: "named to named final source", sourceNamed: true, destinationNamed: true, finalSource: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:destination"}, wantPurge: true},
131+ {name: "named to ephemeral final source", sourceNamed: true, finalSource: true, wantPurge: true},
132+ {name: "ephemeral to ephemeral", finalSource: true},
133+ }
134+
135+ for _, tt := range tests {
136+ t.Run(tt.name, func(t *testing.T) {
137+ ptyCount := 2
138+ if tt.finalSource {
139+ ptyCount = 1
140+ }
141+ ptys := make([]ports.PTY, ptyCount)
142+ for i := range ptys {
143+ ptys[i] = newQuietPTY()
144+ }
145+ d, source, client, _ := newManualSessionWithPTYs(t, ptys...)
146+ if tt.finalSource {
147+ source.client = nil
148+ client.setSession(nil)
149+ }
150+ source.incarnation = domain.IncarnationID{1}
151+ source.ephemeral = !tt.sourceNamed
152+ source.snapEligible.Store(tt.sourceNamed)
153+ source.snapshotWake = d.snapshotWake
154+ moved := source.tabs[0]
155+ moved.stableID = "moved-tab"
156+
157+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
158+ destination.ephemeral = !tt.destinationNamed
159+ destination.snapEligible.Store(tt.destinationNamed)
160+ destination.snapshotWake = d.snapshotWake
161+
162+ catalogue := &movePersistenceCatalogue{
163+ records: map[string]domain.CatalogueRecord{},
164+ source: source,
165+ destination: destination,
166+ requireSourceDirty: tt.sourceNamed && !tt.finalSource,
167+ requireDestDirty: tt.destinationNamed,
168+ dirtyBeforeUpdate: true,
169+ }
170+ if tt.sourceNamed {
171+ catalogue.records[source.name] = domain.CatalogueRecord{Name: source.name, IncarnationID: source.incarnation}
172+ }
173+ if tt.destinationNamed {
174+ catalogue.records[destination.name] = domain.CatalogueRecord{Name: destination.name, IncarnationID: destination.incarnation}
175+ }
176+ repository := &movePersistenceRepository{}
177+ WithCatalogue(catalogue, nil)(d)
178+ WithSnapshotRepository(repository)(d)
179+ WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
180+
181+ require.NoError(t, d.moveTab(moveTabRequest{
182+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
183+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
184+ }))
185+
186+ source.snapshotMu.Lock()
187+ sourceGeneration := source.snapshotGeneration
188+ source.snapshotMu.Unlock()
189+ destination.snapshotMu.Lock()
190+ destinationGeneration := destination.snapshotGeneration
191+ destination.snapshotMu.Unlock()
192+ require.Equal(t, tt.wantSourceGeneration, sourceGeneration > 0)
193+ require.Equal(t, tt.wantDestinationGeneration, destinationGeneration > 0)
194+ catalogue.mu.Lock()
195+ metadata := append([]string(nil), catalogue.events...)
196+ dirtyBeforeUpdate := catalogue.dirtyBeforeUpdate
197+ catalogue.mu.Unlock()
198+ require.True(t, dirtyBeforeUpdate, "snapshot dirtiness must be admitted before catalogue metadata")
199+ if tt.wantPurge {
200+ require.Equal(t, append(append([]string(nil), tt.wantMetadata...), "delete:"+source.name), metadata)
201+ } else {
202+ require.ElementsMatch(t, tt.wantMetadata, metadata)
203+ }
204+ repository.mu.Lock()
205+ purged := len(repository.events) == 1 && repository.events[0] == "purge"
206+ repository.mu.Unlock()
207+ require.Equal(t, tt.wantPurge, purged)
208+ require.Same(t, moved, destination.tabs[len(destination.tabs)-1])
209+ })
210+ }
211+}
212+
213+func TestMoveSnapshotPublicationFailureReportsWithoutRollback(t *testing.T) {
214+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
215+ source.ephemeral = true
216+ moved := source.tabs[0]
217+ moved.stableID = "moved-tab"
218+ destination := addMoveTabTestSession(d, "destination", "destination-tab")
219+ destination.ephemeral = false
220+ destination.snapEligible.Store(true)
221+ destination.snapshotWake = d.snapshotWake
222+
223+ repository := &movePersistenceRepository{
224+ publish: errors.New("injected publication failure"),
225+ daemon: d, source: source, destination: destination, moved: moved, pane: moved.focusedPane(),
226+ }
227+ WithSnapshotRepository(repository)(d)
228+ startSnapshotEncodeWorker(t, d)
229+
230+ require.NoError(t, d.moveTab(moveTabRequest{
231+ Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
232+ Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
233+ }))
234+ d.scheduleEligibleRepositorySnapshots()
235+ awaitSnapshotIdle(t, destination)
236+
237+ require.Same(t, moved, destination.tabs[len(destination.tabs)-1], "repository failure must not roll back live membership")
238+ require.Same(t, destination, moved.focusedPane().ownerSnapshot().session)
239+ require.True(t, destination.snapDirty.Load(), "failed publication remains scheduler-retryable")
240+ repository.mu.Lock()
241+ require.Equal(t, []string{"publish"}, repository.events, "move persistence uses only the existing snapshot publication format")
242+ require.True(t, repository.outsideLocks, "repository publication must run outside architecture locks")
243+ repository.mu.Unlock()
244+ history := d.notices.history()
245+ require.NotEmpty(t, history)
246+ require.Equal(t, domain.NoticeSnapshotWrite, history[len(history)-1].Code)
247+}

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 2

Tags 13

↑↓ moveenter openesc close