1package daemon
2
3import (
4 "sync"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/vev/internal/usecase/layout"
11)
12
13func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) {
14 t.Helper()
15 require.Eventually(t, func() bool {
16 if !mu.TryLock() {
17 return true
18 }
19 mu.Unlock()
20 return false
21 }, time.Second, time.Millisecond, message)
22}
23
24func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) {
25 first := &session{sessionCore: sessionCore{id: "a-first"}}
26 second := &session{sessionCore: sessionCore{id: "z-second"}}
27 fences := newMoveResizeFences([]*session{second, first}, nil, nil)
28
29 first.layoutApplyMu.Lock()
30 acquired := make(chan bool, 1)
31 go func() {
32 acquired <- fences.acquire(func() bool { return true })
33 }()
34
35 require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later session before the lowest SessionID")
36 second.layoutApplyMu.Unlock()
37 first.layoutApplyMu.Unlock()
38
39 require.True(t, awaitTestValue(t, acquired, "ordered resize fences did not acquire"))
40 fences.Release()
41 require.True(t, first.layoutApplyMu.TryLock())
42 first.layoutApplyMu.Unlock()
43 require.True(t, second.layoutApplyMu.TryLock())
44 second.layoutApplyMu.Unlock()
45}
46
47func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) {
48 sess := &session{sessionCore: sessionCore{id: "session"}}
49 first := &tab{stableID: "a-first"}
50 second := &tab{stableID: "z-second"}
51 fences := newMoveResizeFences([]*session{sess}, []*tab{second, first}, nil)
52
53 first.layoutApplyMu.Lock()
54 acquired := make(chan bool, 1)
55 go func() {
56 acquired <- fences.acquire(func() bool { return true })
57 }()
58
59 requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before waiting on a tab fence")
60 require.True(t, second.layoutApplyMu.TryLock(), "acquisition reached the later tab before the lowest stable tab ID")
61 second.layoutApplyMu.Unlock()
62 select {
63 case <-acquired:
64 t.Fatal("resize fences ignored the blocked first tab")
65 default:
66 }
67 first.layoutApplyMu.Unlock()
68
69 require.True(t, awaitTestValue(t, acquired, "ordered tab resize fences did not acquire"))
70 fences.Release()
71}
72
73func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) {
74 sess := &session{sessionCore: sessionCore{id: "session"}}
75 tb := &tab{stableID: "tab"}
76 first := &pane{stableID: "a-first"}
77 second := &pane{stableID: "z-second"}
78 fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{second, first})
79
80 first.resizeMu.Lock()
81 acquired := make(chan bool, 1)
82 go func() {
83 acquired <- fences.acquire(func() bool { return true })
84 }()
85
86 requireMutexLocked(t, &sess.layoutApplyMu, "session fence was not acquired before pane wait")
87 requireMutexLocked(t, &tb.layoutApplyMu, "tab fence was not acquired before pane wait")
88 require.True(t, second.resizeMu.TryLock(), "acquisition reached the later pane before the lowest stable pane ID")
89 second.resizeMu.Unlock()
90 select {
91 case <-acquired:
92 t.Fatal("resize fences ignored the blocked first pane")
93 default:
94 }
95 first.resizeMu.Unlock()
96
97 require.True(t, awaitTestValue(t, acquired, "ordered pane resize fences did not acquire"))
98 fences.Release()
99}
100
101func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) {
102 sess := &session{sessionCore: sessionCore{id: "session"}}
103 tb := &tab{stableID: "tab"}
104 p := &pane{stableID: "pane"}
105
106 fences := newMoveResizeFences([]*session{sess, sess}, []*tab{tb, tb}, []*pane{p, p})
107
108 require.Len(t, fences.sessions, 1)
109 require.Len(t, fences.tabs, 1)
110 require.Len(t, fences.panes, 1)
111 require.True(t, fences.acquire(func() bool { return true }))
112 fences.Release()
113}
114
115func TestMoveResizeFencesDeduplicateSessionIDWithoutReplacingResolvedOwner(t *testing.T) {
116 resolved := &session{sessionCore: sessionCore{id: "same"}}
117 collision := &session{sessionCore: sessionCore{id: "same"}}
118
119 fences := newMoveResizeFences([]*session{resolved, collision}, nil, nil)
120
121 require.Len(t, fences.sessions, 1)
122 require.Same(t, resolved, fences.sessions[0])
123}
124
125func TestMoveResizeFencesDeduplicateTabIDWithoutReplacingResolvedOwner(t *testing.T) {
126 resolved := &tab{stableID: "same"}
127 collision := &tab{stableID: "same"}
128
129 fences := newMoveResizeFences(nil, []*tab{resolved, collision}, nil)
130
131 require.Len(t, fences.tabs, 1)
132 require.Same(t, resolved, fences.tabs[0])
133}
134
135func TestMoveResizeFencesDeduplicatePaneIDWithoutReplacingResolvedOwner(t *testing.T) {
136 resolved := &pane{stableID: "same"}
137 collision := &pane{stableID: "same"}
138
139 fences := newMoveResizeFences(nil, nil, []*pane{resolved, collision})
140
141 require.Len(t, fences.panes, 1)
142 require.Same(t, resolved, fences.panes[0])
143}
144
145func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) {
146 sess := &session{sessionCore: sessionCore{id: "session"}}
147 tb := &tab{stableID: "tab"}
148 p := &pane{stableID: "pane"}
149 fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
150
151 membershipCurrent := true
152 p.resizeMu.Lock()
153 acquired := make(chan bool, 1)
154 callbackCalled := make(chan struct{})
155 go func() {
156 acquired <- fences.acquire(func() bool {
157 close(callbackCalled)
158 return membershipCurrent
159 })
160 }()
161 requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane")
162 membershipCurrent = false
163 p.resizeMu.Unlock()
164
165 require.False(t, awaitTestValue(t, acquired, "stale resize fence acquisition did not return"))
166 awaitTestCompletion(t, callbackCalled, "membership was not revalidated after resize fence acquisition")
167 for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} {
168 require.True(t, lock.TryLock(), "rejected acquisition leaked a resize fence")
169 lock.Unlock()
170 }
171}
172
173func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) {
174 sess := &session{sessionCore: sessionCore{id: "session"}}
175 tb := &tab{stableID: "tab"}
176 p := &pane{stableID: "pane"}
177 fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
178
179 require.Panics(t, func() {
180 fences.acquire(func() bool { panic("publication failed") })
181 })
182 for _, lock := range []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu} {
183 require.True(t, lock.TryLock(), "panicked publication leaked a partial resize fence set")
184 lock.Unlock()
185 }
186}
187
188func TestPublishResizeOwnerPostEffectReleasesAllFencesWhenCallbackPanics(t *testing.T) {
189 d := &Daemon{}
190 sess := &session{sessionCore: sessionCore{id: "session"}}
191 tb := &tab{stableID: "tab"}
192 p := &pane{stableID: "pane"}
193 members := []resizeMember{{session: sess, tab: tb, pane: p}}
194
195 var recovered any
196 func() {
197 defer func() { recovered = recover() }()
198 d.publishResizeOwnerPostEffect(members, resizeOwnerPostSnapshotDirty, func() {
199 panic("post effect failed")
200 })
201 }()
202 require.Equal(t, "post effect failed", recovered)
203
204 for _, fence := range []struct {
205 name string
206 mu *sync.Mutex
207 }{
208 {name: "session", mu: &sess.layoutApplyMu},
209 {name: "tab", mu: &tb.layoutApplyMu},
210 {name: "pane", mu: &p.resizeMu},
211 } {
212 require.True(t, fence.mu.TryLock(), "%s fence leaked after panic recovery", fence.name)
213 fence.mu.Unlock()
214 }
215}
216
217func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) {
218 d := &Daemon{}
219 sess := &session{sessionCore: sessionCore{id: "session"}}
220 tb := &tab{stableID: "tab"}
221 p := &pane{stableID: "pane"}
222 fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
223
224 p.resizeMu.Lock()
225 acquired := make(chan bool, 1)
226 go func() {
227 acquired <- fences.acquire(func() bool { return true })
228 }()
229 requireMutexLocked(t, &tb.layoutApplyMu, "resize fence acquisition did not reach the blocked pane")
230
231 for name, lock := range map[string]*sync.Mutex{
232 "daemon": &d.mu,
233 "session": &sess.mu,
234 "tab": &tb.mu,
235 } {
236 require.True(t, lock.TryLock(), "%s architecture lock was held while waiting on pane resize", name)
237 lock.Unlock()
238 }
239 p.resizeMu.Unlock()
240
241 require.True(t, awaitTestValue(t, acquired, "resize fences did not acquire after pane resize released"))
242 fences.Release()
243}
244
245func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) {
246 source := &session{sessionCore: sessionCore{id: "source"}}
247 destination := &session{sessionCore: sessionCore{id: "destination"}}
248 sourceTab := &tab{stableID: "a-source-tab"}
249 destinationTab := &tab{stableID: "z-destination-tab"}
250 p := &pane{stableID: "pane"}
251 fences := newMoveResizeFences(
252 []*session{source, destination},
253 []*tab{sourceTab, destinationTab},
254 []*pane{p},
255 )
256
257 require.True(t, fences.acquire(func() bool {
258 // This callback is the move's architecture-lock window: membership is
259 // revalidated before owner publication and both affected generations bump.
260 sourceTab.mu.Lock()
261 destinationTab.mu.Lock()
262 sourceTab.bumpLayoutGenerationLocked()
263 destinationTab.bumpLayoutGenerationLocked()
264 destinationTab.mu.Unlock()
265 sourceTab.mu.Unlock()
266 return true
267 }))
268 require.Equal(t, uint64(1), sourceTab.layoutGeneration)
269 require.Equal(t, uint64(1), destinationTab.layoutGeneration)
270 for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} {
271 require.False(t, lock.TryLock(), "publication returned without retaining every resize fence")
272 }
273
274 // Move callers release here, before applyTabLayout or any other PTY I/O.
275 fences.Release()
276 for _, lock := range []*sync.Mutex{&source.layoutApplyMu, &destination.layoutApplyMu, &sourceTab.layoutApplyMu, &destinationTab.layoutApplyMu, &p.resizeMu} {
277 require.True(t, lock.TryLock(), "publication fence did not release before layout application")
278 lock.Unlock()
279 }
280}
281
282func TestMovePaneResizeFencesCoverAffectedOwners(t *testing.T) {
283 source := &session{sessionCore: sessionCore{id: "source"}}
284 destination := &session{sessionCore: sessionCore{id: "destination"}}
285 sourceTab := &tab{stableID: "source-tab"}
286 destinationTab := &tab{stableID: "destination-tab"}
287 moved := &pane{stableID: "moved-pane"}
288
289 fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, moved)
290
291 require.ElementsMatch(t, []*session{source, destination}, fences.sessions)
292 require.ElementsMatch(t, []*tab{sourceTab, destinationTab}, fences.tabs)
293 require.Equal(t, []*pane{moved}, fences.panes)
294}
295
296func TestMoveTabResizeFencesCoverEveryContainedPane(t *testing.T) {
297 for _, tc := range []struct {
298 name string
299 state floatingState
300 }{{name: "hidden", state: floatingHidden}, {name: "visible", state: floatingVisible}} {
301 t.Run(tc.name, func(t *testing.T) {
302 source := &session{sessionCore: sessionCore{id: "source"}}
303 destination := &session{sessionCore: sessionCore{id: "destination"}}
304 first := &pane{stableID: "first"}
305 second := &pane{stableID: "second"}
306 floating := &pane{stableID: "floating"}
307 movedTab := &tab{
308 stableID: "moved-tab",
309 panes: map[layout.PaneID]*pane{
310 "pane-1": first,
311 "pane-2": second,
312 },
313 floating: floatingSlot{state: tc.state, pane: floating, generation: 7},
314 }
315
316 fences := newMoveTabResizeFences(source, destination, movedTab)
317
318 require.ElementsMatch(t, []*session{source, destination}, fences.sessions)
319 require.Equal(t, []*tab{movedTab}, fences.tabs)
320 require.ElementsMatch(t, []*pane{first, second, floating}, fences.panes)
321 })
322 }
323}