pane_owner_reader_test.go RawHistoryFolder

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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close