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/domain/layout"
15 "github.com/bnema/vev/internal/ports"
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(geometry domain.Geometry) error {
32 p.mu.Lock()
33 p.sizes = append(p.sizes, geometry.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.Geometry) 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.Columns())
139 require.Equal(t, 'B', p.screen.Cell(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.Columns())
150 require.Equal(t, 'B', p.screen.Cell(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, "Output")
193 out := unmarshalTestOutput(t, frame.Payload)
194 clipboardOutput = string(out.Data)
195 if strings.Contains(clipboardOutput, "\x1b]52;c;YQ==\a") {
196 break
197 }
198 }
199 require.Contains(t, clipboardOutput, "\x1b]52;c;YQ==\a")
200}
201
202// S3 acceptance: prepare may inspect layout under its locks, but apply must
203// invoke PTY.Resize after all session/tab/pane locks are released. This is the
204// deadlock boundary with PTY readers and scripts which synchronously re-enter
205// daemon state.
206func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) {
207 pty := &transactionalResizePTY{}
208 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
209 tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
210 d.beforeSessionResizePublication = sess.cancel
211
212 _, ok := sess.geometry.applySessionLayout(d, sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
213
214 require.False(t, ok)
215 require.Len(t, pty.requested(), 1, "cancellation must stop before a stale retry applies another PTY resize")
216 tb.mu.Lock()
217 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "canceled session published a tab size")
218 tb.mu.Unlock()
219 p.mu.Lock()
220 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "canceled session published a pane rectangle")
221 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "canceled session published a VT size")
222 p.mu.Unlock()
223}
224
225func TestTransactionalResizeApplyDoesNotHoldTabOrPaneLocks(t *testing.T) {
226 pty := &transactionalResizePTY{}
227 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
228 tb := testAttachmentTab(sess)
229 p := tb.focusedPane()
230 var tabUnlocked, paneUnlocked bool
231 pty.onResize = func() {
232 tabUnlocked = tb.mu.TryLock()
233 if tabUnlocked {
234 tb.mu.Unlock()
235 }
236 paneUnlocked = p.mu.TryLock()
237 if paneUnlocked {
238 p.mu.Unlock()
239 }
240 }
241
242 d.resize(sess, nil, domain.Size{Cols: 100, Rows: 30})
243
244 require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu")
245 require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
246}
247
248// S3 acceptance: a failed member never publishes speculative parser/screen or
249// rectangle state. Successful members and the client layout commit together;
250// composition clips/pads the retained failed screen against that new layout.
251func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) {
252 for _, tc := range []struct {
253 name string
254 failureCount int
255 wantOK bool
256 wantFailed int
257 wantFailureNotice int
258 }{
259 {name: "zero failures", wantOK: true},
260 {name: "one failure", failureCount: 1, wantOK: true, wantFailed: 1, wantFailureNotice: 1},
261 {name: "two failures", failureCount: 2, wantOK: true, wantFailed: 2, wantFailureNotice: 1},
262 } {
263 t.Run(tc.name, func(t *testing.T) {
264 first := &transactionalResizePTY{}
265 second := &transactionalResizePTY{}
266 if tc.failureCount >= 1 {
267 first.errs = []error{errors.New("first failure")}
268 }
269 if tc.failureCount >= 2 {
270 second.errs = []error{errors.New("second failure")}
271 }
272 d, sess, _, _ := newManualSessionWithPTYs(t, first, second)
273
274 failed, ok := sess.geometry.applySessionLayout(d, sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
275
276 require.Equal(t, tc.wantOK, ok)
277 require.Len(t, failed, tc.wantFailed)
278 count := 0
279 for _, notification := range d.notices.history() {
280 if notification.Code == domain.NoticeResizeFailed {
281 count++
282 }
283 }
284 require.Equal(t, tc.wantFailureNotice, count)
285 })
286 }
287}
288
289func TestTransactionalResizePartialFailureCommitsOnlySuccessfulPTYState(t *testing.T) {
290 ok := &transactionalResizePTY{}
291 failed := &transactionalResizePTY{errs: []error{errors.New("scripted resize failure")}}
292 d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
293 tb := testAttachmentTab(sess)
294 second := newPane("pane-2", failed, domain.Size{Cols: 80, Rows: 23})
295 tb.mu.Lock()
296 tb.panes[second.id] = second
297 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"}
298 first := tb.panes["pane-1"]
299 oldFailedRect := second.rect
300 oldFailedScreen := domain.Size{Cols: second.screen.Columns(), Rows: second.screen.Rows()}
301 tb.mu.Unlock()
302
303 d.resize(sess, ac, domain.Size{Cols: 60, Rows: 20})
304
305 require.Equal(t, domain.Size{Cols: 60, Rows: 18}, tb.size, "client layout commits as one epoch")
306 require.Equal(t, domain.Rect{Width: 30, Height: 18}, first.rect)
307 require.Equal(t, domain.Rect{X: 31, Width: 29, Height: 18}, second.rect,
308 "all pane rectangles publish with the committed layout")
309 require.Equal(t, []domain.Size{{Cols: 30, Rows: 18}}, ok.requested())
310 require.Equal(t, []domain.Size{{Cols: 29, Rows: 18}}, failed.requested())
311 require.Equal(t, oldFailedScreen, domain.Size{Cols: second.screen.Columns(), Rows: second.screen.Rows()},
312 "failed PTY retains its parser/screen size for clipped padded composition")
313 require.NotEqual(t, oldFailedRect, second.rect, "failed PTY rectangle still follows the committed layout")
314}
315
316// A retry changes VT state after the committed layout, so it needs a distinct
317// snapshot generation. A failed retry must retain the prior screen and dirty
318// generation.
319func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing.T) {
320 resizeErr := errors.New("scripted resize failure")
321 for _, tc := range []struct {
322 name string
323 errs []error
324 wantGeneration uint64
325 wantScreen domain.Size
326 }{
327 {name: "success", errs: []error{resizeErr, nil}, wantGeneration: 2, wantScreen: domain.Size{Cols: 100, Rows: 28}},
328 {name: "failure", errs: []error{resizeErr, resizeErr}, wantGeneration: 1, wantScreen: domain.Size{Cols: 80, Rows: 23}},
329 } {
330 t.Run(tc.name, func(t *testing.T) {
331 pty := &transactionalResizePTY{errs: tc.errs}
332 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
333 sess.snapEligible.Store(true)
334 tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
335
336 require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 100, Rows: 30}, true))
337 rc := sess.renderCoordinator()
338 snapshot := rc.resizeSnapshot()
339 attachmentResize{d: d, sess: sess, ac: ac, lease: rc.attachmentLease(ac), epoch: snapshot.epoch}.retry([]resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect}})
340
341 sess.snapshotMu.Lock()
342 generation := sess.snapshotGeneration
343 sess.snapshotMu.Unlock()
344 require.Equal(t, tc.wantGeneration, generation)
345 require.True(t, sess.snapDirty.Load())
346 require.Equal(t, tc.wantScreen, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
347 })
348 }
349}
350
351// S3 acceptance: a failed member is retried only for the newest committed
352// epoch. An intervening request supersedes the old retry, and a successful
353// retry must resize its VT before the forced full-frame emission.
354func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) {
355 ok := &transactionalResizePTY{}
356 failed := &transactionalResizePTY{errs: []error{errors.New("first epoch fails"), nil}}
357 d, sess, ac, _ := newManualSessionWithPTYs(t, ok)
358 tb := testAttachmentTab(sess)
359 retry := newPane("retry", failed, domain.Size{Cols: 80, Rows: 23})
360 tb.mu.Lock()
361 tb.panes[retry.id] = retry
362 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"}
363 tb.mu.Unlock()
364
365 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) // failed epoch
366 d.resize(sess, ac, domain.Size{Cols: 120, Rows: 34}) // supersedes it
367
368 // The failed pane must not be retried at 49x28 from the obsolete epoch.
369 // S3 retries 59x32, then commits the parser/screen and requests a reset.
370 require.Equal(t, []domain.Size{{Cols: 49, Rows: 28}, {Cols: 59, Rows: 32}}, failed.requested(),
371 "the failed old apply is historical; only the authoritative newest epoch retries it")
372 require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Columns(), Rows: retry.screen.Rows()})
373 require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().committed,
374 "the retry belongs to the newest committed epoch")
375}
376
377// Stale lifecycle callbacks may never advance a transaction owned by a
378// replacement attachment. The coordinator test clock drives callbacks through
379// channels elsewhere in this package; this ownership assertion is deliberately
380// synchronous so it cannot hide a race behind a sleep.
381func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
382 // Each closure is a callback captured before its lifecycle transition. The
383 // coordinator must reject it, rather than merely coalescing its paint.
384 for _, transition := range []struct {
385 name string
386 run func(*renderCoordinator, *attachedClient, *attachedClient)
387 }{
388 {"detach and attach", func(rc *renderCoordinator, old, next *attachedClient) { rc.noteDetach(old); rc.attach(next) }},
389 {"detach", func(rc *renderCoordinator, old, _ *attachedClient) { rc.noteDetach(old) }},
390 } {
391 t.Run(transition.name, func(t *testing.T) {
392 rc := newRenderCoordinator(renderCoordinatorOptions{})
393 old, next := &attachedClient{}, &attachedClient{}
394 rc.attach(old)
395 require.Equal(t, uint64(1), rc.recordResizeRequest(domain.Size{Cols: 90, Rows: 24}, old))
396 stale := func() uint64 { return rc.recordResizeRequest(domain.Size{Cols: 20, Rows: 5}, old) }
397 transition.run(rc, old, next)
398 require.Zero(t, stale(), "a stale %s callback must not publish", transition.name)
399 })
400 }
401}
402
403// S3 acceptance: three callbacks can be delivered even after their timers were
404// stopped. Only the newest prepared epoch may apply, commit, and emit one full
405// frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its
406// channels make callback order explicit and do not use wall-clock waits.
407func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) {
408 first, second := &transactionalResizePTY{}, &transactionalResizePTY{}
409 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
410 observer := &daemonRuntimeObserver{}
411 d.runtimeObserver = observer
412 rc := d.attachCoordinator(sess, nil, ac, true)
413 lease := rc.attachmentLease(ac)
414 var newer uint64
415 d.beforeSessionResizePublication = func() {
416 // Final external PTY validation is complete, but epoch admission and
417 // every session-visible publication must still be pending.
418 for _, tb := range sess.tabs {
419 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size)
420 p := tb.focusedPane()
421 p.mu.Lock()
422 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect)
423 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
424 p.mu.Unlock()
425 }
426 require.False(t, sess.snapDirty.Load())
427 for _, mark := range observer.marks {
428 require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind)
429 }
430 newer = rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
431 require.NotZero(t, newer)
432 }
433
434 require.False(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 100, Rows: 30}, true))
435 d.beforeSessionResizePublication = nil
436 require.False(t, sess.snapDirty.Load(), "rejected epoch must not dirty the snapshot")
437 for _, tb := range sess.tabs {
438 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "rejected epoch published a tab size")
439 p := tb.focusedPane()
440 require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "rejected epoch published a pane rectangle")
441 require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()}, "rejected epoch published a VT size")
442 }
443
444 require.True(t, attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: newer}.run())
445 for _, tb := range sess.tabs {
446 require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size)
447 p := tb.focusedPane()
448 require.Equal(t, domain.Rect{Width: 120, Height: 32}, p.rect)
449 require.Equal(t, domain.Size{Cols: 120, Rows: 32}, domain.Size{Cols: p.screen.Columns(), Rows: p.screen.Rows()})
450 }
451 committed := 0
452 for _, mark := range observer.marks {
453 if mark.Kind == ports.RuntimeResizeCommitted {
454 committed++
455 }
456 }
457 require.Equal(t, 1, committed, "only the current epoch emits commit telemetry")
458 require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, first.requested())
459 require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}}, second.requested())
460}
461
462func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
463 for _, tc := range []struct {
464 name string
465 effect resizeOwnerPostEffect
466 }{
467 {name: "before snapshot effects", effect: resizeOwnerPostSnapshotDirty},
468 {name: "before attachment size", effect: resizeOwnerPostCommitPublication},
469 } {
470 t.Run(tc.name, func(t *testing.T) {
471 pty := &transactionalResizePTY{}
472 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
473 rc := d.attachCoordinator(sess, nil, ac, true)
474 lease := rc.attachmentLease(ac)
475 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
476 require.NotZero(t, epoch)
477 initialSize := ac.geometry.Size
478
479 entered := make(chan struct{})
480 release := make(chan struct{})
481 d.beforeResizeOwnerPostEffect = func(effect resizeOwnerPostEffect) {
482 if effect != tc.effect {
483 return
484 }
485 close(entered)
486 <-release
487 }
488 done := make(chan bool, 1)
489 go func() { done <- attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.run() }()
490 <-entered
491 newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
492 require.NotZero(t, newer)
493 close(release)
494 require.False(t, <-done, "stale resize generation must be rejected")
495 require.Equal(t, initialSize, ac.geometry.Size, "stale resize must not publish attachment size")
496 if tc.effect == resizeOwnerPostSnapshotDirty {
497 require.False(t, sess.snapDirty.Load(), "stale resize must not publish snapshot dirtiness")
498 }
499 })
500 }
501}
502
503func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) {
504 pty := &transactionalResizePTY{}
505 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
506 tb := testAttachmentTab(sess)
507 p := tb.focusedPane()
508 p.resizeApplying = true
509 p.resizePending = []byte("x")
510 plan := preparedTabLayout{members: []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect, screenResized: true}}}
511 tb.mu.Lock()
512 delete(tb.panes, p.id)
513 tb.bumpLayoutGenerationLocked()
514 tb.mu.Unlock()
515
516 sess.geometry.finishPreparedTabMembers(d, &plan, false)
517 p.mu.Lock()
518 require.True(t, p.resizeApplying, "stale finalization must leave cancellation to the fresh-plan path")
519 p.mu.Unlock()
520 sess.geometry.cancelStalePreparedGates(d, sess, tb, &plan)
521 p.mu.Lock()
522 require.False(t, p.resizeApplying)
523 require.Empty(t, p.resizePending)
524 p.mu.Unlock()
525}
526
527func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
528 pty := &transactionalResizePTY{}
529 d, sess, _, _ := newManualSessionWithPTYs(t, pty)
530 tb := testAttachmentTab(sess)
531 var invalidate sync.Once
532 pty.onResize = func() {
533 invalidate.Do(func() {
534 tb.mu.Lock()
535 tb.bumpLayoutGenerationLocked()
536 tb.mu.Unlock()
537 })
538 }
539
540 require.True(t, sess.geometry.requestResize(d, sess, nil, domain.Size{Cols: 100, Rows: 30}, true))
541 require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 100, Rows: 28}}, pty.requested())
542 tb.mu.Lock()
543 require.Equal(t, domain.Size{Cols: 100, Rows: 28}, tb.size)
544 tb.mu.Unlock()
545}
546
547func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
548 popupPTY := &transactionalResizePTY{errs: []error{errors.New("initial popup failure")}}
549 d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
550 tb := testAttachmentTab(source)
551 popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
552 tb.mu.Lock()
553 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
554 tb.mu.Unlock()
555 owner := publishPaneOwner(popup, source, tb, 7)
556
557 rc := d.attachCoordinator(source, nil, ac, true)
558 lease := rc.attachmentLease(ac)
559 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
560 require.True(t, attachmentResize{d: d, sess: source, ac: ac, lease: lease, epoch: epoch}.run())
561
562 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}}
563 publishPaneOwner(popup, destination, tb, 7)
564 destinationRect := domain.Rect{X: 9, Y: 4, Width: 31, Height: 9}
565 popup.mu.Lock()
566 popup.rect = destinationRect
567 popup.popupGeometry = floatingGeometry{Inner: destinationRect}
568 popup.mu.Unlock()
569 source.snapshotMu.Lock()
570 sourceGeneration := source.snapshotGeneration
571 source.snapshotMu.Unlock()
572 requested := popupPTY.requested()
573
574 attachmentResize{d: d, sess: source, ac: ac, lease: lease, epoch: epoch}.retry([]resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}})
575
576 require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY")
577 popup.mu.Lock()
578 require.Equal(t, destinationRect, popup.rect, "stale floating retry published source geometry")
579 require.Equal(t, destinationRect, popup.popupGeometry.Inner, "stale floating retry published source popup geometry")
580 popup.mu.Unlock()
581 source.snapshotMu.Lock()
582 require.Equal(t, sourceGeneration, source.snapshotGeneration, "stale floating retry dirtied its former session")
583 source.snapshotMu.Unlock()
584}
585
586func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
587 cfg := domain.FloatingConfig{Width: 100, Height: 100}
588 for _, tc := range []struct {
589 name string
590 from domain.Size
591 to domain.Size
592 wantMode ui.PresentationMode
593 }{
594 {name: "80 to 79", from: domain.Size{Cols: 80, Rows: 25}, to: domain.Size{Cols: 79, Rows: 25}, wantMode: ui.PresentationDrawer},
595 {name: "79 to 80", from: domain.Size{Cols: 79, Rows: 25}, to: domain.Size{Cols: 80, Rows: 25}, wantMode: ui.PresentationFloating},
596 } {
597 t.Run(tc.name, func(t *testing.T) {
598 d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
599 d.ApplyConfig(domain.Config{Floating: cfg})
600 require.True(t, sess.geometry.requestResize(d, sess, nil, tc.from, true))
601
602 tb := testAttachmentTab(sess)
603 fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg)
604 toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg)
605 entered, release := make(chan struct{}), make(chan struct{})
606 var once sync.Once
607 popupPTY := &transactionalResizePTY{onResize: func() {
608 once.Do(func() { close(entered) })
609 <-release
610 }}
611 popup := newPane("floating", popupPTY, rectSize(fromGeometry.Inner))
612 popup.rect = fromGeometry.Inner
613 popup.popupGeometry = fromGeometry
614 installTestFloating(tb, popup, true)
615
616 done := make(chan bool, 1)
617 go func() { done <- sess.geometry.requestResize(d, sess, nil, tc.to, true) }()
618 <-entered
619 popup.mu.Lock()
620 require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode")
621 require.Equal(t, fromGeometry.Inner, popup.rect)
622 require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
623 popup.mu.Unlock()
624 close(release)
625 require.True(t, <-done)
626
627 require.Equal(t, []domain.Size{rectSize(toGeometry.Inner)}, popupPTY.requested())
628 popup.mu.Lock()
629 require.Equal(t, tc.wantMode, popup.popupGeometry.Mode)
630 require.Equal(t, toGeometry, popup.popupGeometry)
631 require.Equal(t, toGeometry.Inner, popup.rect)
632 require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
633 popup.mu.Unlock()
634 })
635 }
636}
637
638func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) {
639 cfg := domain.FloatingConfig{Width: 50, Height: 50}
640 committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg)
641 requestedSize := domain.Size{Cols: 79, Rows: 40}
642 requested := calculateContentFloatingGeometry(requestedSize, cfg)
643 require.Equal(t, ui.PresentationFloating, committed.Mode)
644 require.Equal(t, ui.PresentationDrawer, requested.Mode)
645 resizeErr := errors.New("floating resize failed")
646 pty := &transactionalResizePTY{errs: []error{resizeErr}}
647 popup := newPane("floating", pty, rectSize(committed.Inner))
648 popup.rect = committed.Inner
649 popup.popupGeometry = committed
650 tb := newTab(nil, requestedSize)
651 installTestFloating(tb, popup, true)
652 d := newTestDaemon(t, nil, stubClock{})
653 d.ApplyConfig(domain.Config{Floating: cfg})
654 var tabUnlocked, paneUnlocked bool
655 pty.onResize = func() {
656 tabUnlocked = tb.mu.TryLock()
657 if tabUnlocked {
658 tb.mu.Unlock()
659 }
660 paneUnlocked = popup.mu.TryLock()
661 if paneUnlocked {
662 popup.mu.Unlock()
663 }
664 }
665
666 sess := &session{tabs: []*tab{tb}}
667 failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, 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.Columns(), Rows: popup.screen.Rows()})
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 sess := &session{tabs: []*tab{tb}}
708 failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil)
709
710 require.True(t, ok)
711 require.Empty(t, failed)
712 require.Empty(t, pty.requested(), "same-size geometry publication must not resize the PTY")
713 popup.mu.Lock()
714 require.Equal(t, requested.Inner, popup.rect)
715 require.Equal(t, requested, popup.popupGeometry)
716 require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
717 require.False(t, popup.resizeRetry)
718 popup.mu.Unlock()
719}
720
721func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) {
722 cfg := domain.FloatingConfig{Width: 100, Height: 100}
723 committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
724 pty := &transactionalResizePTY{}
725 popup := newPane("floating", pty, rectSize(committed.Inner))
726 popup.rect = committed.Inner
727 popup.popupGeometry = committed
728 tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
729 installTestFloating(tb, popup, true)
730 d := newTestDaemon(t, nil, stubClock{})
731 d.ApplyConfig(domain.Config{Floating: cfg})
732 calls := 0
733
734 sess := &session{tabs: []*tab{tb}}
735 failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, func() bool {
736 calls++
737 return calls == 1
738 })
739
740 require.True(t, ok, "a publication accepted while current must not later report stale")
741 require.Equal(t, 1, calls, "current must not be rechecked after publication")
742 require.Empty(t, failed)
743 require.Empty(t, pty.requested())
744 requested := calculateContentFloatingGeometry(tb.size, cfg)
745 popup.mu.Lock()
746 require.Equal(t, requested, popup.popupGeometry)
747 require.Equal(t, committed.Inner, popup.rect)
748 require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
749 popup.mu.Unlock()
750}
751
752func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t *testing.T) {
753 cfg := domain.FloatingConfig{Width: 100, Height: 100}
754 committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
755 pty := &transactionalResizePTY{}
756 popup := newPane("floating", pty, rectSize(committed.Inner))
757 popup.rect = committed.Inner
758 popup.popupGeometry = committed
759 tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
760 installTestFloating(tb, popup, true)
761 d := newTestDaemon(t, nil, stubClock{})
762 d.ApplyConfig(domain.Config{Floating: cfg})
763
764 sess := &session{tabs: []*tab{tb}}
765 failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, func() bool { return false })
766
767 require.False(t, ok)
768 require.Empty(t, failed)
769 require.Empty(t, pty.requested())
770 popup.mu.Lock()
771 require.Equal(t, committed, popup.popupGeometry)
772 require.Equal(t, committed.Inner, popup.rect)
773 require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Columns(), Rows: popup.screen.Rows()})
774 popup.mu.Unlock()
775}
776
777func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
778 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
779 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
780 tb := testAttachmentTab(sess)
781 popup := newPane("popup", popupPTY, domain.Size{Cols: 80, Rows: 23})
782 tb.mu.Lock()
783 tb.floating = floatingSlot{state: floatingVisible, pane: popup, generation: 7}
784 tb.mu.Unlock()
785
786 rc := d.attachCoordinator(sess, nil, ac, true)
787 lease := rc.attachmentLease(ac)
788 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
789 require.NotZero(t, epoch)
790 require.True(t, attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.run())
791 first := popupPTY.requested()
792 require.Len(t, first, 1)
793
794 attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.retry([]resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
795 retried := popupPTY.requested()
796 require.Len(t, retried, 2)
797 require.Equal(t, first[0], retried[1], "retry must use the current validated floating slot geometry")
798 popup.mu.Lock()
799 require.False(t, popup.resizeRetry)
800 popup.mu.Unlock()
801
802 replacementPTY := &transactionalResizePTY{}
803 replacement := newPane("replacement", replacementPTY, domain.Size{Cols: 80, Rows: 23})
804 replacement.resizeRetry = true
805 tb.mu.Lock()
806 tb.floating = floatingSlot{state: floatingVisible, pane: replacement, generation: 8}
807 tb.mu.Unlock()
808 attachmentResize{d: d, sess: sess, ac: ac, lease: lease, epoch: epoch}.retry([]resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}})
809 require.Empty(t, replacementPTY.requested(), "a replaced floating slot must not inherit an old retry")
810}
811
812func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testing.T) {
813 clock := newCoordinatorMockClock(t, 8)
814 pty := &transactionalResizePTY{}
815 d, sess, ac, frames := newManualSessionWithPTYs(t, pty)
816 d.clock = clock.clock
817
818 for _, size := range []domain.Size{{Cols: 90, Rows: 24}, {Cols: 100, Rows: 28}, {Cols: 120, Rows: 32}} {
819 d.resize(sess, ac, size)
820 }
821 // Epoch scheduling leaves PTY geometry untouched until the final
822 // prepare/apply/commit callback.
823 require.Empty(t, pty.requested(), "obsolete requests must not resize the PTY")
824
825 var timers []*coordinatorMockTimer
826 for len(timers) < 3 {
827 timers = append(timers, <-clock.timers)
828 }
829 done := captureResizeCallbackDone(t, sess.renderCoordinator())
830 for _, timer := range timers {
831 timer.ch <- time.Time{}
832 }
833 awaitTestCompletion(t, done, "latest resize callback did not complete")
834 require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())
835 snapshot := sess.renderCoordinator().resizeSnapshot()
836 require.Equal(t, uint64(3), snapshot.epoch)
837 require.Equal(t, snapshot.epoch, snapshot.committed)
838 // The resize deadline is the only debounce: commit fires its sticky reset
839 // through S2 immediately (subject to ACK/sync gates).
840 frame := <-frames
841 require.Equal(t, "Output", envelopeMessageName(t, frame.Payload), "the committed epoch emits a full frame")
842 output := unmarshalTestOutput(t, frame.Payload)
843 require.Zero(t, output.Base, "the accepted resize frame must reset the renderer state")
844 select {
845 case extra := <-frames:
846 t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)
847 default:
848 }
849}
850
851// Entering copy mode is invalidated at transaction prepare time, while a
852// blocked apply keeps the old committed pane rectangle visible to mouse and
853// composition. Channels, rather than a delay, make the publication boundary
854// deterministic.
855func TestTransactionalResizeFloatingMouseCopyAndSearchContract(t *testing.T) {
856 for _, tc := range []struct {
857 name string
858 errs []error
859 }{
860 {name: "success"},
861 {name: "partial failure", errs: []error{errors.New("resize failed")}},
862 } {
863 t.Run(tc.name, func(t *testing.T) {
864 entered, release := make(chan struct{}), make(chan struct{})
865 pty := &transactionalResizePTY{errs: tc.errs, onResize: func() { close(entered); <-release }}
866 d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
867 tb := testAttachmentTab(sess)
868 p := tb.focusedPane()
869 old := p.rect
870 d.enterCopyMode(sess, ac)
871 ac.overlays.copyMu.Lock()
872 ac.overlays.copySearch = &visualsearch.Model{}
873 ac.overlays.copyMu.Unlock()
874 done := make(chan struct{})
875 go func() {
876 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
877 close(done)
878 }()
879 <-entered
880 require.False(t, ac.overlays.copyActive(), "prepare invalidates copy mode")
881 require.False(t, ac.overlays.copySearchActive(), "prepare invalidates visual search")
882 require.Equal(t, old, p.rect, "mouse uses old committed geometry before publication")
883 close(release)
884 <-done
885 })
886 }
887}