1package daemon
2
3import (
4 "context"
5 "errors"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/stretchr/testify/require"
11
12 renderer "github.com/bnema/vev-vt/ansi"
13 "github.com/bnema/vev/internal/domain"
14 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
15)
16
17func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
18 for _, destinationEphemeral := range []bool{false, true} {
19 t.Run(map[bool]string{false: "named", true: "ephemeral"}[destinationEphemeral], func(t *testing.T) {
20 movedPTY := newQuietPTY()
21 remainingPTY := newQuietPTY()
22 destinationPTY := newQuietPTY()
23 floatingPTY := newQuietPTY()
24 d, source, _, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY)
25 moved := source.tabs[0]
26 moved.stableID = "moved-tab"
27 moved.name = "preserved-name"
28 remaining := source.tabs[1]
29 remaining.stableID = "remaining-tab"
30 selectTestAttachmentTab(source, 0)
31
32 destinationCtx, destinationCancel := context.WithCancel(d.serveCtx)
33 t.Cleanup(destinationCancel)
34 destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30})
35 destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx)
36 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral}, ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}}
37 publishTiledPaneOwners(destination, destinationTab)
38
39 floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})
40 moved.mu.Lock()
41 moved.floating = floatingSlot{state: floatingHidden, pane: floating, generation: 7}
42 moved.mu.Unlock()
43 publishPaneOwner(floating, source, moved, 7)
44
45 d.mu.Lock()
46 d.sessions[destination.id] = destination
47 d.mu.Unlock()
48
49 tiled := moved.focusedPane()
50 tiledScreen := tiled.screen
51 tiledOwnerGeneration := tiled.ownerGeneration
52 floatingOwnerGeneration := floating.ownerGeneration
53 oldWorkerCtx := moved.ctx
54 oldTree := moved.tree
55 oldPanes := moved.panes
56 beforeDestinationActive := destination.tabs[testAttachmentTabIndex(destination)]
57
58 err := d.moveTab(moveTabRequest{
59 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
60 SourceTabID: domain.TabStableID(moved.stableID),
61 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
62 })
63 require.NoError(t, err)
64
65 require.Equal(t, []*tab{remaining}, source.tabs)
66 require.Zero(t, testAttachmentTabIndex(source))
67 require.Same(t, remaining, source.tabs[testAttachmentTabIndex(source)])
68 require.Equal(t, []*tab{destinationTab, moved}, destination.tabs)
69 require.Same(t, beforeDestinationActive, destination.tabs[testAttachmentTabIndex(destination)], "moved tab stays in the background")
70 require.Equal(t, destinationTab.size, moved.size, "destination geometry becomes authoritative")
71 require.Equal(t, "preserved-name", moved.name)
72 require.Same(t, oldTree, moved.tree)
73 require.Equal(t, oldPanes, moved.panes)
74 require.Same(t, movedPTY, tiled.pty)
75 require.Same(t, tiledScreen, tiled.screen)
76 require.Same(t, floating, moved.floating.pane)
77 require.Same(t, floatingPTY, floating.pty)
78 require.Equal(t, floatingHidden, moved.floating.state)
79 require.Equal(t, uint64(7), moved.floating.generation)
80 require.Equal(t, tiledOwnerGeneration+1, tiled.ownerGeneration)
81 require.Equal(t, floatingOwnerGeneration+1, floating.ownerGeneration)
82 require.Same(t, destination, tiled.ownerSnapshot().session)
83 require.Same(t, destination, floating.ownerSnapshot().session)
84 require.Error(t, oldWorkerCtx.Err(), "source-derived tab worker context is cancelled")
85 require.NoError(t, moved.ctx.Err(), "replacement tab worker context belongs to live destination")
86 })
87 }
88}
89
90func TestMoveTabRejectsSameSessionStaleMembershipAndWarming(t *testing.T) {
91 tests := []struct {
92 name string
93 mutate func(source, destination *session, moved *tab, req *moveTabRequest)
94 wantErr error
95 }{
96 {name: "same session", wantErr: errMovePaneInvalid, mutate: func(source, _ *session, _ *tab, req *moveTabRequest) {
97 req.Destination = req.Source
98 }},
99 {name: "stale incarnation", wantErr: errMoveStaleTarget, mutate: func(_, _ *session, _ *tab, req *moveTabRequest) {
100 req.Destination.Incarnation[0]++
101 }},
102 {name: "missing membership", wantErr: errMoveStaleTarget, mutate: func(source, _ *session, moved *tab, _ *moveTabRequest) {
103 source.tabs = source.tabs[1:]
104 _ = moved
105 }},
106 {name: "warming floating", wantErr: errMoveFloatingWarming, mutate: func(_, _ *session, moved *tab, _ *moveTabRequest) {
107 moved.floating.state = floatingWarming
108 moved.floating.generation = 3
109 }},
110 {name: "destination teardown", wantErr: errMoveStaleTarget, mutate: func(_, destination *session, _ *tab, _ *moveTabRequest) {
111 destination.teardownMu.Lock()
112 destination.teardownActive = true
113 destination.teardownMu.Unlock()
114 }},
115 }
116 for _, tt := range tests {
117 t.Run(tt.name, func(t *testing.T) {
118 d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
119 moved := source.tabs[0]
120 moved.stableID = "moved-tab"
121 destination := addMoveTabTestSession(d, "destination", "destination-tab")
122 req := moveTabRequest{
123 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
124 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
125 }
126 tt.mutate(source, destination, moved, &req)
127 beforeDestination := append([]*tab(nil), destination.tabs...)
128 beforeOwner := moved.focusedPane().ownerSnapshot()
129
130 err := d.moveTab(req)
131 destination.teardownMu.Lock()
132 destination.teardownActive = false
133 destination.teardownMu.Unlock()
134 require.ErrorIs(t, err, tt.wantErr)
135 var rejection *moveRejection
136 require.ErrorAs(t, err, &rejection)
137 require.Equal(t, beforeDestination, destination.tabs)
138 require.Same(t, beforeOwner, moved.focusedPane().ownerSnapshot())
139 })
140 }
141}
142
143func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing.T) {
144 for _, tt := range []struct {
145 name string
146 mutate func(*tab)
147 }{
148 {name: "generation", mutate: func(moved *tab) { moved.floating.generation++ }},
149 {name: "warming", mutate: func(moved *tab) { moved.floating.state = floatingWarming }},
150 } {
151 t.Run(tt.name, func(t *testing.T) {
152 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
153 moved := source.tabs[0]
154 moved.stableID = "moved-tab"
155 require.NotNil(t, d.attachCoordinator(source, nil, client, true))
156 destination := addMoveTabTestSession(d, "destination", "destination-tab")
157 floating := newPaneWithStableID("floating", "floating-stable", newQuietPTY(), domain.Size{Cols: 20, Rows: 8})
158 moved.mu.Lock()
159 moved.floating = floatingSlot{state: floatingVisible, pane: floating, desiredVisible: true, generation: 7}
160 moved.mu.Unlock()
161 publishPaneOwner(floating, source, moved, 7)
162
163 source.layoutApplyMu.Lock()
164 admitted := make(chan struct{})
165 d.afterAttachmentEffectGateFrozen = func(action string, ac *attachedClient) {
166 if action == "move-tab" && ac == client {
167 close(admitted)
168 }
169 }
170 moveDone := make(chan error, 1)
171 go func() {
172 moveDone <- d.moveTab(moveTabRequest{
173 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
174 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
175 })
176 }()
177 awaitTestCompletion(t, admitted, "move-tab did not finish admission")
178 moved.mu.Lock()
179 tt.mutate(moved)
180 moved.mu.Unlock()
181 source.layoutApplyMu.Unlock()
182
183 require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return"))
184 require.NotContains(t, destination.tabs, moved)
185 require.Same(t, source, floating.ownerSnapshot().session)
186 d.afterAttachmentEffectGateFrozen = nil
187 })
188 }
189}
190
191func TestMoveTabWaitsForSourceResizeAndAppliesDestinationGeometryLast(t *testing.T) {
192 pty := &transactionalResizePTY{}
193 d, source, _, _ := newManualSessionWithPTYs(t, pty, newQuietPTY())
194 moved := source.tabs[0]
195 moved.stableID = "moved-tab"
196 destination := addMoveTabTestSession(d, "destination", "destination-tab")
197 destination.tabs[0].mu.Lock()
198 destination.tabs[0].size = domain.Size{Cols: 100, Rows: 30}
199 destination.tabs[0].mu.Unlock()
200 moved.mu.Lock()
201 moved.size = domain.Size{Cols: 90, Rows: 20}
202 moved.bumpLayoutGenerationLocked()
203 moved.mu.Unlock()
204
205 resizeEntered := make(chan struct{})
206 releaseResize := make(chan struct{})
207 var once sync.Once
208 pty.onResize = func() {
209 once.Do(func() {
210 close(resizeEntered)
211 <-releaseResize
212 })
213 }
214 layoutDone := make(chan bool, 1)
215 go func() { layoutDone <- source.geometry.applyTabLayout(d, source, moved) }()
216 <-resizeEntered
217 reserved := make(chan struct{})
218 d.afterMoveLifecycleReserved = func() { close(reserved) }
219 defer func() { d.afterMoveLifecycleReserved = nil }()
220 moveDone := make(chan error, 1)
221 go func() {
222 moveDone <- d.moveTab(moveTabRequest{
223 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
224 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
225 })
226 }()
227 <-reserved
228 select {
229 case err := <-moveDone:
230 t.Fatalf("move crossed blocked source resize: %v", err)
231 default:
232 }
233 close(releaseResize)
234 <-layoutDone // the old transaction may lose owner publication after its PTY call
235 require.NoError(t, <-moveDone)
236
237 pty.mu.Lock()
238 sizes := append([]domain.Size(nil), pty.sizes...)
239 pty.mu.Unlock()
240 require.GreaterOrEqual(t, len(sizes), 2)
241 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, sizes[len(sizes)-1], "destination geometry must be the final external resize")
242}
243
244func TestMoveTabMigratesSyncAndInvalidatesSourceRetryOwner(t *testing.T) {
245 d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
246 moved := source.tabs[0]
247 moved.stableID = "moved-tab"
248 destination := addMoveTabTestSession(d, "destination", "destination-tab")
249 p := moved.focusedPane()
250 sourceCoordinator := d.ensureRenderCoordinator(source)
251 destinationCoordinator := d.ensureRenderCoordinator(destination)
252
253 p.mu.Lock()
254 p.screen.Write([]byte(renderer.SyncStartCSI))
255 generation := source.syncGen.Add(1)
256 p.syncGen = generation
257 oldLease := p.effectLeaseLocked()
258 p.resizeRetry = true
259 p.mu.Unlock()
260 sourceCoordinator.noteSyncBeginWithRenderability(p, generation, oldLease.Current, nil)
261 oldRetry := newAcceptedTabLayoutRetryToken(source, moved, []resizeMember{{session: source, tab: moved, pane: p, owner: oldLease}})
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
268 require.False(t, oldLease.Current(), "source effects are revoked at owner publication")
269 require.False(t, oldRetry.current(), "source delayed retry cannot run after transfer")
270 require.Same(t, destination, p.ownerSnapshot().session)
271 sourceCoordinator.mu.Lock()
272 require.NotContains(t, sourceCoordinator.syncBatches, p)
273 sourceCoordinator.mu.Unlock()
274 destinationCoordinator.mu.Lock()
275 require.Contains(t, destinationCoordinator.syncBatches, p)
276 destinationCoordinator.mu.Unlock()
277 require.Equal(t, destination.syncGen.Load(), p.syncGen)
278 p.mu.Lock()
279 require.False(t, p.resizeRetry, "required resize work is completed under the destination owner")
280 p.mu.Unlock()
281}
282
283func TestMoveTabExitReapsOnlyDestination(t *testing.T) {
284 d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
285 moved := source.tabs[0]
286 moved.stableID = "moved-tab"
287 remaining := source.tabs[1]
288 destination := addMoveTabTestSession(d, "destination", "destination-tab")
289 p := moved.focusedPane()
290
291 require.NoError(t, d.moveTab(moveTabRequest{
292 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
293 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
294 }))
295 d.reapPaneOwner(p)
296
297 require.Equal(t, []*tab{remaining}, source.tabs)
298 require.NotContains(t, destination.tabs, moved, "exit removes the tab from its destination owner")
299 require.Nil(t, p.ownerSnapshot())
300}
301
302func TestMoveTabOppositeDirectionsDoNotDeadlock(t *testing.T) {
303 d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
304 left.tabs[0].stableID = "left-moved"
305 right := addMoveTabTestSession(d, "right", "right-stays")
306 rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
307 rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx)
308 publishTiledPaneOwners(right, rightMoved)
309 right.tabs = append(right.tabs, rightMoved)
310
311 start := make(chan struct{})
312 done := make(chan error, 2)
313 go func() {
314 <-start
315 done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}, SourceTabID: domain.TabStableID("left-moved"), Destination: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}})
316 }()
317 go func() {
318 <-start
319 done <- d.moveTab(moveTabRequest{Source: moveSessionLocator{ID: right.id, Incarnation: right.incarnation}, SourceTabID: domain.TabStableID("right-moved"), Destination: moveSessionLocator{ID: left.id, Incarnation: left.incarnation}})
320 }()
321 close(start)
322 for range 2 {
323 select {
324 case err := <-done:
325 require.NoError(t, err)
326 case <-time.After(5 * time.Second):
327 t.Fatal("opposite-direction move tabs deadlocked")
328 }
329 }
330}
331
332type moveTabPurgeCatalogue struct {
333 mu sync.Mutex
334 records map[string]domain.CatalogueRecord
335 events []string
336}
337
338func (c *moveTabPurgeCatalogue) Records() ([]domain.CatalogueRecord, error) {
339 c.mu.Lock()
340 defer c.mu.Unlock()
341 records := make([]domain.CatalogueRecord, 0, len(c.records))
342 for _, record := range c.records {
343 records = append(records, record)
344 }
345 return records, nil
346}
347
348func (c *moveTabPurgeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
349 c.mu.Lock()
350 defer c.mu.Unlock()
351 record, ok := c.records[name]
352 return record, ok, nil
353}
354
355func (c *moveTabPurgeCatalogue) Create(domain.CatalogueRecord) error {
356 return errors.New("move tab purge catalogue: Create is unsupported")
357}
358
359func (c *moveTabPurgeCatalogue) Replace(string, domain.CatalogueRecord) error {
360 return errors.New("move tab purge catalogue: Replace is unsupported")
361}
362
363func (c *moveTabPurgeCatalogue) Rename(string, domain.CatalogueRecord) error {
364 return errors.New("move tab purge catalogue: Rename is unsupported")
365}
366
367func (c *moveTabPurgeCatalogue) Delete(name string) error {
368 c.mu.Lock()
369 defer c.mu.Unlock()
370 delete(c.records, name)
371 c.events = append(c.events, "catalogue-delete:"+name)
372 return nil
373}
374
375func (c *moveTabPurgeCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
376 c.mu.Lock()
377 defer c.mu.Unlock()
378 c.events = append(c.events, "metadata:"+update.Name)
379 return nil
380}
381
382func (c *moveTabPurgeCatalogue) Sync() error { return nil }
383func (c *moveTabPurgeCatalogue) Close() error { return nil }
384
385type moveTabPurgeRepository struct {
386 noOpSnapshotRepository
387 d *Daemon
388 source *session
389 destination *session
390 moved *tab
391 pane *pane
392 client *attachedClient
393 catalogue *moveTabPurgeCatalogue
394 err error
395 deleted []domain.IncarnationID
396 outside bool
397 dirty bool
398}
399
400func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.IncarnationID) error {
401 r.deleted = append(r.deleted, id)
402 daemonUnlocked := r.d.mu.TryLock()
403 routingUnlocked := r.d.notices.routingMu.TryLock()
404 sourceUnlocked := r.source.mu.TryLock()
405 destinationUnlocked := r.destination.mu.TryLock()
406 tabUnlocked := r.moved.mu.TryLock()
407 paneUnlocked := r.pane.mu.TryLock()
408 sourceFenceUnlocked := r.source.layoutApplyMu.TryLock()
409 destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock()
410 tabFenceUnlocked := r.moved.layoutApplyMu.TryLock()
411 roleUnlocked := r.client.lifecycle.mu.TryLock()
412 roleStable := roleUnlocked && r.client.lifecycle.phase == attachmentEffectsStable
413 r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked &&
414 sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable
415 if roleUnlocked {
416 r.client.lifecycle.mu.Unlock()
417 }
418 if tabFenceUnlocked {
419 r.moved.layoutApplyMu.Unlock()
420 }
421 if destinationFenceUnlocked {
422 r.destination.layoutApplyMu.Unlock()
423 }
424 if sourceFenceUnlocked {
425 r.source.layoutApplyMu.Unlock()
426 }
427 if paneUnlocked {
428 r.pane.mu.Unlock()
429 }
430 if tabUnlocked {
431 r.moved.mu.Unlock()
432 }
433 if destinationUnlocked {
434 r.destination.mu.Unlock()
435 }
436 if sourceUnlocked {
437 r.source.mu.Unlock()
438 }
439 if routingUnlocked {
440 r.d.notices.routingMu.Unlock()
441 }
442 if daemonUnlocked {
443 r.d.mu.Unlock()
444 }
445 r.destination.snapshotMu.Lock()
446 r.dirty = r.destination.snapDirty.Load() && r.destination.snapshotGeneration > 0
447 r.destination.snapshotMu.Unlock()
448 r.catalogue.mu.Lock()
449 r.catalogue.events = append(r.catalogue.events, "repository-delete")
450 r.catalogue.mu.Unlock()
451 return r.err
452}
453
454func TestMoveTabFinalNamedSourcePurgesAfterDestinationAdmissionAndFailureDoesNotRollback(t *testing.T) {
455 d, source, client, _ := newManualSessionWithPTYs(t, newQuietPTY())
456 require.NotNil(t, d.attachCoordinator(source, nil, client, true))
457 source.incarnation = domain.IncarnationID{4}
458 moved := source.tabs[0]
459 moved.stableID = "moved-tab"
460 destination := addMoveTabTestSession(d, "destination", "destination-tab")
461 destination.ephemeral = false
462 destination.snapEligible.Store(true)
463
464 catalogue := &moveTabPurgeCatalogue{records: map[string]domain.CatalogueRecord{
465 source.name: {Name: source.name, IncarnationID: source.incarnation},
466 destination.name: {Name: destination.name, IncarnationID: destination.incarnation},
467 }}
468 failure := errors.New("repository delete failed")
469 repository := &moveTabPurgeRepository{
470 d: d, source: source, destination: destination, moved: moved, pane: moved.focusedPane(), client: client,
471 catalogue: catalogue, err: failure,
472 }
473 WithCatalogue(catalogue, nil)(d)
474 WithSnapshotRepository(repository)(d)
475 WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
476
477 require.NoError(t, d.moveTab(moveTabRequest{
478 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, SourceTabID: domain.TabStableID(moved.stableID),
479 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
480 }))
481
482 require.Equal(t, []domain.IncarnationID{source.incarnation}, repository.deleted)
483 require.True(t, repository.outside, "repository purge must run outside architecture and owner locks")
484 require.True(t, repository.dirty, "destination snapshot dirtiness must be admitted before purge")
485 catalogue.mu.Lock()
486 require.Equal(t, []string{"metadata:destination", "catalogue-delete:work", "repository-delete"}, catalogue.events)
487 catalogue.mu.Unlock()
488 require.Same(t, moved, destination.tabs[len(destination.tabs)-1], "best-effort purge failure cannot roll back committed membership")
489 require.Same(t, destination, moved.focusedPane().ownerSnapshot().session)
490}
491
492func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *session {
493 ctx, cancel := context.WithCancel(d.serveCtx)
494 tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
495 tb.ctx, tb.cancel = context.WithCancel(ctx)
496 sess := &session{sessionCore: sessionCore{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}}
497 publishTiledPaneOwners(sess, tb)
498 d.mu.Lock()
499 d.sessions[sess.id] = sess
500 d.mu.Unlock()
501 return sess
502}