1package daemon
2
3import (
4 "io"
5 "sync/atomic"
6 "testing"
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/stretchr/testify/require"
12)
13
14type ownerRoutingPTY struct {
15 steps chan channelPTYStep
16 processed chan struct{}
17 activeReaders atomic.Int32
18 maxReaders atomic.Int32
19 reads atomic.Int32
20 closes atomic.Int32
21}
22
23func newOwnerRoutingPTY() *ownerRoutingPTY {
24 return &ownerRoutingPTY{
25 steps: make(chan channelPTYStep),
26 processed: make(chan struct{}, 4),
27 }
28}
29
30func (p *ownerRoutingPTY) Read(buf []byte) (int, error) {
31 active := p.activeReaders.Add(1)
32 defer p.activeReaders.Add(-1)
33 for {
34 maximum := p.maxReaders.Load()
35 if active <= maximum || p.maxReaders.CompareAndSwap(maximum, active) {
36 break
37 }
38 }
39 if p.reads.Add(1) > 1 {
40 p.processed <- struct{}{}
41 }
42 step := <-p.steps
43 return copy(buf, step.data), step.err
44}
45
46func (*ownerRoutingPTY) Write(b []byte) (int, error) { return len(b), nil }
47func (p *ownerRoutingPTY) Close() error { p.closes.Add(1); return nil }
48func (*ownerRoutingPTY) Resize(domain.Geometry) error { return nil }
49func (*ownerRoutingPTY) Pid() int { return 0 }
50func (*ownerRoutingPTY) ForegroundPgid() (int, error) {
51 return 0, nil
52}
53
54func (p *pane) clearOwnerForTest() {
55 p.mu.Lock()
56 p.clearOwnerLocked()
57 p.mu.Unlock()
58}
59
60func TestPTYReaderExitReapsExactlyCurrentOwnerAfterPublication(t *testing.T) {
61 pty := newOwnerRoutingPTY()
62 d, source, _, _ := newManualSessionWithPTYs(t, pty)
63 sourceTab := testAttachmentTab(source)
64 moved := sourceTab.focusedPane()
65
66 destinationTab := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
67 other := newPane(layout.PaneID("pane-2"), newQuietPTY(), domain.Size{Cols: 40, Rows: 23})
68 destinationTab.mu.Lock()
69 require.NoError(t, destinationTab.tree.Split(layout.PaneID("pane-1"), layout.Right, true, other.id, domain.Rect{Width: 80, Height: 23}))
70 destinationTab.panes[other.id] = other
71 destinationTab.mu.Unlock()
72 destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"), name: "destination"}, tabs: []*tab{destinationTab}}
73 d.sessions[destination.id] = destination
74
75 // Model a completed owner commit while the one reader remains blocked in
76 // Read. The source immediately reuses the old local ID, which makes stale
77 // ID-only exit routing observably close the wrong pane.
78 replacement := newPane(layout.PaneID("pane-1"), newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
79 sourceTab.mu.Lock()
80 sourceTab.panes[replacement.id] = replacement
81 publishPaneOwner(replacement, source, sourceTab, 0)
82 sourceTab.mu.Unlock()
83
84 destinationTab.mu.Lock()
85 displaced := destinationTab.panes[layout.PaneID("pane-1")]
86 displaced.clearOwnerForTest()
87 destinationTab.panes[moved.id] = moved
88 publishPaneOwner(moved, destination, destinationTab, 0)
89 destinationTab.mu.Unlock()
90
91 d.sessWg.Add(1)
92 go d.ptyReader(source, sourceTab, moved)
93 pty.steps <- channelPTYStep{err: io.EOF}
94 d.sessWg.Wait()
95
96 d.mu.Lock()
97 require.Same(t, source, d.sessions[source.id], "stale exit must not close the source replacement")
98 require.Same(t, destination, d.sessions[destination.id])
99 d.mu.Unlock()
100 sourceTab.mu.Lock()
101 require.Same(t, replacement, sourceTab.panes[replacement.id])
102 sourceTab.mu.Unlock()
103 destinationTab.mu.Lock()
104 require.NotContains(t, destinationTab.panes, moved.id, "the exited pane must be removed from its current owner")
105 require.Same(t, other, destinationTab.panes[other.id])
106 destinationTab.mu.Unlock()
107 require.Nil(t, moved.ownerSnapshot())
108 require.True(t, replacement.effectLease().Current())
109 require.Equal(t, int32(1), pty.maxReaders.Load())
110
111 // A stale second reap is a no-op and cannot close either surviving owner.
112 d.reapPaneOwner(moved)
113 sourceTab.mu.Lock()
114 require.Same(t, replacement, sourceTab.panes[replacement.id])
115 sourceTab.mu.Unlock()
116 destinationTab.mu.Lock()
117 require.Same(t, other, destinationTab.panes[other.id])
118 destinationTab.mu.Unlock()
119 require.Equal(t, int32(1), pty.closes.Load(), "exit must release the process exactly once")
120}
121
122var _ ports.PTY = (*ownerRoutingPTY)(nil)