1package daemon
2
3import (
4 "errors"
5 "io"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/require"
12
13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/usecase/layout"
16 "github.com/bnema/vev/internal/usecase/ui"
17 "github.com/bnema/vev/internal/usecase/visualsearch"
18)
19
20// transactionalResizePTY is deliberately channel-scripted: a test can stop a
21// transaction at PTY.Resize without relying on scheduler timing.
22type transactionalResizePTY struct {
23 mu sync.Mutex
24 sizes []domain.Size
25 writeData [][]byte
26 errs []error
27 onResize func()
28 onWrite func([]byte)
29}
30
31func (p *transactionalResizePTY) Resize(size domain.Size) error {
32 p.mu.Lock()
33 p.sizes = append(p.sizes, size)
34 n := len(p.sizes)
35 hook := p.onResize
36 var err error
37 if n <= len(p.errs) {
38 err = p.errs[n-1]
39 }
40 p.mu.Unlock()
41 if hook != nil {
42 hook()
43 }
44 return err
45}
46func (*transactionalResizePTY) Read([]byte) (int, error) { return 0, io.EOF }
47func (p *transactionalResizePTY) Write(b []byte) (int, error) {
48 p.mu.Lock()
49 copied := append([]byte(nil), b...)
50 p.writeData = append(p.writeData, copied)
51 hook := p.onWrite
52 p.mu.Unlock()
53 if hook != nil {
54 hook(copied)
55 }
56 return len(b), nil
57}
58func (*transactionalResizePTY) Close() error { return nil }
59func (*transactionalResizePTY) Pid() int { return 0 }
60func (*transactionalResizePTY) ForegroundPgid() (int, error) { return 0, nil }
61func (p *transactionalResizePTY) requested() []domain.Size {
62 p.mu.Lock()
63 defer p.mu.Unlock()
64 return append([]domain.Size(nil), p.sizes...)
65}
66
67func (p *transactionalResizePTY) writes() [][]byte {
68 p.mu.Lock()
69 defer p.mu.Unlock()
70 writes := make([][]byte, len(p.writeData))
71 for i := range p.writeData {
72 writes[i] = append([]byte(nil), p.writeData[i]...)
73 }
74 return writes
75}
76
77// resizeReaderPTY is a deterministic child: Resize releases its redraw only
78// after the daemon has installed resizeApplying, and waits until ptyReader has
79// accepted that read. It deliberately has no timing dependency.
80type resizeReaderPTY struct {
81 redraw []byte
82 reads chan []byte
83 accepted chan struct{}
84 closed chan struct{}
85 closeOnce sync.Once
86 mu sync.Mutex
87 applying func() bool
88 delivered bool
89}
90
91func newResizeReaderPTY(redraw []byte) *resizeReaderPTY {
92 return &resizeReaderPTY{redraw: redraw, reads: make(chan []byte), accepted: make(chan struct{}), closed: make(chan struct{})}
93}
94
95func (p *resizeReaderPTY) Resize(domain.Size) error {
96 p.reads <- p.redraw
97 <-p.accepted
98 // A second, empty read cannot begin until ptyReader has processed the
99 // redraw from the first read. This is a channel rendezvous, not a timing
100 // assumption, and proves the redraw entered resizePending before Resize
101 // returns to replay it.
102 p.reads <- nil
103 <-p.accepted
104 p.mu.Lock()
105 p.delivered = p.applying != nil && p.applying()
106 p.mu.Unlock()
107 return nil
108}
109func (p *resizeReaderPTY) Read(dst []byte) (int, error) {
110 select {
111 case data := <-p.reads:
112 n := copy(dst, data)
113 p.accepted <- struct{}{}
114 return n, nil
115 case <-p.closed:
116 return 0, io.EOF
117 }
118}
119func (*resizeReaderPTY) Write(b []byte) (int, error) { return len(b), nil }
120func (*resizeReaderPTY) Pid() int { return 0 }
121func (*resizeReaderPTY) ForegroundPgid() (int, error) { return 0, nil }
122func (p *resizeReaderPTY) Close() error { p.close(); return nil }
123func (p *resizeReaderPTY) close() { p.closeOnce.Do(func() { close(p.closed) }) }
124func (p *resizeReaderPTY) deliveredWhileApplying() bool {
125 p.mu.Lock()
126 defer p.mu.Unlock()
127 return p.delivered
128}
129
130func TestReplayResizePendingBuffersSuccessFailureAndBatchOrder(t *testing.T) {
131 t.Run("success resizes then replays", func(t *testing.T) {
132 pty := &transactionalResizePTY{}
133 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
134 tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
135 p.resizeApplying = true
136 p.resizePending = []byte("A\x1b[1;81HB")
137 d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 120, Height: 23})
138 require.Equal(t, 120, p.screen.Frame.Width)
139 require.Equal(t, 'B', p.screen.Frame.At(80, 0).Rune)
140 require.False(t, p.resizeApplying)
141 })
142 t.Run("failure retains old parser width", func(t *testing.T) {
143 pty := &transactionalResizePTY{}
144 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
145 tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
146 p.resizeApplying = true
147 p.resizePending = []byte("\x1b[1;81HB")
148 d.replayResizePending(sess, tb, p, false, domain.Rect{Width: 120, Height: 23})
149 require.Equal(t, 80, p.screen.Frame.Width)
150 require.Equal(t, 'B', p.screen.Frame.At(79, 0).Rune)
151 })
152 t.Run("batches retain read order", func(t *testing.T) {
153 pty := &transactionalResizePTY{}
154 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
155 tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
156 p.screen.OnResponse = func([]byte) { p.ptyResponses = append(p.ptyResponses, 'r') }
157 pty.onWrite = func([]byte) {
158 p.mu.Lock()
159 p.resizePending = append(p.resizePending, 'B')
160 p.mu.Unlock()
161 }
162 p.resizeApplying = true
163 p.resizePending = []byte("A\x1b[6n")
164 d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23})
165 require.Equal(t, "AB", screenLineText(p.screen, 0)[:2])
166 })
167}
168
169func TestProcessPTYDataRetainsCallbacksDuringResizeReplay(t *testing.T) {
170 responses := make(chan []byte, 1)
171 pty := &transactionalResizePTY{onWrite: func(b []byte) { responses <- b }}
172 d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
173 publishActiveClipboardCapability(d, sess, ac, ac.transport())
174 tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
175 p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
176 p.screen.OnBell = func() { p.ptyAttention = true }
177 p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
178 p.resizeApplying = true
179 p.resizePending = []byte("\x1b]2;resized\a\a\x1b]52;c;YQ==\a\x1b[6n")
180 d.replayResizePending(sess, tb, p, true, domain.Rect{Width: 80, Height: 23})
181
182 p.mu.Lock()
183 title := p.title.terminalTitle
184 p.mu.Unlock()
185 require.Equal(t, "resized", title)
186 require.True(t, tb.attention)
187 require.NotEmpty(t, <-responses, "DSR response must be flushed back to the child")
188 // DSR is flushed back to the child and OSC 52 is forwarded through the
189 // normal asynchronous client path, proving replay uses processPTYData.
190 var clipboardOutput string
191 for {
192 frame := awaitFrame(t, sends, ports.MsgOutput)
193 out, err := ports.UnmarshalOutput(frame.Payload)
194 require.NoError(t, err)
195 clipboardOutput = string(out.Data)
196 if strings.Contains(clipboardOutput, "\x1b]52;c;YQ==\a") {
197 break
198 }
199 }
200 require.Contains(t, clipboardOutput, "\x1b]52;c;YQ==\a")
201}
202
203// S3 acceptance: prepare may inspect layout under its locks, but apply must
204// invoke PTY.Resize after all session/tab/pane locks are released. This is the
205// deadlock boundary with PTY readers and scripts which synchronously re-enter
206// daemon state.
207func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) {
208 pty := &transactionalResizePTY{}
209 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
210 tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
211 d.beforeSessionResizePublication = sess.cancel
212
213 _, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
214
215 require.False(t, ok)
216 require.Len(t, pty.requested(), 1, "cancellation must stop before a stale retry applies another PTY resize")
217 tb.mu.Lock()
218 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "canceled session published a tab size")
219 tb.mu.Unlock()
220 p.mu.Lock()
221 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "canceled session published a pane rectangle")
222 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "canceled session published a VT size")
223 p.mu.Unlock()
224}
225
226func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
227 pty := &transactionalResizePTY{}
228 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
229 tb := testAttachmentTab(sess)
230 p := tb.focusedPane()
231 var tabUnlocked, paneUnlocked bool
232 pty.onResize = func() {
233 tabUnlocked = tb.mu.TryLock()
234 if tabUnlocked {
235 tb.mu.Unlock()
236 }
237 paneUnlocked = p.mu.TryLock()
238 if paneUnlocked {
239 p.mu.Unlock()
240 }
241 }
242
243 d.resize(sess, nil, domain.Size{Cols: 100, Rows: 30})
244
245 require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu")
246 require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
247}
248
249// S3 acceptance: a failed member never publishes speculative parser/screen or
250// rectangle state. Successful members and the client layout commit together;
251// composition clips/pads the retained failed screen against that new layout.
252func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) {
253 for _, tc := range []struct {
254 name string
255 failureCount int
256 wantOK bool
257 wantFailed int
258 wantFailureNotice int
259 }{
260 {name: "zero failures", wantOK: true},
261 {name: "one failure", failureCount: 1, wantOK: true, wantFailed: 1, wantFailureNotice: 1},
262 {name: "two failures", failureCount: 2, wantOK: true, wantFailed: 2, wantFailureNotice: 1},
263 } {
264 t.Run(tc.name, func(t *testing.T) {
265 first := &transactionalResizePTY{}
266 second := &transactionalResizePTY{}
267 if tc.failureCount >= 1 {
268 first.errs = []error{errors.New("first failure")}
269 }
270 if tc.failureCount >= 2 {
271 second.errs = []error{errors.New("second failure")}
272 }
273 d, sess, _, _ := newManualSessionWithPTYs(t, first, second)
274
275 failed, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
276
277 require.Equal(t, tc.wantOK, ok)
278 require.Len(t, failed, tc.wantFailed)
279 count := 0
280 for _, notification := range d.notices.history() {
281 if notification.Code == domain.NoticeResizeFailed {
282 count++
283 }
284 }
285 require.Equal(t, tc.wantFailureNotice, count)
286 })
287 }
288}
289
290func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testing.T) {
291 ok := &transactionalResizePTY{}
292 failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
293 d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
294 tb := testAttachmentTab(sess)
295 second := newPane("pane-2", failed, domain.Size{Cols: 80, Rows: 23})
296 tb.mu.Lock()
297 tb.panes[second.id] = second
298 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
299 first := tb.panes["pane-1"]
300 oldFailedRect := second.rect
301 oldFailedScreen := domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height}
302 tb.mu.Unlock()
303
304 d.resize(sess, ac, domain.Size{Cols: 60, Rows: 20})
305
306 require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size, "client layout commits as one epoch")
307 require.Equal(t, domain.Rect{Width: 30, Height: 18}, first.rect)
308 require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, second.rect,
309 "all pane rectangles publish with the committed layout")
310 require.Equal(t, []domain.Size{{Cols: 30, Rows: 18}}, ok.requested())
311 require.Equal(t, []domain.Size{{Cols: 29, Rows: 18}}, failed.requested())
312 require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Frame.Width, Rows: second.screen.Frame.Height},
313 "failed PTY retains its parser/screen size for clipped padded composition")
314 require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout")
315}
316
317// A retry changes VT state after the committed layout, so it needs a distinct
318// snapshot generation. A failed retry must retain the prior screen and dirty
319// generation.
320func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing.T) {
321 resizeErr := errors.New("scripted resize failure")
322 for _, tc := range []struct {
323 name string
324 errs []error
325 wantGeneration uint64
326 wantScreen domain.Size
327 }{
328 {name: "success", errs: []error{resizeErr, nil}, wantGeneration: 2, wantScreen: domain.Size{Cols: 100, Rows: 28}},
329 {name: "failure", errs: []error{resizeErr, resizeErr}, wantGeneration: 1, wantScreen: domain.Size{Cols: 80, Rows: 23}},
330 } {
331 t.Run(tc.name, func(t *testing.T) {
332 pty := &transactionalResizePTY{errs: tc.errs}
333 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
334 sess.snapEligible.Store(true)
335 tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
336
337 require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true))
338 rc := sess.renderCoordinator()
339 snapshot := rc.resizeSnapshot()
340 d.retryResizeMembers(sess, ac, rc.attachmentLease(ac), snapshot.epoch, []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect}})
341
342 sess.snapshotMu.Lock()
343 generation := sess.snapshotGeneration
344 sess.snapshotMu.Unlock()
345 require.Equal(t, tc.wantGeneration, generation)
346 require.True(t, sess.snapDirty.Load())
347 require.Equal(t, tc.wantScreen, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
348 })
349 }
350}
351
352// S3 acceptance: a failed member is retried only for the newest committed
353// epoch. An intervening request supersedes the old retry, and a successful
354// retry must resize its VT before the forced full-frame emission.
355func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) {
356 ok := &transactionalResizePTY{}
357 failed := &transactionalResizePTY{errs: []error{errors.New("first epoch fails"), nil}}
358 d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
359 tb := testAttachmentTab(sess)
360 retry := newPane("retry", failed, domain.Size{Cols: 80, Rows: 23})
361 tb.mu.Lock()
362 tb.panes[retry.id] = retry
363 tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("retry")}}, Focus: "pane-1"}
364 tb.mu.Unlock()
365
366 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) // failed epoch
367 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 34}) // supersedes it
368
369 // The failed pane must not be retried at 49x28 from the obsolete epoch.
370 // S3 retries 59x32, then commits the parser/screen and requests a reset.
371 require.Equal(t, []domain.Size{{Cols: 49, Rows: 28}, {Cols: 59, Rows: 32}}, failed.requested(),
372 "the failed old apply is historical; only the authoritative newest epoch retries it")
373 require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Frame.Width, Rows: retry.screen.Frame.Height})
374 require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().committed,
375 "the retry belongs to the newest committed epoch")
376}
377
378// Stale lifecycle callbacks may never advance a transaction owned by a
379// replacement attachment. The coordinator test clock drives callbacks through
380// channels elsewhere in this package; this ownership assertion is deliberately
381// synchronous so it cannot hide a race behind a sleep.
382func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
383 // Each closure is a callback captured before its lifecycle transition. The
384 // coordinator must reject it, rather than merely coalescing its paint.
385 for _, transition := range []struct {
386 name string
387 run func(*renderCoordinator, *attachedClient, *attachedClient)
388 }{
389 {"detach and attach", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(next) }},
390 {"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }},
391 } {
392 t.Run(transition.name, func(t *testing.T) {
393 rc := newRenderCoordinator(renderCoordinatorOptions{})
394 old, next := &attachedClient{}, &attachedClient{}
395 rc.attach(old)
396 require.Equal(t, uint64(1), rc.recordResizeRequest(domain.Size{Cols: 90, Rows: 24}, old))
397 stale := func() uint64 { return rc.recordResizeRequest(domain.Size{Cols: 20, Rows: 5}, old) }
398 transition.run(rc, old, next)
399 require.Zero(t, stale(), "a stale %s callback must not publish", transition.name)
400 })
401 }
402}
403
404// S3 acceptance: three callbacks can be delivered even after their timers were
405// stopped. Only the newest prepared epoch may apply, commit, and emit one full
406// frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its
407// channels make callback order explicit and do not use wall-clock waits.
408func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) {
409 first, second := &transactionalResizePTY{}, &transactionalResizePTY{}
410 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
411 observer := &daemonRuntimeObserver{}
412 d.runtimeObserver = observer
413 rc := d.attachCoordinator(sess, nil, ac, true)
414 lease := rc.attachmentLease(ac)
415 var newer uint64
416 d.beforeSessionResizePublication = func() {
417 // Final external PTY validation is complete, but epoch admission and
418 // every session-visible publication must still be pending.
419 for _, tb := range sess.tabs {
420 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size)
421 p := tb.focusedPane()
422 p.mu.Lock()
423 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect)
424 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
425 p.mu.Unlock()
426 }
427 require.False(t, sess.snapDirty.Load())
428 for _, mark := range observer.marks {
429 require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind)
430 }
431 newer = rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
432 require.NotZero(t, newer)
433 }
434
435 require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true))
436 d.beforeSessionResizePublication = nil
437 require.False(t, sess.snapDirty.Load(), "rejected epoch must not dirty the snapshot")
438 for _, tb := range sess.tabs {
439 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "rejected epoch published a tab size")
440 p := tb.focusedPane()
441 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "rejected epoch published a pane rectangle")
442 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "rejected epoch published a VT size")
443 }
444
445 require.True(t, d.runResizeTransaction(sess, ac, lease, newer))
446 for _, tb := range sess.tabs {
447 require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size)
448 p := tb.focusedPane()
449 require.Equal(t, domain.Rect{Width: 120, Height: 32}, p.rect)
450 require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
451 }
452 committed := 0
453 for _, mark := range observer.marks {
454 if mark.Kind == ports.RuntimeResizeCommitted {
455 committed++
456 }
457 }
458 require.Equal(t, 1, committed, "only the current epoch emits commit telemetry")
459 require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, first.requested())
460 require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, second.requested())
461}
462
463func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
464 for _, tc := range []struct {
465 name string
466 effect resizeOwnerPostEffect
467 }{
468 {name: "before snapshot effects", effect: resizeOwnerPostSnapshotDirty},
469 {name: "before attachment size", effect: resizeOwnerPostCommitPublication},
470 } {
471 t.Run(tc.name, func(t *testing.T) {
472 pty := &transactionalResizePTY{}
473 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
474 rc := d.attachCoordinator(sess, nil, ac, true)
475 lease := rc.attachmentLease(ac)
476 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
477 require.NotZero(t, epoch)
478 initialSize := ac.size
479
480 entered := make(chan struct{})
481 release := make(chan struct{})
482 d.beforeResizeOwnerPostEffect = func(effect resizeOwnerPostEffect) {
483 if effect != tc.effect {
484 return
485 }
486 close(entered)
487 <-release
488 }
489 done := make(chan bool, 1)
490 go func() { done <- d.runResizeTransaction(sess, ac, lease, epoch) }()
491 <-entered
492 newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
493 require.NotZero(t, newer)
494 close(release)
495 require.False(t, <-done, "stale resize generation must be rejected")
496 require.Equal(t, initialSize, ac.size, "stale resize must not publish attachment size")
497 if tc.effect == resizeOwnerPostSnapshotDirty {
498 require.False(t, sess.snapDirty.Load(), "stale resize must not publish snapshot dirtiness")
499 }
500 })
501 }
502}
503
504func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) {
505 pty := &transactionalResizePTY{}
506 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
507 tb := testAttachmentTab(sess)
508 p := tb.focusedPane()
509 p.resizeApplying = true
510 p.resizePending = []byte("x")
511 plan := preparedTabLayout{members: []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect, screenResized: true}}}
512 tb.mu.Lock()
513 delete(tb.panes, p.id)
514 tb.bumpLayoutGenerationLocked()
515 tb.mu.Unlock()
516
517 d.finishPreparedTabMembers(&plan, false)
518 p.mu.Lock()
519 require.True(t, p.resizeApplying, "stale finalization must leave cancellation to the fresh-plan path")
520 p.mu.Unlock()
521 d.cancelStalePreparedGates(sess, tb, &plan)
522 p.mu.Lock()
523 require.False(t, p.resizeApplying)
524 require.Empty(t, p.resizePending)
525 p.mu.Unlock()
526}
527
528func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
529 pty := &transactionalResizePTY{}
530 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
531 tb := testAttachmentTab(sess)
532 var invalidate sync.Once
533 pty.onResize = func() {
534 invalidate.Do(func() {
535 tb.mu.Lock()
536 tb.bumpLayoutGenerationLocked()
537 tb.mu.Unlock()
538 })
539 }
540
541 require.True(t, d.requestTransactionalResize(sess, nil, domain.Size{Cols: 100, Rows: 30}, true))
542 require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 100, Rows: 28}}, pty.requested())
543 tb.mu.Lock()
544 require.Equal(t, domain.Size{Cols: 100, Rows: 28}, tb.size)
545 tb.mu.Unlock()
546}
547
548func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
549 popupPTY := &transactionalResizePTY{errs: []error{errors.New("initial popup failure")}}
550 d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
551 tb := testAttachmentTab(source)
552 popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
553 tb.mu.Lock()
554 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
555 tb.mu.Unlock()
556 owner := publishPaneOwner(popup, source, tb, 7)
557
558 rc := d.attachCoordinator(source, nil, ac, true)
559 lease := rc.attachmentLease(ac)
560 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
561 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))
562
563 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}}
564 publishPaneOwner(popup, destination, tb, 7)
565 destinationRect := domain.Rect{X: 9, Y: 4, Width: 31, Height: 9}
566 popup.mu.Lock()
567 popup.rect = destinationRect
568 popup.popupGeometry = floatingGeometry{Inner: destinationRect}
569 popup.mu.Unlock()
570 source.snapshotMu.Lock()
571 sourceGeneration := source.snapshotGeneration
572 source.snapshotMu.Unlock()
573 requested := popupPTY.requested()
574
575 d.retryResizeMembers(source, ac, lease, epoch, []resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}})
576
577 require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY")
578 popup.mu.Lock()
579 require.Equal(t, destinationRect, popup.rect, "stale floating retry published source geometry")
580 require.Equal(t, destinationRect, popup.popupGeometry.Inner, "stale floating retry published source popup geometry")
581 popup.mu.Unlock()
582 source.snapshotMu.Lock()
583 require.Equal(t, sourceGeneration, source.snapshotGeneration, "stale floating retry dirtied its former session")
584 source.snapshotMu.Unlock()
585}
586
587func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
588 cfg := domain.FloatingConfig{Width: 100, Height: 100}
589 for _, tc := range []struct {
590 name string
591 from domain.Size
592 to domain.Size
593 wantMode ui.PresentationMode
594 }{
595 {name: "80 to 79", from: domain.Size{Cols: 80, Rows: 25}, to: domain.Size{Cols: 79, Rows: 25}, wantMode: ui.PresentationDrawer},
596 {name: "79 to 80", from: domain.Size{Cols: 79, Rows: 25}, to: domain.Size{Cols: 80, Rows: 25}, wantMode: ui.PresentationFloating},
597 } {
598 t.Run(tc.name, func(t *testing.T) {
599 d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
600 d.ApplyConfig(domain.Config{Floating: cfg})
601 require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true))
602
603 tb := testAttachmentTab(sess)
604 fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg)
605 toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg)
606 entered, release := make(chan struct{}), make(chan struct{})
607 var once sync.Once
608 popupPTY := &transactionalResizePTY{onResize: func() {
609 once.Do(func() { close(entered) })
610 <-release
611 }}
612 popup := newPane("floating", popupPTY, rectSize(fromGeometry.Inner))
613 popup.rect = fromGeometry.Inner
614 popup.popupGeometry = fromGeometry
615 installTestFloating(tb, popup, true)
616
617 done := make(chan bool, 1)
618 go func() { done <- d.requestTransactionalResize(sess, nil, tc.to, true) }()
619 <-entered
620 popup.mu.Lock()
621 require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode")
622 require.Equal(t, fromGeometry.Inner, popup.rect)
623 require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
624 popup.mu.Unlock()
625 close(release)
626 require.True(t, <-done)
627
628 require.Equal(t, []domain.Size{rectSize(toGeometry.Inner)}, popupPTY.requested())
629 popup.mu.Lock()
630 require.Equal(t, tc.wantMode, popup.popupGeometry.Mode)
631 require.Equal(t, toGeometry, popup.popupGeometry)
632 require.Equal(t, toGeometry.Inner, popup.rect)
633 require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
634 popup.mu.Unlock()
635 })
636 }
637}
638
639func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) {
640 cfg := domain.FloatingConfig{Width: 50, Height: 50}
641 committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg)
642 requestedSize := domain.Size{Cols: 79, Rows: 40}
643 requested := calculateContentFloatingGeometry(requestedSize, cfg)
644 require.Equal(t, ui.PresentationFloating, committed.Mode)
645 require.Equal(t, ui.PresentationDrawer, requested.Mode)
646 resizeErr := errors.New("floating resize failed")
647 pty := &transactionalResizePTY{errs: []error{resizeErr}}
648 popup := newPane("floating", pty, rectSize(committed.Inner))
649 popup.rect = committed.Inner
650 popup.popupGeometry = committed
651 tb := newTab(nil, requestedSize)
652 installTestFloating(tb, popup, true)
653 d := newTestDaemon(t, nil, stubClock{})
654 d.ApplyConfig(domain.Config{Floating: cfg})
655 var tabUnlocked, paneUnlocked bool
656 pty.onResize = func() {
657 tabUnlocked = tb.mu.TryLock()
658 if tabUnlocked {
659 tb.mu.Unlock()
660 }
661 paneUnlocked = popup.mu.TryLock()
662 if paneUnlocked {
663 popup.mu.Unlock()
664 }
665 }
666
667 failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
668
669 require.True(t, ok)
670 require.Len(t, failed, 1)
671 require.ErrorIs(t, failed[0].err, resizeErr)
672 require.Equal(t, []domain.Size{rectSize(requested.Inner)}, pty.requested())
673 require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu")
674 require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
675 popup.mu.Lock()
676 require.Equal(t, committed.Inner, popup.rect, "failed PTY resize published a speculative rectangle")
677 require.Equal(t, committed, popup.popupGeometry, "failed PTY resize published speculative popup geometry")
678 require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
679 require.True(t, popup.resizeRetry, "failed PTY resize must remain retryable")
680 require.False(t, popup.resizeApplying, "failed PTY resize must reopen the parser gate")
681 popup.mu.Unlock()
682 tb.mu.Lock()
683 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
684 tb.mu.Unlock()
685 require.True(t, visible)
686 require.Equal(t, committed, inputGeometry, "input mapping must retain committed geometry")
687}
688
689func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t *testing.T) {
690 cfg := domain.FloatingConfig{Width: 50, Height: 50}
691 size := domain.Size{Cols: 79, Rows: 20}
692 requested := calculateContentFloatingGeometry(size, cfg)
693 require.Equal(t, ui.PresentationDrawer, requested.Mode)
694 committed := requested
695 committed.Mode = ui.PresentationFloating
696 committed.Bounds.X++
697 committed.Inner.X++
698 pty := &transactionalResizePTY{}
699 popup := newPane("floating", pty, rectSize(committed.Inner))
700 popup.rect = committed.Inner
701 popup.popupGeometry = committed
702 tb := newTab(nil, size)
703 installTestFloating(tb, popup, true)
704 d := newTestDaemon(t, nil, stubClock{})
705 d.ApplyConfig(domain.Config{Floating: cfg})
706
707 failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
708
709 require.True(t, ok)
710 require.Empty(t, failed)
711 require.Empty(t, pty.requested(), "same-size geometry publication must not resize the PTY")
712 popup.mu.Lock()
713 require.Equal(t, requested.Inner, popup.rect)
714 require.Equal(t, requested, popup.popupGeometry)
715 require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
716 require.False(t, popup.resizeRetry)
717 popup.mu.Unlock()
718}
719
720func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) {
721 cfg := domain.FloatingConfig{Width: 100, Height: 100}
722 committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
723 pty := &transactionalResizePTY{}
724 popup := newPane("floating", pty, rectSize(committed.Inner))
725 popup.rect = committed.Inner
726 popup.popupGeometry = committed
727 tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
728 installTestFloating(tb, popup, true)
729 d := newTestDaemon(t, nil, stubClock{})
730 d.ApplyConfig(domain.Config{Floating: cfg})
731 calls := 0
732
733 failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool {
734 calls++
735 return calls == 1
736 })
737
738 require.True(t, ok, "a publication accepted while current must not later report stale")
739 require.Equal(t, 1, calls, "current must not be rechecked after publication")
740 require.Empty(t, failed)
741 require.Empty(t, pty.requested())
742 requested := calculateContentFloatingGeometry(tb.size, cfg)
743 popup.mu.Lock()
744 require.Equal(t, requested, popup.popupGeometry)
745 require.Equal(t, committed.Inner, popup.rect)
746 require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
747 popup.mu.Unlock()
748}
749
750func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t *testing.T) {
751 cfg := domain.FloatingConfig{Width: 100, Height: 100}
752 committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
753 pty := &transactionalResizePTY{}
754 popup := newPane("floating", pty, rectSize(committed.Inner))
755 popup.rect = committed.Inner
756 popup.popupGeometry = committed
757 tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
758 installTestFloating(tb, popup, true)
759 d := newTestDaemon(t, nil, stubClock{})
760 d.ApplyConfig(domain.Config{Floating: cfg})
761
762 failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { return false })
763
764 require.False(t, ok)
765 require.Empty(t, failed)
766 require.Empty(t, pty.requested())
767 popup.mu.Lock()
768 require.Equal(t, committed, popup.popupGeometry)
769 require.Equal(t, committed.Inner, popup.rect)
770 require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
771 popup.mu.Unlock()
772}
773
774func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
775 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
776 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
777 tb := testAttachmentTab(sess)
778 popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
779 tb.mu.Lock()
780 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
781 tb.mu.Unlock()
782
783 rc := d.attachCoordinator(sess, nil, ac, true)
784 lease := rc.attachmentLease(ac)
785 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
786 require.NotZero(t, epoch)
787 require.True(t, d.runResizeTransaction(sess, ac, lease, epoch))
788 first := popupPTY.requested()
789 require.Len(t, first, 1)
790
791 d.retryResizeMembers(sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
792 retried := popupPTY.requested()
793 require.Len(t, retried, 2)
794 require.Equal(t, first[0], retried[1], "retry must use the current validated floating slot geometry")
795 popup.mu.Lock()
796 require.False(t, popup.resizeRetry)
797 popup.mu.Unlock()
798
799 replacementPTY := &transactionalResizePTY{}
800 replacement := newPane("replacement", replacementPTY, domain.Size{Cols: 80, Rows: 23})
801 replacement.resizeRetry = true
802 tb.mu.Lock()
803 tb.floating = floatingSlot{state: floatingVisible, pane: replacement, generation: 8}
804 tb.mu.Unlock()
805 d.retryResizeMembers(sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
806 require.Empty(t, replacementPTY.requested(), "a replaced floating slot must not inherit an old retry")
807}
808
809func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) {
810 clock := newCoordinatorMockClock(t, 8)
811 pty := &transactionalResizePTY{}
812 d, sess, ac, frames := newManualSessionWithPTYs(t, pty)
813 d.clock = clock.clock
814
815 for _, size := range []domain.Size{{Cols: 90, Rows: 24}, {Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}} {
816 d.resize(sess, ac, size)
817 }
818 // Epoch scheduling leaves PTY geometry untouched until the final
819 // prepare/apply/commit callback.
820 require.Empty(t, pty.requested(), "obsolete requests must not resize the PTY")
821
822 var timers []*coordinatorMockTimer
823 for len(timers) < 3 {
824 timers = append(timers, <-clock.timers)
825 }
826 done := captureResizeCallbackDone(t, sess.renderCoordinator())
827 for _, timer := range timers {
828 timer.ch <- time.Time{}
829 }
830 awaitTestCompletion(t, done, "latest resize callback did not complete")
831 require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())
832 snapshot := sess.renderCoordinator().resizeSnapshot()
833 require.Equal(t, uint64(3), snapshot.epoch)
834 require.Equal(t, snapshot.epoch, snapshot.committed)
835 // The resize deadline is the only debounce: commit fires its sticky reset
836 // through S2 immediately (subject to ACK/sync gates).
837 frame := <-frames
838 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
839 output, err := ports.UnmarshalOutput(frame.Payload)
840 require.NoError(t, err)
841 require.Zero(t, output.Base, "the accepted resize frame must reset the renderer state")
842 select {
843 case extra := <-frames:
844 t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)
845 default:
846 }
847}
848
849// Entering copy mode is invalidated at transaction prepare time, while a
850// blocked apply keeps the old committed pane rectangle visible to mouse and
851// composition. Channels, rather than a delay, make the publication boundary
852// deterministic.
853func TestTransactionalResizeFloatingMouseCopyAndSearchContract(t *testing.T) {
854 for _, tc := range []struct {
855 name string
856 errs []error
857 }{
858 {name: "success"},
859 {name: "partial failure", errs: []error{errors.New("resize failed")}},
860 } {
861 t.Run(tc.name, func(t *testing.T) {
862 entered, release := make(chan struct{}), make(chan struct{})
863 pty := &transactionalResizePTY{errs: tc.errs, onResize: func() { close(entered); <-release }}
864 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
865 tb := testAttachmentTab(sess)
866 p := tb.focusedPane()
867 old := p.rect
868 d.enterCopyMode(sess, ac)
869 ac.overlays.copyMu.Lock()
870 ac.overlays.copySearch = &visualsearch.Model{}
871 ac.overlays.copyMu.Unlock()
872 done := make(chan struct{})
873 go func() {
874 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
875 close(done)
876 }()
877 <-entered
878 require.False(t, ac.overlays.copyActive(), "prepare invalidates copy mode")
879 require.False(t, ac.overlays.copySearchActive(), "prepare invalidates visual search")
880 require.Equal(t, old, p.rect, "mouse uses old committed geometry before publication")
881 close(release)
882 <-done
883 })
884 }
885}