navigation_attachment_handoff_test.go RawHistoryFolder
1package daemon
2
3import (
4 "sync"
5 "testing"
6 "time"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/domain/layout"
10 "github.com/bnema/vev/internal/protocol"
11 "github.com/bnema/vev/internal/usecase/picker"
12 "github.com/stretchr/testify/require"
13)
14
15func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
16 tests := []struct {
17 name string
18 releasedAction string
19 wantErr bool
20 run func(*Daemon, *session, *session, *attachedClient, *attachmentEffect) error
21 check func(*testing.T, *Daemon, *session, *session)
22 }{
23 {
24 name: "palette session", releasedAction: "detach",
25 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffect) error {
26 return d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
27 },
28 },
29 {
30 name: "session overflow", releasedAction: "detach",
31 run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffect) error {
32 source.mu.Lock()
33 source.name = "alpha"
34 source.mu.Unlock()
35 target.mu.Lock()
36 target.name = "charlie"
37 target.mu.Unlock()
38 d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
39 return d.focusDir(source, ac, layout.Down, effect)
40 },
41 },
42 {
43 name: "stopped session resume", releasedAction: "detach",
44 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffect) error {
45 d.mu.Lock()
46 delete(d.sessions, target.id)
47 d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 9, incarnation: domain.IncarnationID{2}, state: protocol.SessionDown}
48 d.mu.Unlock()
49 expectedCreatedAt := int64(9)
50 return d.switchToTargetForAttachment(effect, picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
51 },
52 check: func(t *testing.T, d *Daemon, _, _ *session) {
53 d.mu.Lock()
54 _, stopped := d.inactive["stopped"]
55 resumed := d.findByNameLocked("stopped")
56 d.mu.Unlock()
57 require.True(t, stopped)
58 require.Nil(t, resumed)
59 },
60 },
61 {
62 name: "transition prompt creation", releasedAction: "create-session", wantErr: true,
63 run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffect) error {
64 return d.createSessionAndSwitchForAttachment(effect, "created")
65 },
66 check: func(t *testing.T, d *Daemon, _, _ *session) {
67 d.mu.Lock()
68 created := d.findByNameLocked("created")
69 d.mu.Unlock()
70 require.Nil(t, created, "stale prompt submission created a target session")
71 },
72 },
73 }
74
75 for _, tt := range tests {
76 t.Run(tt.name, func(t *testing.T) {
77 d, source, old, _, releases := newManualTabSession(t, 2)
78 defer releaseAll(releases)
79 d.ptys = newFactorySeq(t)
80 target := &session{sessionCore: sessionCore{id: "target", name: "target", incarnation: domain.SessionLifecycleID{1}}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
81 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
82 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
83 }}
84 d.mu.Lock()
85 d.sessions[target.id] = target
86 d.mu.Unlock()
87
88 rc := d.attachCoordinator(source, nil, old, true)
89 token := source.captureAttachmentCapability(old, old.transport())
90 token.lease = rc.attachmentLease(old)
91 old.installTestAttachmentCapability(token)
92 effect, admitted := old.beginAttachmentEffect(token)
93 require.True(t, admitted)
94
95 admissionEnded := make(chan struct{})
96 releaseAction := make(chan struct{})
97 var admissionEndedOnce sync.Once
98 d.afterActionAttachmentEffectEnded = func(action string) {
99 if action == tt.releasedAction {
100 admissionEndedOnce.Do(func() { close(admissionEnded) })
101 <-releaseAction
102 }
103 }
104 actionDone := make(chan error, 1)
105 go func() { actionDone <- tt.run(d, source, target, old, effect) }()
106 select {
107 case <-admissionEnded:
108 case <-time.After(time.Second):
109 t.Fatal("handoff did not release its role ticket")
110 }
111
112 replacement, err := d.transitionAttachment(attachmentTransitionRequest{
113 source: source, target: source, next: old,
114
115 expectedTransport: old.transportSnapshot(), ready: true,
116 })
117 require.NoError(t, err)
118 close(releaseAction)
119 actionErr := <-actionDone
120 if tt.wantErr {
121 require.Error(t, actionErr)
122 } else {
123 require.NoError(t, actionErr)
124 }
125
126 require.Same(t, source, old.currentSession())
127 source.mu.Lock()
128 require.Contains(t, source.snapshotAttachmentsLocked(), old)
129 source.mu.Unlock()
130 target.mu.Lock()
131 require.Empty(t, target.snapshotAttachmentsLocked(), "stale handoff attached to the target")
132 require.Zero(t, testAttachmentTabIndexLocked(target), "stale handoff changed target tab focus")
133 target.mu.Unlock()
134 if tt.check != nil {
135 tt.check(t, d, source, target)
136 }
137 d.deferAttachmentTransitionCleanups(replacement)
138 })
139 }
140}
141
142func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) {
143 d, source, old, _ := newManualSessionWithPTYs(t, nil)
144 d.mu.Lock()
145 d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 7, incarnation: domain.IncarnationID{3}, state: protocol.SessionDown}
146 d.mu.Unlock()
147
148 rc := d.attachCoordinator(source, nil, old, true)
149 token := source.captureAttachmentCapability(old, old.transport())
150 token.lease = rc.attachmentLease(old)
151 old.installTestAttachmentCapability(token)
152 effect, admitted := old.beginAttachmentEffect(token)
153 require.True(t, admitted)
154
155 ended := make(chan struct{})
156 release := make(chan struct{})
157 var endedOnce sync.Once
158 d.afterActionAttachmentEffectEnded = func(action string) {
159 if action == "detach" {
160 endedOnce.Do(func() { close(ended) })
161 <-release
162 }
163 }
164 expectedCreatedAt := int64(7)
165 done := make(chan error, 1)
166 go func() {
167 done <- d.switchToTargetForAttachment(effect, picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped")
168 }()
169 select {
170 case <-ended:
171 case <-time.After(time.Second):
172 t.Fatal("stopped-session handoff did not release its role ticket")
173 }
174
175 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), geometry: domain.Geometry{Size: old.geometry.Size}}
176 next.initOverlays()
177 replacement, err := d.transitionAttachment(attachmentTransitionRequest{
178 target: source, next: next, expectedTransport: next.transportSnapshot(), ready: true,
179 })
180 require.NoError(t, err)
181 close(release)
182 require.NoError(t, <-done)
183
184 d.mu.Lock()
185 _, stillStopped := d.inactive["stopped"]
186 resumed := d.findByNameLocked("stopped")
187 d.mu.Unlock()
188 require.True(t, stillStopped)
189 require.Nil(t, resumed)
190 d.deferAttachmentTransitionCleanups(replacement)
191}