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