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