attachment_multiplex_semantics_test.go RawHistoryFolder

1package daemon
2
3import (
4	"slices"
5	"testing"
6
7	"github.com/bnema/vev/internal/domain"
8	"github.com/bnema/vev/internal/protocol"
9	"github.com/stretchr/testify/require"
10)
11
12// TestMultiplexLatestValidGeometryClaimant proves three independently admitted
13// attachments arbitrate shared content by claim sequence, while a transaction
14// captured before a later claim cannot publish. Removing the current claimant
15// would make the stale transaction win and fail this test.
16func TestMultiplexLatestValidGeometryClaimant(t *testing.T) {
17	pty := &transactionalResizePTY{}
18	d, sess, first, _ := newManualSessionWithPTYs(t, pty)
19	first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480})
20	addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 100, Rows: 30}, PixelWidth: 1000, PixelHeight: 600})
21	third := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}, PixelWidth: 1200, PixelHeight: 800})
22
23	require.True(t, sess.geometry.reconcile(d, sess, nil))
24	requireMultiplexGeometry(t, sess, third, domain.Size{Cols: 120, Rows: 38})
25
26	// Capture an earlier claimant's new request, then supersede it before the
27	// commit fence. A stale transaction must not overwrite the third claim.
28	first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 90, Rows: 28}, PixelWidth: 900, PixelHeight: 560})
29	var superseded bool
30	d.beforeSessionResizePublication = func() {
31		if superseded {
32			return
33		}
34		superseded = true
35		third.setGeometry(domain.Geometry{Size: domain.Size{Cols: 130, Rows: 42}, PixelWidth: 1300, PixelHeight: 840})
36		_, ok := sess.geometry.claimAttachment(sess, third)
37		require.True(t, ok)
38	}
39	require.False(t, sess.geometry.reconcile(d, sess, first))
40	tab := sess.tabs[0]
41	tab.mu.Lock()
42	require.Equal(t, domain.Size{Cols: 120, Rows: 38}, tab.size, "stale transaction committed before the current-claim fence rejected it")
43	tab.mu.Unlock()
44	d.beforeSessionResizePublication = nil
45	require.True(t, sess.geometry.reconcile(d, sess, nil))
46	requireMultiplexGeometry(t, sess, third, domain.Size{Cols: 130, Rows: 40})
47}
48
49// TestMultiplexClaimantSuspendDisconnectFallback proves removal chooses the
50// newest remaining claim, not attachment insertion order or a default. Picking
51// the oldest claim would produce 80x22 after third is suspended.
52func TestMultiplexClaimantSuspendDisconnectFallback(t *testing.T) {
53	d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
54	first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
55	second := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 100, Rows: 30}})
56	third := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}})
57	_, claimed := sess.geometry.claimAttachment(sess, first)
58	require.True(t, claimed)
59	_, claimed = sess.geometry.claimAttachment(sess, second)
60	require.True(t, claimed)
61	_, claimed = sess.geometry.claimAttachment(sess, third)
62	require.True(t, claimed)
63	require.True(t, sess.geometry.reconcile(d, sess, nil))
64
65	rc := d.attachCoordinator(sess, nil, first, true)
66	d.attachCoordinator(sess, nil, second, true)
67	d.attachCoordinator(sess, nil, third, true)
68	thirdToken := sess.captureAttachmentCapability(third, third.transport())
69	thirdToken.lease = rc.attachmentLease(third)
70	third.installTestAttachmentCapability(thirdToken)
71	require.NoError(t, d.suspendAttachment(thirdToken, protocol.SuspendAttachment{RequestID: 1}))
72	requireMultiplexGeometry(t, sess, second, domain.Size{Cols: 100, Rows: 28})
73
74	d.clientGone(sess, second, second.transport(), true)
75	requireMultiplexGeometry(t, sess, first, domain.Size{Cols: 80, Rows: 22})
76}
77
78// TestMultiplexClaimantRemovalDoesNotTouchUnrelatedSession proves geometry and
79// attachment registries are session-owned. Accidentally using daemon-global
80// claimant state would mutate the unrelated session and fail these snapshots.
81func TestMultiplexClaimantRemovalDoesNotTouchUnrelatedSession(t *testing.T) {
82	d, affected, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
83	first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
84	claimant := addMultiplexTestAttachment(t, affected, domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}})
85	require.True(t, affected.geometry.reconcile(d, affected, nil))
86
87	other := &session{sessionCore: sessionCore{id: "other", name: "other"}, tabs: []*tab{newTab(newQuietPTY(), domain.Size{Cols: 95, Rows: 34})}}
88	otherAttachment := addMultiplexTestAttachment(t, other, domain.Geometry{Size: domain.Size{Cols: 95, Rows: 35}})
89	require.True(t, other.geometry.reconcile(d, other, nil))
90	otherBefore := other.geometry.appliedSnapshot()
91
92	d.clientGone(affected, claimant, claimant.transport(), true)
93	require.True(t, affected.geometry.reconcile(d, affected, first))
94	requireMultiplexGeometry(t, affected, first, domain.Size{Cols: 80, Rows: 22})
95	require.Equal(t, otherBefore, other.geometry.appliedSnapshot())
96	attachments := other.snapshotAttachments()
97	require.Len(t, attachments, 1)
98	require.Same(t, otherAttachment, attachments[0])
99}
100
101// TestMultiplexAttachmentLocalStateAndFrames proves view, renderer cache,
102// overlay runtime, and command-result carriage are attachment-owned. Aliasing
103// any of those fields, or routing a result to the peer, breaks the snapshots.
104func TestMultiplexAttachmentLocalStateAndFrames(t *testing.T) {
105	d, sess, first, firstFrames := newManualSessionWithPTYs(t, newQuietPTY())
106	peerTransport, peerFrames := newCapturingTransport(t)
107	peer, err := d.attachClient(sess, peerTransport, domain.Size{Cols: 90, Rows: 30}, attachClientOptions{})
108	require.NoError(t, err)
109	require.NotSame(t, first.output, peer.output)
110	require.NotSame(t, first.overlays, peer.overlays)
111
112	peerView := peer.viewSnapshot()
113	require.True(t, sess.updateAttachmentView(first, func(view *attachmentView) { view.windowTop = 7; view.liveBottom = false }))
114	require.Equal(t, peerView, peer.viewSnapshot())
115
116	first.overlays.paletteMu.Lock()
117	first.overlays.paletteFeedback = "first-only"
118	first.overlays.paletteMu.Unlock()
119	peer.overlays.paletteMu.Lock()
120	require.Empty(t, peer.overlays.paletteFeedback)
121	peer.overlays.paletteMu.Unlock()
122
123	rc := d.attachCoordinator(sess, nil, first, true)
124	d.attachCoordinator(sess, nil, peer, true)
125	firstToken := sess.captureAttachmentCapability(first, first.transport())
126	firstToken.lease = rc.attachmentLease(first)
127	first.installTestAttachmentCapability(firstToken)
128	peerToken := sess.captureAttachmentCapability(peer, peer.transport())
129	peerToken.lease = rc.attachmentLease(peer)
130	peer.installTestAttachmentCapability(peerToken)
131	require.False(t, d.handleAttachmentClientMessage(firstToken, protocol.CommandRequest{Version: protocol.Version, RequestID: 77, Attached: true, Slug: "next-tab"}))
132	result, ok := decodeServerMessage(t, awaitFrame(t, firstFrames, "CommandResult")).(protocol.CommandResult)
133	require.True(t, ok)
134	require.Equal(t, uint64(77), result.RequestID)
135	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
136	select {
137	case frame := <-peerFrames:
138		if command, ok := decodeServerMessage(t, frame).(protocol.CommandResult); ok && command.RequestID == 77 {
139			t.Fatal("command result crossed attachment stream")
140		}
141	default:
142	}
143}
144
145// TestMultiplexReconnectGenerationFencesOldEffects proves an older attachment
146// capability cannot act after a production suspension/activation reconnect.
147// Accepting the old token would change size or execute its command.
148func TestMultiplexReconnectGenerationFencesOldEffects(t *testing.T) {
149	d, sess, reconnecting, oldFrames := newManualSessionWithPTYs(t, &transactionalResizePTY{})
150	peer := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 100, Rows: 30}})
151	rc := d.attachCoordinator(sess, nil, reconnecting, true)
152	d.attachCoordinator(sess, nil, peer, true)
153	old := sess.captureAttachmentCapability(reconnecting, reconnecting.transport())
154	old.lease = rc.attachmentLease(reconnecting)
155	reconnecting.installTestAttachmentCapability(old)
156
157	require.NoError(t, d.suspendAttachment(old, protocol.SuspendAttachment{RequestID: 1}))
158	for len(oldFrames) != 0 {
159		<-oldFrames
160	}
161	fresh, _ := newCapturingTransport(t)
162	reconnecting.replaceTransport(fresh)
163	target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
164	require.NoError(t, d.activateAttachment(reconnecting, reconnecting.transportSnapshot(), protocol.ActivateAttachment{RequestID: 2, Target: target, Size: reconnecting.sizeSnapshot()}))
165	current := sess.captureAttachmentCapability(reconnecting, fresh)
166	require.Greater(t, current.generation, old.generation, "production reconnect did not advance the attachment generation")
167	require.NotEqual(t, current.transport.incarnation, old.transport.incarnation)
168
169	// Isolate the generation clause from transport-incarnation fencing: use the
170	// current transport and lease with only the previous production generation.
171	staleGeneration := current
172	staleGeneration.generation = old.generation
173	require.False(t, staleGeneration.current(), "previous generation remained current after reconnect")
174	before := reconnecting.geometrySnapshot()
175	require.False(t, d.handleAttachmentClientMessage(staleGeneration, protocol.Resize{Size: domain.Size{Cols: 140, Rows: 50}}))
176	require.False(t, d.handleAttachmentClientMessage(staleGeneration, protocol.CommandRequest{Version: protocol.Version, RequestID: 9, Attached: true, Slug: "next-tab"}))
177	require.Equal(t, before, reconnecting.geometrySnapshot())
178	select {
179	case <-oldFrames:
180		t.Fatal("stale generation emitted a frame")
181	default:
182	}
183	require.True(t, slices.Contains(sess.snapshotAttachments(), peer))
184}
185
186// TestMultiplexSingleStreamErrorIsolation proves one failed attachment is
187// removed without retiring its siblings or session. Detaching every attachment
188// on one transport error, or deleting the session, fails the registry checks.
189func TestMultiplexSingleStreamErrorIsolation(t *testing.T) {
190	d, sess, failed, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
191	failedTransport := &closeTrackingTransport{}
192	failed.replaceTransport(failedTransport)
193	peer := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 100, Rows: 30}})
194	d.attachCoordinator(sess, nil, failed, true)
195	d.attachCoordinator(sess, nil, peer, true)
196	failed.resumeCapable = false
197	failedToken := sess.captureAttachmentCapability(failed, failedTransport)
198	failed.installTestAttachmentCapability(failedToken)
199
200	d.detachOnAttachmentSendError(failedToken, failedTransport)
201	require.False(t, slices.Contains(sess.snapshotAttachments(), failed))
202	require.True(t, slices.Contains(sess.snapshotAttachments(), peer))
203	require.Same(t, sess, peer.currentSession())
204	d.mu.Lock()
205	require.Same(t, sess, d.sessions[sess.id])
206	d.mu.Unlock()
207	require.True(t, failedTransport.Closed())
208}
209
210func addMultiplexTestAttachment(t *testing.T, sess *session, geometry domain.Geometry) *attachedClient {
211	t.Helper()
212	transport, _ := newCapturingTransport(t)
213	attachment := &attachedClient{tr: transport, output: newOutputStateStream()}
214	attachment.output.attachment = attachment
215	attachment.initOverlays()
216	attachment.setGeometry(geometry)
217	attachment.setSession(sess)
218	require.True(t, sess.registerAttachment(attachment))
219	return attachment
220}
221
222func requireMultiplexGeometry(t *testing.T, sess *session, claimant *attachedClient, content domain.Size) {
223	t.Helper()
224	source, ok := sess.geometry.sourceSnapshot(sess)
225	require.True(t, ok)
226	require.Same(t, claimant, source.attachment)
227	require.Equal(t, claimant.geometrySnapshot(), sess.geometry.appliedSnapshot())
228	tab := sess.tabs[0]
229	tab.mu.Lock()
230	defer tab.mu.Unlock()
231	require.Equal(t, content, tab.size)
232	pane := tab.focusedPane()
233	pane.mu.Lock()
234	defer pane.mu.Unlock()
235	require.Equal(t, content, domain.Size{Cols: pane.screen.Columns(), Rows: pane.screen.Rows()})
236}

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