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}