1package daemon
2
3import (
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
16type 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
27func (c *movePersistenceCatalogue) Records() ([]domain.CatalogueRecord, error) { return nil, nil }
28func (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}
34func (c *movePersistenceCatalogue) Create(domain.CatalogueRecord) error { return nil }
35func (c *movePersistenceCatalogue) Replace(string, domain.CatalogueRecord) error { return nil }
36func (c *movePersistenceCatalogue) Rename(string, domain.CatalogueRecord) error { return nil }
37func (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}
44func (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}
63func (c *movePersistenceCatalogue) Sync() error { return nil }
64func (c *movePersistenceCatalogue) Close() error { return nil }
65
66type 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
79func (r *movePersistenceRepository) Publish(context.Context, ports.SnapshotPublication) error {
80 outsideLocks := true
81 unlocks := make([]func(), 0, 7)
82 candidates := make([]struct {
83 tryLock func() bool
84 unlock func()
85 }, 0, 7)
86 if r.daemon != nil {
87 candidates = append(candidates, struct {
88 tryLock func() bool
89 unlock func()
90 }{r.daemon.mu.TryLock, r.daemon.mu.Unlock})
91 }
92 if r.source != nil {
93 candidates = append(candidates,
94 struct {
95 tryLock func() bool
96 unlock func()
97 }{r.source.mu.TryLock, r.source.mu.Unlock},
98 struct {
99 tryLock func() bool
100 unlock func()
101 }{r.source.layoutApplyMu.TryLock, r.source.layoutApplyMu.Unlock})
102 }
103 if r.destination != nil {
104 candidates = append(candidates,
105 struct {
106 tryLock func() bool
107 unlock func()
108 }{r.destination.mu.TryLock, r.destination.mu.Unlock},
109 struct {
110 tryLock func() bool
111 unlock func()
112 }{r.destination.layoutApplyMu.TryLock, r.destination.layoutApplyMu.Unlock})
113 }
114 if r.moved != nil {
115 candidates = append(candidates, struct {
116 tryLock func() bool
117 unlock func()
118 }{r.moved.mu.TryLock, r.moved.mu.Unlock})
119 }
120 if r.pane != nil {
121 candidates = append(candidates, struct {
122 tryLock func() bool
123 unlock func()
124 }{r.pane.mu.TryLock, r.pane.mu.Unlock})
125 }
126 for _, candidate := range candidates {
127 if candidate.tryLock() {
128 unlocks = append(unlocks, candidate.unlock)
129 } else {
130 outsideLocks = false
131 }
132 }
133 for i := len(unlocks) - 1; i >= 0; i-- {
134 unlocks[i]()
135 }
136 r.mu.Lock()
137 r.events = append(r.events, "publish")
138 r.outsideLocks = outsideLocks
139 r.mu.Unlock()
140 return r.publish
141}
142
143func (r *movePersistenceRepository) DeleteIncarnation(_ context.Context, _ domain.IncarnationID) error {
144 r.mu.Lock()
145 r.events = append(r.events, "purge")
146 r.mu.Unlock()
147 return nil
148}
149
150func TestMoveTabPersistenceMatrix(t *testing.T) {
151 tests := []struct {
152 name string
153 sourceNamed bool
154 destinationNamed bool
155 finalSource bool
156 wantSourceGeneration bool
157 wantDestinationGeneration bool
158 wantMetadata []string
159 wantPurge bool
160 }{
161 {name: "named to named", sourceNamed: true, destinationNamed: true, wantSourceGeneration: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:work", "metadata:destination"}},
162 {name: "ephemeral to named", destinationNamed: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:destination"}},
163 {name: "named to named final source", sourceNamed: true, destinationNamed: true, finalSource: true, wantDestinationGeneration: true, wantMetadata: []string{"metadata:destination"}, wantPurge: true},
164 {name: "named to ephemeral final source", sourceNamed: true, finalSource: true, wantPurge: true},
165 {name: "ephemeral to ephemeral", finalSource: true},
166 }
167
168 for _, tt := range tests {
169 t.Run(tt.name, func(t *testing.T) {
170 ptyCount := 2
171 if tt.finalSource {
172 ptyCount = 1
173 }
174 ptys := make([]ports.PTY, ptyCount)
175 for i := range ptys {
176 ptys[i] = newQuietPTY()
177 }
178 d, source, client, _ := newManualSessionWithPTYs(t, ptys...)
179 if tt.finalSource {
180 clearAttachmentsForTest(source)
181 client.setSession(nil)
182 }
183 source.incarnation = domain.IncarnationID{1}
184 source.ephemeral = !tt.sourceNamed
185 source.snapEligible.Store(tt.sourceNamed)
186 source.snapshotWake = d.snapshotWake
187 moved := source.tabs[0]
188 moved.stableID = "moved-tab"
189
190 destination := addMoveTabTestSession(d, "destination", "destination-tab")
191 destination.ephemeral = !tt.destinationNamed
192 destination.snapEligible.Store(tt.destinationNamed)
193 destination.snapshotWake = d.snapshotWake
194
195 catalogue := &movePersistenceCatalogue{
196 records: map[string]domain.CatalogueRecord{},
197 source: source,
198 destination: destination,
199 requireSourceDirty: tt.sourceNamed && !tt.finalSource,
200 requireDestDirty: tt.destinationNamed,
201 dirtyBeforeUpdate: true,
202 }
203 if tt.sourceNamed {
204 catalogue.records[source.name] = domain.CatalogueRecord{Name: source.name, IncarnationID: source.incarnation}
205 }
206 if tt.destinationNamed {
207 catalogue.records[destination.name] = domain.CatalogueRecord{Name: destination.name, IncarnationID: destination.incarnation}
208 }
209 repository := &movePersistenceRepository{}
210 WithCatalogue(catalogue, nil)(d)
211 WithSnapshotRepository(repository)(d)
212 WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
213
214 require.NoError(t, d.moveTab(moveTabRequest{
215 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
216 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
217 }))
218
219 source.snapshotMu.Lock()
220 sourceGeneration := source.snapshotGeneration
221 source.snapshotMu.Unlock()
222 destination.snapshotMu.Lock()
223 destinationGeneration := destination.snapshotGeneration
224 destination.snapshotMu.Unlock()
225 require.Equal(t, tt.wantSourceGeneration, sourceGeneration > 0)
226 require.Equal(t, tt.wantDestinationGeneration, destinationGeneration > 0)
227 catalogue.mu.Lock()
228 metadata := append([]string(nil), catalogue.events...)
229 dirtyBeforeUpdate := catalogue.dirtyBeforeUpdate
230 catalogue.mu.Unlock()
231 require.True(t, dirtyBeforeUpdate, "snapshot dirtiness must be admitted before catalogue metadata")
232 if tt.wantPurge {
233 require.Equal(t, append(append([]string(nil), tt.wantMetadata...), "delete:"+source.name), metadata)
234 } else {
235 require.ElementsMatch(t, tt.wantMetadata, metadata)
236 }
237 repository.mu.Lock()
238 purged := len(repository.events) == 1 && repository.events[0] == "purge"
239 repository.mu.Unlock()
240 require.Equal(t, tt.wantPurge, purged)
241 require.Same(t, moved, destination.tabs[len(destination.tabs)-1])
242 })
243 }
244}
245
246func TestMoveSnapshotPublicationFailureReportsWithoutRollback(t *testing.T) {
247 d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
248 source.ephemeral = true
249 moved := source.tabs[0]
250 moved.stableID = "moved-tab"
251 destination := addMoveTabTestSession(d, "destination", "destination-tab")
252 destination.ephemeral = false
253 destination.snapEligible.Store(true)
254 destination.snapshotWake = d.snapshotWake
255
256 repository := &movePersistenceRepository{
257 publish: errors.New("injected publication failure"),
258 daemon: d, source: source, destination: destination, moved: moved, pane: moved.focusedPane(),
259 }
260 WithSnapshotRepository(repository)(d)
261 startSnapshotEncodeWorker(t, d)
262
263 require.NoError(t, d.moveTab(moveTabRequest{
264 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
265 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
266 }))
267 d.scheduleEligibleRepositorySnapshots()
268 awaitSnapshotIdle(t, destination)
269
270 require.Same(t, moved, destination.tabs[len(destination.tabs)-1], "repository failure must not roll back live membership")
271 require.Same(t, destination, moved.focusedPane().ownerSnapshot().session)
272 require.True(t, destination.snapDirty.Load(), "failed publication remains scheduler-retryable")
273 repository.mu.Lock()
274 require.Equal(t, []string{"publish"}, repository.events, "move persistence uses only the existing snapshot publication format")
275 require.True(t, repository.outsideLocks, "repository publication must run outside architecture locks")
276 repository.mu.Unlock()
277 history := d.notices.history()
278 require.NotEmpty(t, history)
279 require.Equal(t, domain.NoticeSnapshotWrite, history[len(history)-1].Code)
280}