detached ac27f72

feat(remote): add proxy session link and rendering

commit
ac27f720e03689a87af76e6842a3c83413a22b6f
author
brice · · 2026-07-30 16:38 UTC
committer
brice · · 2026-07-31 12:09 UTC
parent
a832d60
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +2206 -80
M internal/usecase/daemon/attachment_transition_publish.go +1 -3
@@ -120,9 +120,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
120120
121121 var targetCoordinator *renderCoordinator
122122 if req.targetRole == attachmentActive {
123- if target, ok := localSession(req.target); ok {
124- targetCoordinator = d.ensureRenderCoordinatorPrelocked(target)
125- }
123+ targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
126124 }
127125 sourceCoordinator := sourceCore.coordinator.Load()
128126 if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||
@@ -120,9 +120,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
120 120
121 var targetCoordinator *renderCoordinator121 var targetCoordinator *renderCoordinator
122 if req.targetRole == attachmentActive {122 if req.targetRole == attachmentActive {
123- if target, ok := localSession(req.target); ok {123+ targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
124- targetCoordinator = d.ensureRenderCoordinatorPrelocked(target)
125- }
126 }124 }
127 sourceCoordinator := sourceCore.coordinator.Load()125 sourceCoordinator := sourceCore.coordinator.Load()
128 if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||126 if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||
M internal/usecase/daemon/client.go +19 -10
@@ -565,7 +565,14 @@ func (d *Daemon) ensureRenderCoordinator(sess *session) *renderCoordinator {
565565 // ensureRenderCoordinatorPrelocked is the non-reentrant coordinator setup seam
566566 // for transactions that already hold sess.mu.
567567 func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordinator {
568- if rc := sess.renderCoordinator(); rc != nil {
568+ return d.ensureAttachmentRenderCoordinatorPrelocked(sess)
569+}
570+
571+// ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an
572+// exact attachment implementation. The caller holds entry.core().mu; proxy
573+// VT state is deliberately not touched here.
574+func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSession) *renderCoordinator {
575+ if rc := attachmentRenderCoordinator(entry); rc != nil {
569576 return rc
570577 }
571578 var rc *renderCoordinator
@@ -573,18 +580,16 @@ func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordina
573580 clock: d.clock,
574581 observer: d.runtimeObserver,
575582 wake: func(w renderWake) {
576- // Never reread sess.client here: a wake is bound to both its
577- // attachment and its coordinator incarnation. The second check in
578- // paint occurs under sendMu, closing a park/resume race after this
579- // unlocked coordinator validation.
583+ // The wake retains the exact attachment lease. paint repeats this
584+ // identity check under sendMu before composing any snapshot.
580585 if w.lease != nil && rc.wakeCurrent(w) {
581- d.paint(sess, w.lease.attachment, w.reset, w.lease)
586+ d.paint(entry, w.lease.attachment, w.reset, w.lease)
582587 }
583588 },
584589 ackReady: func() bool {
585- sess.mu.Lock()
586- attached := sess.client
587- sess.mu.Unlock()
590+ entry.core().mu.Lock()
591+ attached := entry.core().client
592+ entry.core().mu.Unlock()
588593 if attached == nil {
589594 return false
590595 }
@@ -594,7 +599,7 @@ func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordina
594599 return ready
595600 },
596601 })
597- sess.installRenderCoordinator(rc)
602+ installAttachmentRenderCoordinator(entry, rc)
598603 return rc
599604 }
600605
@@ -645,6 +650,10 @@ func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
645650 token.ac.captureFrames = nil
646651 token.ac.sendMu.Unlock()
647652 }
653+ if proxy, ok := token.sess.(*proxySession); ok {
654+ d.firstProxyPaintWithLease(proxy, token.ac, token.lease)
655+ return true
656+ }
648657 sess, ok := localSession(token.sess)
649658 if !ok {
650659 return false
@@ -565,7 +565,14 @@ func (d *Daemon) ensureRenderCoordinator(sess *session) *renderCoordinator {
565 // ensureRenderCoordinatorPrelocked is the non-reentrant coordinator setup seam565 // ensureRenderCoordinatorPrelocked is the non-reentrant coordinator setup seam
566 // for transactions that already hold sess.mu.566 // for transactions that already hold sess.mu.
567 func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordinator {567 func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordinator {
568- if rc := sess.renderCoordinator(); rc != nil {568+ return d.ensureAttachmentRenderCoordinatorPrelocked(sess)
569+}
570+
571+// ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an
572+// exact attachment implementation. The caller holds entry.core().mu; proxy
573+// VT state is deliberately not touched here.
574+func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSession) *renderCoordinator {
575+ if rc := attachmentRenderCoordinator(entry); rc != nil {
569 return rc576 return rc
570 }577 }
571 var rc *renderCoordinator578 var rc *renderCoordinator
@@ -573,18 +580,16 @@ func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordina
573 clock: d.clock,580 clock: d.clock,
574 observer: d.runtimeObserver,581 observer: d.runtimeObserver,
575 wake: func(w renderWake) {582 wake: func(w renderWake) {
576- // Never reread sess.client here: a wake is bound to both its583+ // The wake retains the exact attachment lease. paint repeats this
577- // attachment and its coordinator incarnation. The second check in584+ // identity check under sendMu before composing any snapshot.
578- // paint occurs under sendMu, closing a park/resume race after this
579- // unlocked coordinator validation.
580 if w.lease != nil && rc.wakeCurrent(w) {585 if w.lease != nil && rc.wakeCurrent(w) {
581- d.paint(sess, w.lease.attachment, w.reset, w.lease)586+ d.paint(entry, w.lease.attachment, w.reset, w.lease)
582 }587 }
583 },588 },
584 ackReady: func() bool {589 ackReady: func() bool {
585- sess.mu.Lock()590+ entry.core().mu.Lock()
586- attached := sess.client591+ attached := entry.core().client
587- sess.mu.Unlock()592+ entry.core().mu.Unlock()
588 if attached == nil {593 if attached == nil {
589 return false594 return false
590 }595 }
@@ -594,7 +599,7 @@ func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordina
594 return ready599 return ready
595 },600 },
596 })601 })
597- sess.installRenderCoordinator(rc)602+ installAttachmentRenderCoordinator(entry, rc)
598 return rc603 return rc
599 }604 }
600 605
@@ -645,6 +650,10 @@ func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
645 token.ac.captureFrames = nil650 token.ac.captureFrames = nil
646 token.ac.sendMu.Unlock()651 token.ac.sendMu.Unlock()
647 }652 }
653+ if proxy, ok := token.sess.(*proxySession); ok {
654+ d.firstProxyPaintWithLease(proxy, token.ac, token.lease)
655+ return true
656+ }
648 sess, ok := localSession(token.sess)657 sess, ok := localSession(token.sess)
649 if !ok {658 if !ok {
650 return false659 return false
M internal/usecase/daemon/client_frame_routing.go +2 -0
@@ -79,6 +79,8 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
7979 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() {
8080 if sess, ok := localSession(token.sess); ok {
8181 d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false)
82+ } else if proxy, ok := token.sess.(*proxySession); ok {
83+ d.resizeProxyForLease(proxy, token.ac, token.lease, rz.Size)
8284 }
8385 }
8486 case ports.MsgTheme:
@@ -79,6 +79,8 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
79 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() {79 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() {
80 if sess, ok := localSession(token.sess); ok {80 if sess, ok := localSession(token.sess); ok {
81 d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false)81 d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false)
82+ } else if proxy, ok := token.sess.(*proxySession); ok {
83+ d.resizeProxyForLease(proxy, token.ac, token.lease, rz.Size)
82 }84 }
83 }85 }
84 case ports.MsgTheme:86 case ports.MsgTheme:
A internal/usecase/daemon/proxy_link.go +591 -0
@@ -0,0 +1,591 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+const (
14+ proxyAttachedCommandTimeout = 10 * time.Second
15+ proxyResumeInitialBackoff = 250 * time.Millisecond
16+ proxyResumeMaxBackoff = 4 * time.Second
17+ proxyResumeMaxAttempts = 5
18+ proxyResumeStableDuration = 30 * time.Second
19+)
20+
21+var (
22+ errProxyCommandTimeout = errors.New("proxy session: attached command timed out")
23+ errProxyCommandUnavailable = errors.New("proxy session: attached command link is unavailable")
24+ errProxyCommandGeneration = errors.New("proxy session: attached command link was replaced")
25+)
26+
27+type proxyCommandOutcome struct {
28+ result ports.CommandResult
29+ err error
30+}
31+
32+type proxyCommandPending struct {
33+ generation uint64
34+ outcome chan proxyCommandOutcome
35+}
36+
37+type proxyRecvResult struct {
38+ frame ports.Frame
39+ err error
40+}
41+
42+type proxyLinkResult uint8
43+
44+const (
45+ proxyLinkStop proxyLinkResult = iota
46+ proxyLinkResume
47+ proxyLinkReplaced
48+)
49+
50+// dialProxyHandshake installs a candidate transport, sends the proxied Hello
51+// through the session's sole sender, and requires Welcome followed immediately
52+// by valid authoritative metadata. The candidate is never registry-visible.
53+func (d *Daemon) dialProxyHandshake(ctx context.Context, p *proxySession, intent uint8) error {
54+ if err := ctx.Err(); err != nil {
55+ return err
56+ }
57+ dialer, err := d.remoteDialerFactory.DialerForRemote(p.key.Host, p.key.Name, d.remoteTransportMode, d.log)
58+ if err != nil {
59+ return fmt.Errorf("proxy session: select remote dialer: %w", err)
60+ }
61+ if dialer == nil {
62+ return errors.New("proxy session: remote dialer factory returned nil dialer")
63+ }
64+ if err := ctx.Err(); err != nil {
65+ return err
66+ }
67+ transport, err := dialer.Dial(ctx)
68+ if err != nil {
69+ return fmt.Errorf("proxy session: dial: %w", err)
70+ }
71+ if transport == nil {
72+ return errors.New("proxy session: remote dialer returned nil transport")
73+ }
74+ if err := ctx.Err(); err != nil {
75+ _ = transport.Close()
76+ return err
77+ }
78+
79+ generation, previous := p.installTransport(transport)
80+ if previous != nil && previous != transport {
81+ _ = previous.Close()
82+ }
83+ failed := true
84+ defer func() {
85+ if failed {
86+ p.retireTransport(generation, transport)
87+ _ = transport.Close()
88+ }
89+ }()
90+
91+ p.mu.Lock()
92+ resumeToken := p.resumeToken
93+ clientID := p.clientID
94+ size := p.contentSize
95+ p.mu.Unlock()
96+ if intent != ports.IntentResume {
97+ resumeToken = 0
98+ }
99+ hello := ports.Hello{
100+ Version: ports.ProtocolVersion,
101+ Intent: intent,
102+ Proxied: true,
103+ ClientID: clientID,
104+ ResumeToken: resumeToken,
105+ Name: p.key.Name,
106+ Size: size,
107+ MaxOutputInFlight: proxyOutputWindow(transport),
108+ }
109+ if err := p.sendGeneration(generation, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
110+ return fmt.Errorf("proxy session: send hello: %w", err)
111+ }
112+
113+ welcome, meta, err := recvProxyHandshake(ctx, transport, p.key.Name)
114+ if err != nil {
115+ return err
116+ }
117+ if err := ctx.Err(); err != nil {
118+ return err
119+ }
120+
121+ p.mu.Lock()
122+ if p.linkGeneration != generation || p.transport != transport {
123+ p.mu.Unlock()
124+ return errors.New("proxy session: link replaced during handshake")
125+ }
126+ p.resumeToken = welcome.ResumeToken
127+ p.replaceSessionMetaLocked(meta, d.clock.Now())
128+ p.linkState = ports.LinkStateConnected
129+ p.expired = false
130+ p.mu.Unlock()
131+ failed = false
132+ return nil
133+}
134+
135+func proxyOutputWindow(transport ports.Transport) uint8 {
136+ if _, ok := transport.(ports.DatagramTransport); ok {
137+ return 1
138+ }
139+ return maxUnackedOutputStates
140+}
141+
142+func recvProxyHandshake(ctx context.Context, transport ports.Transport, sessionName string) (ports.Welcome, ports.SessionMeta, error) {
143+ frame, err := recvProxyHandshakeFrame(ctx, transport)
144+ if err != nil {
145+ return ports.Welcome{}, ports.SessionMeta{}, fmt.Errorf("proxy session: receive welcome: %w", err)
146+ }
147+ welcome, err := validateProxyWelcome(frame, sessionName)
148+ if err != nil {
149+ return ports.Welcome{}, ports.SessionMeta{}, err
150+ }
151+
152+ frame, err = recvProxyHandshakeFrame(ctx, transport)
153+ if err != nil {
154+ return ports.Welcome{}, ports.SessionMeta{}, fmt.Errorf("proxy session: receive initial metadata: %w", err)
155+ }
156+ meta, err := validateProxyInitialMeta(frame, sessionName)
157+ if err != nil {
158+ return ports.Welcome{}, ports.SessionMeta{}, err
159+ }
160+ return welcome, meta, nil
161+}
162+
163+func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, error) {
164+ if frame.Type == ports.MsgError {
165+ remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
166+ if err != nil {
167+ return ports.Welcome{}, fmt.Errorf("proxy session: malformed remote error: %w", err)
168+ }
169+ return ports.Welcome{}, fmt.Errorf("proxy session: remote rejected handshake (%d): %s", remoteErr.Code, remoteErr.Text)
170+ }
171+ if frame.Type != ports.MsgWelcome {
172+ return ports.Welcome{}, fmt.Errorf("proxy session: first server frame is %d, want Welcome", frame.Type)
173+ }
174+ welcome, err := ports.UnmarshalWelcome(frame.Payload)
175+ if err != nil {
176+ return ports.Welcome{}, fmt.Errorf("proxy session: decode welcome: %w", err)
177+ }
178+ if welcome.Capabilities&ports.CapabilityProxied == 0 {
179+ return ports.Welcome{}, errors.New("proxy session: remote did not negotiate proxied capability")
180+ }
181+ if welcome.SessionID == "" || welcome.SessionName != sessionName {
182+ return ports.Welcome{}, fmt.Errorf("proxy session: welcome identity mismatch: got %q", welcome.SessionName)
183+ }
184+ return welcome, nil
185+}
186+
187+func validateProxyInitialMeta(frame ports.Frame, sessionName string) (ports.SessionMeta, error) {
188+ if frame.Type != ports.MsgSessionMeta {
189+ return ports.SessionMeta{}, fmt.Errorf("proxy session: second server frame is %d, want SessionMeta", frame.Type)
190+ }
191+ meta, err := ports.UnmarshalSessionMeta(frame.Payload)
192+ if err != nil {
193+ return ports.SessionMeta{}, fmt.Errorf("proxy session: decode initial metadata: %w", err)
194+ }
195+ if meta.SessionName != sessionName {
196+ return ports.SessionMeta{}, fmt.Errorf("proxy session: metadata identity mismatch: got %q", meta.SessionName)
197+ }
198+ return meta, nil
199+}
200+
201+func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {
202+ result := make(chan proxyRecvResult, 1)
203+ go func() {
204+ frame, err := transport.Recv()
205+ result <- proxyRecvResult{frame: frame, err: err}
206+ }()
207+ select {
208+ case received := <-result:
209+ return received.frame, received.err
210+ case <-ctx.Done():
211+ _ = transport.Close()
212+ received := <-result // Transport.Close is required to unblock Recv.
213+ if received.err != nil && !errors.Is(received.err, io.EOF) {
214+ return ports.Frame{}, errors.Join(ctx.Err(), received.err)
215+ }
216+ return ports.Frame{}, ctx.Err()
217+ }
218+}
219+
220+func (p *proxySession) installTransport(transport ports.Transport) (uint64, ports.Transport) {
221+ // Link replacement participates in the same sender serialization as every
222+ // frame. A request can therefore only be published wholly before the
223+ // generation changes or wholly after it.
224+ p.sendMu.Lock()
225+ defer p.sendMu.Unlock()
226+ p.mu.Lock()
227+ previous := p.transport
228+ p.linkGeneration++
229+ generation := p.linkGeneration
230+ // State numbers belong to one remote output stream. A replacement link
231+ // must prove its state with a base-zero paint before an incremental frame
232+ // can advance this proxy.
233+ p.resetOutputStateLocked()
234+ p.transport = transport
235+ p.linkState = ports.LinkStateConnected
236+ p.mu.Unlock()
237+ p.failPendingCommandsLocked(errProxyCommandGeneration)
238+ return generation, previous
239+}
240+
241+func (p *proxySession) retireTransport(generation uint64, transport ports.Transport) {
242+ p.sendMu.Lock()
243+ defer p.sendMu.Unlock()
244+ p.mu.Lock()
245+ current := p.linkGeneration == generation && p.transport == transport
246+ if current {
247+ p.transport = nil
248+ }
249+ p.mu.Unlock()
250+ if current {
251+ p.failPendingCommandsLocked(errProxyCommandUnavailable)
252+ }
253+}
254+
255+// Every proxy Transport.Send path is serialized by sendMu: Hello, input,
256+// resize, ACK/reset, ping/pong, and command traffic. The leaf proxy lock is
257+// held only long enough to revalidate
258+// the immutable link snapshot; I/O happens after releasing it.
259+func (p *proxySession) sendCurrent(frame ports.Frame) error {
260+ p.mu.Lock()
261+ generation := p.linkGeneration
262+ p.mu.Unlock()
263+ return p.sendGeneration(generation, frame)
264+}
265+
266+func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error {
267+ p.sendMu.Lock()
268+ defer p.sendMu.Unlock()
269+ p.mu.Lock()
270+ if p.linkGeneration != generation || p.transport == nil {
271+ p.mu.Unlock()
272+ return errors.New("proxy session: stale or unavailable link")
273+ }
274+ transport := p.transport
275+ p.mu.Unlock()
276+ return transport.Send(frame)
277+}
278+
279+// sendCommand sends one correlated attached palette command and waits for its
280+// exact result. commandMu serializes interactive requests while sendMu remains
281+// available to all other link traffic during the wait.
282+func (p *proxySession) sendCommand(ctx context.Context, clock ports.Clock, slug string, args []string) (ports.CommandResult, error) {
283+ if p == nil || clock == nil {
284+ return ports.CommandResult{}, errProxyCommandUnavailable
285+ }
286+ if ctx == nil {
287+ ctx = context.Background()
288+ }
289+ if err := ctx.Err(); err != nil {
290+ return ports.CommandResult{}, err
291+ }
292+ p.commandMu.Lock()
293+ defer p.commandMu.Unlock()
294+ if err := ctx.Err(); err != nil {
295+ return ports.CommandResult{}, err
296+ }
297+
298+ p.sendMu.Lock()
299+ p.mu.Lock()
300+ generation, transport := p.linkGeneration, p.transport
301+ p.mu.Unlock()
302+ if transport == nil {
303+ p.sendMu.Unlock()
304+ return ports.CommandResult{}, errProxyCommandUnavailable
305+ }
306+ p.commandNext++
307+ if p.commandNext == 0 {
308+ p.commandNext++
309+ }
310+ requestID := p.commandNext
311+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
312+ Version: ports.ProtocolVersion, RequestID: requestID, Attached: true,
313+ Slug: slug, Args: append([]string(nil), args...),
314+ })
315+ if err != nil {
316+ p.sendMu.Unlock()
317+ return ports.CommandResult{}, err
318+ }
319+ outcome := make(chan proxyCommandOutcome, 1)
320+ p.commandPending[requestID] = proxyCommandPending{generation: generation, outcome: outcome}
321+ if err := transport.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
322+ delete(p.commandPending, requestID)
323+ p.sendMu.Unlock()
324+ return ports.CommandResult{}, err
325+ }
326+ p.sendMu.Unlock()
327+
328+ timer := clock.NewTimer(proxyAttachedCommandTimeout)
329+ if timer == nil {
330+ p.removePendingCommand(requestID, generation)
331+ return ports.CommandResult{}, errProxyCommandTimeout
332+ }
333+ defer timer.Stop()
334+ select {
335+ case completed := <-outcome:
336+ return completed.result, completed.err
337+ case <-ctx.Done():
338+ p.removePendingCommand(requestID, generation)
339+ return ports.CommandResult{}, ctx.Err()
340+ case <-timer.C():
341+ p.removePendingCommand(requestID, generation)
342+ return ports.CommandResult{}, errProxyCommandTimeout
343+ }
344+}
345+
346+func (p *proxySession) removePendingCommand(requestID, generation uint64) {
347+ p.sendMu.Lock()
348+ pending, ok := p.commandPending[requestID]
349+ if ok && pending.generation == generation {
350+ delete(p.commandPending, requestID)
351+ }
352+ p.sendMu.Unlock()
353+}
354+
355+// completeCommandResult ignores malformed correlation state, unknown/late IDs,
356+// and results received by a replacement generation.
357+func (p *proxySession) completeCommandResult(generation uint64, result ports.CommandResult) {
358+ if result.RequestID == 0 {
359+ return
360+ }
361+ p.sendMu.Lock()
362+ pending, ok := p.commandPending[result.RequestID]
363+ if !ok || pending.generation != generation {
364+ p.sendMu.Unlock()
365+ return
366+ }
367+ delete(p.commandPending, result.RequestID)
368+ p.sendMu.Unlock()
369+ pending.outcome <- proxyCommandOutcome{result: result}
370+}
371+
372+// failPendingCommandsLocked requires sendMu and never blocks.
373+func (p *proxySession) failPendingCommandsLocked(err error) {
374+ for id, pending := range p.commandPending {
375+ delete(p.commandPending, id)
376+ pending.outcome <- proxyCommandOutcome{err: err}
377+ }
378+}
379+
380+func (d *Daemon) runProxyLink(ctx context.Context, p *proxySession) {
381+ defer p.finish()
382+ connectedAt := d.clock.Now()
383+ resumeAttempts := 0
384+ for {
385+ p.mu.Lock()
386+ generation := p.linkGeneration
387+ transport := p.transport
388+ p.mu.Unlock()
389+ if transport == nil {
390+ return
391+ }
392+
393+ result := d.runProxyTransport(ctx, p, generation, transport)
394+ if result == proxyLinkReplaced {
395+ // ReasonReplaced is terminal for the exact transport incarnation. Publish
396+ // expiry before retirement clears the transport identity used by the
397+ // callback fence; the switch below deliberately never redials it.
398+ d.markProxyReplaced(p, generation, transport)
399+ }
400+ p.retireTransport(generation, transport)
401+ switch result {
402+ case proxyLinkReplaced, proxyLinkStop:
403+ return
404+ case proxyLinkResume:
405+ if ctx.Err() != nil {
406+ return
407+ }
408+ if d.clock.Now().Sub(connectedAt) >= proxyResumeStableDuration {
409+ resumeAttempts = 0
410+ }
411+ d.updateProxyDisconnectedState(p, generation, ports.LinkStateOffline)
412+
413+ for {
414+ if resumeAttempts >= proxyResumeMaxAttempts {
415+ d.log.Warn("proxy resume attempts exhausted", "host", p.key.Host, "session", p.key.Name, "attempts", resumeAttempts)
416+ return
417+ }
418+ delay := proxyResumeBackoff(resumeAttempts)
419+ if !waitProxyResume(ctx, d.clock, delay) {
420+ return
421+ }
422+ resumeAttempts++
423+ if err := d.dialProxyHandshake(ctx, p, ports.IntentResume); err != nil {
424+ p.mu.Lock()
425+ failedGeneration := p.linkGeneration
426+ p.mu.Unlock()
427+ d.updateProxyDisconnectedState(p, failedGeneration, ports.LinkStateOffline)
428+ if ctx.Err() != nil {
429+ return
430+ }
431+ d.log.Warn("proxy resume failed", "host", p.key.Host, "session", p.key.Name, "attempt", resumeAttempts, "err", err)
432+ continue
433+ }
434+ connectedAt = d.clock.Now()
435+ d.repaintProxyLifecycle(p)
436+ break
437+ }
438+ }
439+ }
440+}
441+
442+func proxyResumeBackoff(attempt int) time.Duration {
443+ delay := proxyResumeInitialBackoff
444+ for range attempt {
445+ if delay >= proxyResumeMaxBackoff/2 {
446+ return proxyResumeMaxBackoff
447+ }
448+ delay *= 2
449+ }
450+ return min(delay, proxyResumeMaxBackoff)
451+}
452+
453+func waitProxyResume(ctx context.Context, clock ports.Clock, delay time.Duration) bool {
454+ if err := ctx.Err(); err != nil {
455+ return false
456+ }
457+ timer := clock.NewTimer(delay)
458+ if timer == nil {
459+ return false
460+ }
461+ defer timer.Stop()
462+ select {
463+ case <-ctx.Done():
464+ return false
465+ case <-timer.C():
466+ return true
467+ }
468+}
469+
470+func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generation uint64, transport ports.Transport) proxyLinkResult {
471+ recvCtx, cancelRecv := context.WithCancel(ctx)
472+ recv := make(chan proxyRecvResult, 1)
473+ recvDone := make(chan struct{})
474+ go func() {
475+ defer close(recvDone)
476+ for {
477+ frame, err := transport.Recv()
478+ select {
479+ case recv <- proxyRecvResult{frame: frame, err: err}:
480+ case <-recvCtx.Done():
481+ return
482+ }
483+ if err != nil {
484+ return
485+ }
486+ }
487+ }()
488+ defer func() {
489+ cancelRecv()
490+ _ = transport.Close()
491+ <-recvDone
492+ }()
493+
494+ var events <-chan ports.LinkEvent
495+ if reporter, ok := transport.(ports.LinkStateReporter); ok {
496+ events = reporter.LinkEvents()
497+ }
498+ for {
499+ select {
500+ case <-ctx.Done():
501+ return proxyLinkStop
502+ case event, ok := <-events:
503+ if !ok {
504+ events = nil
505+ continue
506+ }
507+ if !d.updateProxyLinkState(p, generation, transport, event.State) {
508+ return proxyLinkStop
509+ }
510+ if event.State == ports.LinkStateOffline || event.State == ports.LinkStateDead {
511+ _ = transport.Close()
512+ return proxyLinkResume
513+ }
514+ case received := <-recv:
515+ if received.err != nil {
516+ if ctx.Err() != nil {
517+ return proxyLinkStop
518+ }
519+ return proxyLinkResume
520+ }
521+ result, err := d.handleLinkFrame(p, generation, received.frame)
522+ if err != nil {
523+ d.log.Warn("invalid proxy link frame", "host", p.key.Host, "session", p.key.Name, "err", err)
524+ return proxyLinkStop
525+ }
526+ if result != proxyLinkResume {
527+ return result
528+ }
529+ }
530+ }
531+}
532+
533+// handleLinkFrame returns proxyLinkResume to mean "continue this transport";
534+// the other values are terminal actions.
535+func (d *Daemon) handleLinkFrame(p *proxySession, generation uint64, frame ports.Frame) (proxyLinkResult, error) {
536+ switch frame.Type {
537+ case ports.MsgOutput:
538+ out, err := ports.UnmarshalOutput(frame.Payload)
539+ if err != nil {
540+ return proxyLinkStop, err
541+ }
542+ if err := d.handleProxyOutput(p, generation, out); err != nil {
543+ return proxyLinkStop, err
544+ }
545+ return proxyLinkResume, nil
546+ case ports.MsgSessionMeta:
547+ meta, err := ports.UnmarshalSessionMeta(frame.Payload)
548+ if err != nil {
549+ return proxyLinkStop, err
550+ }
551+ if _, err := d.applyProxySessionMeta(p, generation, meta); err != nil {
552+ return proxyLinkStop, err
553+ }
554+ return proxyLinkResume, nil
555+ case ports.MsgCommandResult:
556+ result, err := ports.UnmarshalCommandResult(frame.Payload)
557+ if err != nil {
558+ return proxyLinkStop, err
559+ }
560+ p.completeCommandResult(generation, result)
561+ return proxyLinkResume, nil
562+ case ports.MsgPong:
563+ _, err := ports.UnmarshalPong(frame.Payload)
564+ return proxyLinkResume, err
565+ case ports.MsgPing:
566+ if _, err := ports.UnmarshalPing(frame.Payload); err != nil {
567+ return proxyLinkStop, err
568+ }
569+ if err := p.sendGeneration(generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}); err != nil {
570+ return proxyLinkStop, err
571+ }
572+ return proxyLinkResume, nil
573+ case ports.MsgDetached:
574+ detached, err := ports.UnmarshalDetached(frame.Payload)
575+ if err != nil {
576+ return proxyLinkStop, err
577+ }
578+ if detached.Reason == ports.ReasonReplaced {
579+ return proxyLinkReplaced, nil
580+ }
581+ return proxyLinkStop, nil
582+ case ports.MsgError:
583+ remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
584+ if err != nil {
585+ return proxyLinkStop, err
586+ }
587+ return proxyLinkStop, fmt.Errorf("remote proxy error (%d): %s", remoteErr.Code, remoteErr.Text)
588+ default:
589+ return proxyLinkStop, fmt.Errorf("unexpected proxy server frame type %d", frame.Type)
590+ }
591+}
@@ -0,0 +1,591 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+const (
14+ proxyAttachedCommandTimeout = 10 * time.Second
15+ proxyResumeInitialBackoff = 250 * time.Millisecond
16+ proxyResumeMaxBackoff = 4 * time.Second
17+ proxyResumeMaxAttempts = 5
18+ proxyResumeStableDuration = 30 * time.Second
19+)
20+
21+var (
22+ errProxyCommandTimeout = errors.New("proxy session: attached command timed out")
23+ errProxyCommandUnavailable = errors.New("proxy session: attached command link is unavailable")
24+ errProxyCommandGeneration = errors.New("proxy session: attached command link was replaced")
25+)
26+
27+type proxyCommandOutcome struct {
28+ result ports.CommandResult
29+ err error
30+}
31+
32+type proxyCommandPending struct {
33+ generation uint64
34+ outcome chan proxyCommandOutcome
35+}
36+
37+type proxyRecvResult struct {
38+ frame ports.Frame
39+ err error
40+}
41+
42+type proxyLinkResult uint8
43+
44+const (
45+ proxyLinkStop proxyLinkResult = iota
46+ proxyLinkResume
47+ proxyLinkReplaced
48+)
49+
50+// dialProxyHandshake installs a candidate transport, sends the proxied Hello
51+// through the session's sole sender, and requires Welcome followed immediately
52+// by valid authoritative metadata. The candidate is never registry-visible.
53+func (d *Daemon) dialProxyHandshake(ctx context.Context, p *proxySession, intent uint8) error {
54+ if err := ctx.Err(); err != nil {
55+ return err
56+ }
57+ dialer, err := d.remoteDialerFactory.DialerForRemote(p.key.Host, p.key.Name, d.remoteTransportMode, d.log)
58+ if err != nil {
59+ return fmt.Errorf("proxy session: select remote dialer: %w", err)
60+ }
61+ if dialer == nil {
62+ return errors.New("proxy session: remote dialer factory returned nil dialer")
63+ }
64+ if err := ctx.Err(); err != nil {
65+ return err
66+ }
67+ transport, err := dialer.Dial(ctx)
68+ if err != nil {
69+ return fmt.Errorf("proxy session: dial: %w", err)
70+ }
71+ if transport == nil {
72+ return errors.New("proxy session: remote dialer returned nil transport")
73+ }
74+ if err := ctx.Err(); err != nil {
75+ _ = transport.Close()
76+ return err
77+ }
78+
79+ generation, previous := p.installTransport(transport)
80+ if previous != nil && previous != transport {
81+ _ = previous.Close()
82+ }
83+ failed := true
84+ defer func() {
85+ if failed {
86+ p.retireTransport(generation, transport)
87+ _ = transport.Close()
88+ }
89+ }()
90+
91+ p.mu.Lock()
92+ resumeToken := p.resumeToken
93+ clientID := p.clientID
94+ size := p.contentSize
95+ p.mu.Unlock()
96+ if intent != ports.IntentResume {
97+ resumeToken = 0
98+ }
99+ hello := ports.Hello{
100+ Version: ports.ProtocolVersion,
101+ Intent: intent,
102+ Proxied: true,
103+ ClientID: clientID,
104+ ResumeToken: resumeToken,
105+ Name: p.key.Name,
106+ Size: size,
107+ MaxOutputInFlight: proxyOutputWindow(transport),
108+ }
109+ if err := p.sendGeneration(generation, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
110+ return fmt.Errorf("proxy session: send hello: %w", err)
111+ }
112+
113+ welcome, meta, err := recvProxyHandshake(ctx, transport, p.key.Name)
114+ if err != nil {
115+ return err
116+ }
117+ if err := ctx.Err(); err != nil {
118+ return err
119+ }
120+
121+ p.mu.Lock()
122+ if p.linkGeneration != generation || p.transport != transport {
123+ p.mu.Unlock()
124+ return errors.New("proxy session: link replaced during handshake")
125+ }
126+ p.resumeToken = welcome.ResumeToken
127+ p.replaceSessionMetaLocked(meta, d.clock.Now())
128+ p.linkState = ports.LinkStateConnected
129+ p.expired = false
130+ p.mu.Unlock()
131+ failed = false
132+ return nil
133+}
134+
135+func proxyOutputWindow(transport ports.Transport) uint8 {
136+ if _, ok := transport.(ports.DatagramTransport); ok {
137+ return 1
138+ }
139+ return maxUnackedOutputStates
140+}
141+
142+func recvProxyHandshake(ctx context.Context, transport ports.Transport, sessionName string) (ports.Welcome, ports.SessionMeta, error) {
143+ frame, err := recvProxyHandshakeFrame(ctx, transport)
144+ if err != nil {
145+ return ports.Welcome{}, ports.SessionMeta{}, fmt.Errorf("proxy session: receive welcome: %w", err)
146+ }
147+ welcome, err := validateProxyWelcome(frame, sessionName)
148+ if err != nil {
149+ return ports.Welcome{}, ports.SessionMeta{}, err
150+ }
151+
152+ frame, err = recvProxyHandshakeFrame(ctx, transport)
153+ if err != nil {
154+ return ports.Welcome{}, ports.SessionMeta{}, fmt.Errorf("proxy session: receive initial metadata: %w", err)
155+ }
156+ meta, err := validateProxyInitialMeta(frame, sessionName)
157+ if err != nil {
158+ return ports.Welcome{}, ports.SessionMeta{}, err
159+ }
160+ return welcome, meta, nil
161+}
162+
163+func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, error) {
164+ if frame.Type == ports.MsgError {
165+ remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
166+ if err != nil {
167+ return ports.Welcome{}, fmt.Errorf("proxy session: malformed remote error: %w", err)
168+ }
169+ return ports.Welcome{}, fmt.Errorf("proxy session: remote rejected handshake (%d): %s", remoteErr.Code, remoteErr.Text)
170+ }
171+ if frame.Type != ports.MsgWelcome {
172+ return ports.Welcome{}, fmt.Errorf("proxy session: first server frame is %d, want Welcome", frame.Type)
173+ }
174+ welcome, err := ports.UnmarshalWelcome(frame.Payload)
175+ if err != nil {
176+ return ports.Welcome{}, fmt.Errorf("proxy session: decode welcome: %w", err)
177+ }
178+ if welcome.Capabilities&ports.CapabilityProxied == 0 {
179+ return ports.Welcome{}, errors.New("proxy session: remote did not negotiate proxied capability")
180+ }
181+ if welcome.SessionID == "" || welcome.SessionName != sessionName {
182+ return ports.Welcome{}, fmt.Errorf("proxy session: welcome identity mismatch: got %q", welcome.SessionName)
183+ }
184+ return welcome, nil
185+}
186+
187+func validateProxyInitialMeta(frame ports.Frame, sessionName string) (ports.SessionMeta, error) {
188+ if frame.Type != ports.MsgSessionMeta {
189+ return ports.SessionMeta{}, fmt.Errorf("proxy session: second server frame is %d, want SessionMeta", frame.Type)
190+ }
191+ meta, err := ports.UnmarshalSessionMeta(frame.Payload)
192+ if err != nil {
193+ return ports.SessionMeta{}, fmt.Errorf("proxy session: decode initial metadata: %w", err)
194+ }
195+ if meta.SessionName != sessionName {
196+ return ports.SessionMeta{}, fmt.Errorf("proxy session: metadata identity mismatch: got %q", meta.SessionName)
197+ }
198+ return meta, nil
199+}
200+
201+func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {
202+ result := make(chan proxyRecvResult, 1)
203+ go func() {
204+ frame, err := transport.Recv()
205+ result <- proxyRecvResult{frame: frame, err: err}
206+ }()
207+ select {
208+ case received := <-result:
209+ return received.frame, received.err
210+ case <-ctx.Done():
211+ _ = transport.Close()
212+ received := <-result // Transport.Close is required to unblock Recv.
213+ if received.err != nil && !errors.Is(received.err, io.EOF) {
214+ return ports.Frame{}, errors.Join(ctx.Err(), received.err)
215+ }
216+ return ports.Frame{}, ctx.Err()
217+ }
218+}
219+
220+func (p *proxySession) installTransport(transport ports.Transport) (uint64, ports.Transport) {
221+ // Link replacement participates in the same sender serialization as every
222+ // frame. A request can therefore only be published wholly before the
223+ // generation changes or wholly after it.
224+ p.sendMu.Lock()
225+ defer p.sendMu.Unlock()
226+ p.mu.Lock()
227+ previous := p.transport
228+ p.linkGeneration++
229+ generation := p.linkGeneration
230+ // State numbers belong to one remote output stream. A replacement link
231+ // must prove its state with a base-zero paint before an incremental frame
232+ // can advance this proxy.
233+ p.resetOutputStateLocked()
234+ p.transport = transport
235+ p.linkState = ports.LinkStateConnected
236+ p.mu.Unlock()
237+ p.failPendingCommandsLocked(errProxyCommandGeneration)
238+ return generation, previous
239+}
240+
241+func (p *proxySession) retireTransport(generation uint64, transport ports.Transport) {
242+ p.sendMu.Lock()
243+ defer p.sendMu.Unlock()
244+ p.mu.Lock()
245+ current := p.linkGeneration == generation && p.transport == transport
246+ if current {
247+ p.transport = nil
248+ }
249+ p.mu.Unlock()
250+ if current {
251+ p.failPendingCommandsLocked(errProxyCommandUnavailable)
252+ }
253+}
254+
255+// Every proxy Transport.Send path is serialized by sendMu: Hello, input,
256+// resize, ACK/reset, ping/pong, and command traffic. The leaf proxy lock is
257+// held only long enough to revalidate
258+// the immutable link snapshot; I/O happens after releasing it.
259+func (p *proxySession) sendCurrent(frame ports.Frame) error {
260+ p.mu.Lock()
261+ generation := p.linkGeneration
262+ p.mu.Unlock()
263+ return p.sendGeneration(generation, frame)
264+}
265+
266+func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error {
267+ p.sendMu.Lock()
268+ defer p.sendMu.Unlock()
269+ p.mu.Lock()
270+ if p.linkGeneration != generation || p.transport == nil {
271+ p.mu.Unlock()
272+ return errors.New("proxy session: stale or unavailable link")
273+ }
274+ transport := p.transport
275+ p.mu.Unlock()
276+ return transport.Send(frame)
277+}
278+
279+// sendCommand sends one correlated attached palette command and waits for its
280+// exact result. commandMu serializes interactive requests while sendMu remains
281+// available to all other link traffic during the wait.
282+func (p *proxySession) sendCommand(ctx context.Context, clock ports.Clock, slug string, args []string) (ports.CommandResult, error) {
283+ if p == nil || clock == nil {
284+ return ports.CommandResult{}, errProxyCommandUnavailable
285+ }
286+ if ctx == nil {
287+ ctx = context.Background()
288+ }
289+ if err := ctx.Err(); err != nil {
290+ return ports.CommandResult{}, err
291+ }
292+ p.commandMu.Lock()
293+ defer p.commandMu.Unlock()
294+ if err := ctx.Err(); err != nil {
295+ return ports.CommandResult{}, err
296+ }
297+
298+ p.sendMu.Lock()
299+ p.mu.Lock()
300+ generation, transport := p.linkGeneration, p.transport
301+ p.mu.Unlock()
302+ if transport == nil {
303+ p.sendMu.Unlock()
304+ return ports.CommandResult{}, errProxyCommandUnavailable
305+ }
306+ p.commandNext++
307+ if p.commandNext == 0 {
308+ p.commandNext++
309+ }
310+ requestID := p.commandNext
311+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
312+ Version: ports.ProtocolVersion, RequestID: requestID, Attached: true,
313+ Slug: slug, Args: append([]string(nil), args...),
314+ })
315+ if err != nil {
316+ p.sendMu.Unlock()
317+ return ports.CommandResult{}, err
318+ }
319+ outcome := make(chan proxyCommandOutcome, 1)
320+ p.commandPending[requestID] = proxyCommandPending{generation: generation, outcome: outcome}
321+ if err := transport.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
322+ delete(p.commandPending, requestID)
323+ p.sendMu.Unlock()
324+ return ports.CommandResult{}, err
325+ }
326+ p.sendMu.Unlock()
327+
328+ timer := clock.NewTimer(proxyAttachedCommandTimeout)
329+ if timer == nil {
330+ p.removePendingCommand(requestID, generation)
331+ return ports.CommandResult{}, errProxyCommandTimeout
332+ }
333+ defer timer.Stop()
334+ select {
335+ case completed := <-outcome:
336+ return completed.result, completed.err
337+ case <-ctx.Done():
338+ p.removePendingCommand(requestID, generation)
339+ return ports.CommandResult{}, ctx.Err()
340+ case <-timer.C():
341+ p.removePendingCommand(requestID, generation)
342+ return ports.CommandResult{}, errProxyCommandTimeout
343+ }
344+}
345+
346+func (p *proxySession) removePendingCommand(requestID, generation uint64) {
347+ p.sendMu.Lock()
348+ pending, ok := p.commandPending[requestID]
349+ if ok && pending.generation == generation {
350+ delete(p.commandPending, requestID)
351+ }
352+ p.sendMu.Unlock()
353+}
354+
355+// completeCommandResult ignores malformed correlation state, unknown/late IDs,
356+// and results received by a replacement generation.
357+func (p *proxySession) completeCommandResult(generation uint64, result ports.CommandResult) {
358+ if result.RequestID == 0 {
359+ return
360+ }
361+ p.sendMu.Lock()
362+ pending, ok := p.commandPending[result.RequestID]
363+ if !ok || pending.generation != generation {
364+ p.sendMu.Unlock()
365+ return
366+ }
367+ delete(p.commandPending, result.RequestID)
368+ p.sendMu.Unlock()
369+ pending.outcome <- proxyCommandOutcome{result: result}
370+}
371+
372+// failPendingCommandsLocked requires sendMu and never blocks.
373+func (p *proxySession) failPendingCommandsLocked(err error) {
374+ for id, pending := range p.commandPending {
375+ delete(p.commandPending, id)
376+ pending.outcome <- proxyCommandOutcome{err: err}
377+ }
378+}
379+
380+func (d *Daemon) runProxyLink(ctx context.Context, p *proxySession) {
381+ defer p.finish()
382+ connectedAt := d.clock.Now()
383+ resumeAttempts := 0
384+ for {
385+ p.mu.Lock()
386+ generation := p.linkGeneration
387+ transport := p.transport
388+ p.mu.Unlock()
389+ if transport == nil {
390+ return
391+ }
392+
393+ result := d.runProxyTransport(ctx, p, generation, transport)
394+ if result == proxyLinkReplaced {
395+ // ReasonReplaced is terminal for the exact transport incarnation. Publish
396+ // expiry before retirement clears the transport identity used by the
397+ // callback fence; the switch below deliberately never redials it.
398+ d.markProxyReplaced(p, generation, transport)
399+ }
400+ p.retireTransport(generation, transport)
401+ switch result {
402+ case proxyLinkReplaced, proxyLinkStop:
403+ return
404+ case proxyLinkResume:
405+ if ctx.Err() != nil {
406+ return
407+ }
408+ if d.clock.Now().Sub(connectedAt) >= proxyResumeStableDuration {
409+ resumeAttempts = 0
410+ }
411+ d.updateProxyDisconnectedState(p, generation, ports.LinkStateOffline)
412+
413+ for {
414+ if resumeAttempts >= proxyResumeMaxAttempts {
415+ d.log.Warn("proxy resume attempts exhausted", "host", p.key.Host, "session", p.key.Name, "attempts", resumeAttempts)
416+ return
417+ }
418+ delay := proxyResumeBackoff(resumeAttempts)
419+ if !waitProxyResume(ctx, d.clock, delay) {
420+ return
421+ }
422+ resumeAttempts++
423+ if err := d.dialProxyHandshake(ctx, p, ports.IntentResume); err != nil {
424+ p.mu.Lock()
425+ failedGeneration := p.linkGeneration
426+ p.mu.Unlock()
427+ d.updateProxyDisconnectedState(p, failedGeneration, ports.LinkStateOffline)
428+ if ctx.Err() != nil {
429+ return
430+ }
431+ d.log.Warn("proxy resume failed", "host", p.key.Host, "session", p.key.Name, "attempt", resumeAttempts, "err", err)
432+ continue
433+ }
434+ connectedAt = d.clock.Now()
435+ d.repaintProxyLifecycle(p)
436+ break
437+ }
438+ }
439+ }
440+}
441+
442+func proxyResumeBackoff(attempt int) time.Duration {
443+ delay := proxyResumeInitialBackoff
444+ for range attempt {
445+ if delay >= proxyResumeMaxBackoff/2 {
446+ return proxyResumeMaxBackoff
447+ }
448+ delay *= 2
449+ }
450+ return min(delay, proxyResumeMaxBackoff)
451+}
452+
453+func waitProxyResume(ctx context.Context, clock ports.Clock, delay time.Duration) bool {
454+ if err := ctx.Err(); err != nil {
455+ return false
456+ }
457+ timer := clock.NewTimer(delay)
458+ if timer == nil {
459+ return false
460+ }
461+ defer timer.Stop()
462+ select {
463+ case <-ctx.Done():
464+ return false
465+ case <-timer.C():
466+ return true
467+ }
468+}
469+
470+func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generation uint64, transport ports.Transport) proxyLinkResult {
471+ recvCtx, cancelRecv := context.WithCancel(ctx)
472+ recv := make(chan proxyRecvResult, 1)
473+ recvDone := make(chan struct{})
474+ go func() {
475+ defer close(recvDone)
476+ for {
477+ frame, err := transport.Recv()
478+ select {
479+ case recv <- proxyRecvResult{frame: frame, err: err}:
480+ case <-recvCtx.Done():
481+ return
482+ }
483+ if err != nil {
484+ return
485+ }
486+ }
487+ }()
488+ defer func() {
489+ cancelRecv()
490+ _ = transport.Close()
491+ <-recvDone
492+ }()
493+
494+ var events <-chan ports.LinkEvent
495+ if reporter, ok := transport.(ports.LinkStateReporter); ok {
496+ events = reporter.LinkEvents()
497+ }
498+ for {
499+ select {
500+ case <-ctx.Done():
501+ return proxyLinkStop
502+ case event, ok := <-events:
503+ if !ok {
504+ events = nil
505+ continue
506+ }
507+ if !d.updateProxyLinkState(p, generation, transport, event.State) {
508+ return proxyLinkStop
509+ }
510+ if event.State == ports.LinkStateOffline || event.State == ports.LinkStateDead {
511+ _ = transport.Close()
512+ return proxyLinkResume
513+ }
514+ case received := <-recv:
515+ if received.err != nil {
516+ if ctx.Err() != nil {
517+ return proxyLinkStop
518+ }
519+ return proxyLinkResume
520+ }
521+ result, err := d.handleLinkFrame(p, generation, received.frame)
522+ if err != nil {
523+ d.log.Warn("invalid proxy link frame", "host", p.key.Host, "session", p.key.Name, "err", err)
524+ return proxyLinkStop
525+ }
526+ if result != proxyLinkResume {
527+ return result
528+ }
529+ }
530+ }
531+}
532+
533+// handleLinkFrame returns proxyLinkResume to mean "continue this transport";
534+// the other values are terminal actions.
535+func (d *Daemon) handleLinkFrame(p *proxySession, generation uint64, frame ports.Frame) (proxyLinkResult, error) {
536+ switch frame.Type {
537+ case ports.MsgOutput:
538+ out, err := ports.UnmarshalOutput(frame.Payload)
539+ if err != nil {
540+ return proxyLinkStop, err
541+ }
542+ if err := d.handleProxyOutput(p, generation, out); err != nil {
543+ return proxyLinkStop, err
544+ }
545+ return proxyLinkResume, nil
546+ case ports.MsgSessionMeta:
547+ meta, err := ports.UnmarshalSessionMeta(frame.Payload)
548+ if err != nil {
549+ return proxyLinkStop, err
550+ }
551+ if _, err := d.applyProxySessionMeta(p, generation, meta); err != nil {
552+ return proxyLinkStop, err
553+ }
554+ return proxyLinkResume, nil
555+ case ports.MsgCommandResult:
556+ result, err := ports.UnmarshalCommandResult(frame.Payload)
557+ if err != nil {
558+ return proxyLinkStop, err
559+ }
560+ p.completeCommandResult(generation, result)
561+ return proxyLinkResume, nil
562+ case ports.MsgPong:
563+ _, err := ports.UnmarshalPong(frame.Payload)
564+ return proxyLinkResume, err
565+ case ports.MsgPing:
566+ if _, err := ports.UnmarshalPing(frame.Payload); err != nil {
567+ return proxyLinkStop, err
568+ }
569+ if err := p.sendGeneration(generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}); err != nil {
570+ return proxyLinkStop, err
571+ }
572+ return proxyLinkResume, nil
573+ case ports.MsgDetached:
574+ detached, err := ports.UnmarshalDetached(frame.Payload)
575+ if err != nil {
576+ return proxyLinkStop, err
577+ }
578+ if detached.Reason == ports.ReasonReplaced {
579+ return proxyLinkReplaced, nil
580+ }
581+ return proxyLinkStop, nil
582+ case ports.MsgError:
583+ remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
584+ if err != nil {
585+ return proxyLinkStop, err
586+ }
587+ return proxyLinkStop, fmt.Errorf("remote proxy error (%d): %s", remoteErr.Code, remoteErr.Text)
588+ default:
589+ return proxyLinkStop, fmt.Errorf("unexpected proxy server frame type %d", frame.Type)
590+ }
591+}
A internal/usecase/daemon/proxy_output.go +112 -0
@@ -0,0 +1,112 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/pkg/vt"
8+)
9+
10+// applyOutput applies one decoded remote output frame for the current link.
11+// Link readers use applyOutputForGeneration to bind processing to the exact
12+// transport generation that received the frame.
13+func (p *proxySession) applyOutput(out ports.Output) (ack uint64, requestReset, changed bool) {
14+ p.mu.Lock()
15+ generation := p.linkGeneration
16+ p.mu.Unlock()
17+ return p.applyOutputForGeneration(generation, out)
18+}
19+
20+// applyOutputForGeneration applies one decoded remote output frame while
21+// holding only the proxy leaf lock. The supplied link generation prevents a
22+// receive goroutine from an old transport advancing the replacement link's VT
23+// state.
24+func (p *proxySession) applyOutputForGeneration(generation uint64, out ports.Output) (ack uint64, requestReset, changed bool) {
25+ // Screen.Write may retain a partial escape sequence, so do not retain a
26+ // transport-owned payload.
27+ data := append([]byte(nil), out.Data...)
28+
29+ p.mu.Lock()
30+ defer p.mu.Unlock()
31+ if p.linkGeneration != generation {
32+ return 0, false, false
33+ }
34+ if p.screen == nil {
35+ return 0, false, false
36+ }
37+ if out.NewStateNum == 0 {
38+ p.screen.Write(data)
39+ return 0, false, len(data) != 0
40+ }
41+ if out.BaseStateNum == 0 {
42+ p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)
43+ p.screen.Write(data)
44+ p.appliedState = out.NewStateNum
45+ p.awaitingReset = false
46+ return out.NewStateNum, false, true
47+ }
48+ // Stateful frames are consecutive by construction. A duplicate or an
49+ // otherwise non-monotonic state is invalid just like a missing dependency.
50+ if p.awaitingReset || out.BaseStateNum != p.appliedState || out.NewStateNum != out.BaseStateNum+1 {
51+ if !p.awaitingReset {
52+ p.awaitingReset = true
53+ requestReset = true
54+ }
55+ return 0, requestReset, false
56+ }
57+ p.screen.Write(data)
58+ p.appliedState = out.NewStateNum
59+ return out.NewStateNum, false, len(data) != 0
60+}
61+
62+// handleProxyOutput completes output handling after applyOutputForGeneration
63+// has released proxy.mu. In particular, transport I/O and local render
64+// invalidation must never run under the VT lock.
65+func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports.Output) error {
66+ ack, requestReset, changed := p.applyOutputForGeneration(generation, out)
67+ if ack != 0 {
68+ if err := p.sendGeneration(generation, ports.Frame{
69+ Type: ports.MsgAck,
70+ Payload: ports.MarshalAck(ports.Ack{AckedStateNum: ack}),
71+ }); err != nil {
72+ return fmt.Errorf("proxy output ACK: %w", err)
73+ }
74+ }
75+ if requestReset {
76+ if err := p.sendGeneration(generation, ports.Frame{
77+ Type: ports.MsgOutputResetRequest,
78+ Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
79+ }); err != nil {
80+ return fmt.Errorf("proxy output reset request: %w", err)
81+ }
82+ }
83+ if !changed || !p.currentLinkGeneration(generation) {
84+ return nil
85+ }
86+
87+ p.sessionCore.mu.Lock()
88+ ac := p.client
89+ p.sessionCore.mu.Unlock()
90+ d.invalidateRender(p, ac, false, "proxy_output.go")
91+ return nil
92+}
93+
94+// resetOutputState discards the dependency chain after a local content resize.
95+// The next incremental remote frame is rejected and triggers a request for an
96+// authoritative base-zero paint.
97+func (p *proxySession) resetOutputState() {
98+ p.mu.Lock()
99+ defer p.mu.Unlock()
100+ p.resetOutputStateLocked()
101+}
102+
103+func (p *proxySession) resetOutputStateLocked() {
104+ p.appliedState = 0
105+ p.awaitingReset = false
106+}
107+
108+func (p *proxySession) currentLinkGeneration(generation uint64) bool {
109+ p.mu.Lock()
110+ defer p.mu.Unlock()
111+ return p.linkGeneration == generation
112+}
@@ -0,0 +1,112 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/pkg/vt"
8+)
9+
10+// applyOutput applies one decoded remote output frame for the current link.
11+// Link readers use applyOutputForGeneration to bind processing to the exact
12+// transport generation that received the frame.
13+func (p *proxySession) applyOutput(out ports.Output) (ack uint64, requestReset, changed bool) {
14+ p.mu.Lock()
15+ generation := p.linkGeneration
16+ p.mu.Unlock()
17+ return p.applyOutputForGeneration(generation, out)
18+}
19+
20+// applyOutputForGeneration applies one decoded remote output frame while
21+// holding only the proxy leaf lock. The supplied link generation prevents a
22+// receive goroutine from an old transport advancing the replacement link's VT
23+// state.
24+func (p *proxySession) applyOutputForGeneration(generation uint64, out ports.Output) (ack uint64, requestReset, changed bool) {
25+ // Screen.Write may retain a partial escape sequence, so do not retain a
26+ // transport-owned payload.
27+ data := append([]byte(nil), out.Data...)
28+
29+ p.mu.Lock()
30+ defer p.mu.Unlock()
31+ if p.linkGeneration != generation {
32+ return 0, false, false
33+ }
34+ if p.screen == nil {
35+ return 0, false, false
36+ }
37+ if out.NewStateNum == 0 {
38+ p.screen.Write(data)
39+ return 0, false, len(data) != 0
40+ }
41+ if out.BaseStateNum == 0 {
42+ p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)
43+ p.screen.Write(data)
44+ p.appliedState = out.NewStateNum
45+ p.awaitingReset = false
46+ return out.NewStateNum, false, true
47+ }
48+ // Stateful frames are consecutive by construction. A duplicate or an
49+ // otherwise non-monotonic state is invalid just like a missing dependency.
50+ if p.awaitingReset || out.BaseStateNum != p.appliedState || out.NewStateNum != out.BaseStateNum+1 {
51+ if !p.awaitingReset {
52+ p.awaitingReset = true
53+ requestReset = true
54+ }
55+ return 0, requestReset, false
56+ }
57+ p.screen.Write(data)
58+ p.appliedState = out.NewStateNum
59+ return out.NewStateNum, false, len(data) != 0
60+}
61+
62+// handleProxyOutput completes output handling after applyOutputForGeneration
63+// has released proxy.mu. In particular, transport I/O and local render
64+// invalidation must never run under the VT lock.
65+func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports.Output) error {
66+ ack, requestReset, changed := p.applyOutputForGeneration(generation, out)
67+ if ack != 0 {
68+ if err := p.sendGeneration(generation, ports.Frame{
69+ Type: ports.MsgAck,
70+ Payload: ports.MarshalAck(ports.Ack{AckedStateNum: ack}),
71+ }); err != nil {
72+ return fmt.Errorf("proxy output ACK: %w", err)
73+ }
74+ }
75+ if requestReset {
76+ if err := p.sendGeneration(generation, ports.Frame{
77+ Type: ports.MsgOutputResetRequest,
78+ Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
79+ }); err != nil {
80+ return fmt.Errorf("proxy output reset request: %w", err)
81+ }
82+ }
83+ if !changed || !p.currentLinkGeneration(generation) {
84+ return nil
85+ }
86+
87+ p.sessionCore.mu.Lock()
88+ ac := p.client
89+ p.sessionCore.mu.Unlock()
90+ d.invalidateRender(p, ac, false, "proxy_output.go")
91+ return nil
92+}
93+
94+// resetOutputState discards the dependency chain after a local content resize.
95+// The next incremental remote frame is rejected and triggers a request for an
96+// authoritative base-zero paint.
97+func (p *proxySession) resetOutputState() {
98+ p.mu.Lock()
99+ defer p.mu.Unlock()
100+ p.resetOutputStateLocked()
101+}
102+
103+func (p *proxySession) resetOutputStateLocked() {
104+ p.appliedState = 0
105+ p.awaitingReset = false
106+}
107+
108+func (p *proxySession) currentLinkGeneration(generation uint64) bool {
109+ p.mu.Lock()
110+ defer p.mu.Unlock()
111+ return p.linkGeneration == generation
112+}
A internal/usecase/daemon/proxy_output_test.go +641 -0
@@ -0,0 +1,641 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+type proxyRecv struct {
20+ frame ports.Frame
21+ err error
22+}
23+
24+type proxyTestTransport struct {
25+ recv chan proxyRecv
26+ closed chan struct{}
27+ closeOnce sync.Once
28+ sent chan ports.Frame
29+
30+ sendGate <-chan struct{}
31+ sendEntered chan struct{}
32+ ungatedSends atomic.Int32
33+ sending atomic.Int32
34+ concurrent atomic.Bool
35+ receiving atomic.Int32
36+}
37+
38+func newProxyTestTransport() *proxyTestTransport {
39+ return newProxyTestTransportWithSendGate(nil, nil, 0)
40+}
41+
42+func newProxyTestTransportWithSendGate(gate <-chan struct{}, entered chan struct{}, ungatedSends int32) *proxyTestTransport {
43+ transport := &proxyTestTransport{
44+ recv: make(chan proxyRecv, 16),
45+ closed: make(chan struct{}),
46+ sent: make(chan ports.Frame, 32),
47+ sendGate: gate,
48+ sendEntered: entered,
49+ }
50+ transport.ungatedSends.Store(ungatedSends)
51+ return transport
52+}
53+
54+func (t *proxyTestTransport) Send(frame ports.Frame) error {
55+ if t.sending.Add(1) != 1 {
56+ t.concurrent.Store(true)
57+ }
58+ defer t.sending.Add(-1)
59+ if t.sendGate != nil && t.ungatedSends.Add(-1) < 0 {
60+ if t.sendEntered != nil {
61+ select {
62+ case t.sendEntered <- struct{}{}:
63+ default:
64+ }
65+ }
66+ select {
67+ case <-t.sendGate:
68+ case <-t.closed:
69+ return io.ErrClosedPipe
70+ }
71+ }
72+ select {
73+ case t.sent <- frame:
74+ return nil
75+ case <-t.closed:
76+ return io.ErrClosedPipe
77+ }
78+}
79+
80+func (t *proxyTestTransport) Recv() (ports.Frame, error) {
81+ t.receiving.Add(1)
82+ defer t.receiving.Add(-1)
83+ select {
84+ case item := <-t.recv:
85+ return item.frame, item.err
86+ case <-t.closed:
87+ return ports.Frame{}, io.EOF
88+ }
89+}
90+
91+func (t *proxyTestTransport) Close() error {
92+ t.closeOnce.Do(func() { close(t.closed) })
93+ return nil
94+}
95+
96+func proxyWelcome(name string, token uint64, capabilities uint32) ports.Frame {
97+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
98+ SessionID: "remote-id",
99+ SessionName: name,
100+ ResumeToken: token,
101+ Capabilities: capabilities,
102+ })}
103+}
104+
105+func proxyMeta(name string) ports.Frame {
106+ payload, err := ports.MarshalSessionMeta(ports.SessionMeta{
107+ SessionName: name,
108+ Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}},
109+ })
110+ if err != nil {
111+ panic(err)
112+ }
113+ return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}
114+}
115+
116+func newProxyTestDaemon(t *testing.T, factory ports.RemoteDialerFactory) *Daemon {
117+ t.Helper()
118+ return New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)),
119+ WithRemoteDiscovery(nil, nil, nil, factory, ports.RemoteTransportUDP))
120+}
121+
122+func proxyFactoryFor(t *testing.T, key domain.RemoteSessionKey, transports ...ports.Transport) ports.RemoteDialerFactory {
123+ t.Helper()
124+ factory := portsmocks.NewMockRemoteDialerFactory(t)
125+ for _, transport := range transports {
126+ dialer := portsmocks.NewMockDialer(t)
127+ factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
128+ dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
129+ }
130+ return factory
131+}
132+
133+func requireProxyHello(t *testing.T, tr *proxyTestTransport) ports.Hello {
134+ t.Helper()
135+ select {
136+ case frame := <-tr.sent:
137+ require.Equal(t, ports.MsgHello, frame.Type)
138+ hello, err := ports.UnmarshalHello(frame.Payload)
139+ require.NoError(t, err)
140+ return hello
141+ case <-time.After(time.Second):
142+ t.Fatal("timed out waiting for proxy Hello")
143+ return ports.Hello{}
144+ }
145+}
146+
147+func stopProxy(t *testing.T, p *proxySession) {
148+ t.Helper()
149+ p.stop()
150+ select {
151+ case <-p.done:
152+ case <-time.After(time.Second):
153+ t.Fatal("timed out waiting for proxy link to stop")
154+ }
155+}
156+
157+func TestProxyHandshakePublishesOnlyAfterWelcomeAndMetadata(t *testing.T) {
158+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
159+ tr := newProxyTestTransport()
160+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr))
161+
162+ result := make(chan struct {
163+ proxy *proxySession
164+ err error
165+ }, 1)
166+ go func() {
167+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
168+ result <- struct {
169+ proxy *proxySession
170+ err error
171+ }{proxy, err}
172+ }()
173+
174+ hello := requireProxyHello(t, tr)
175+ require.Equal(t, ports.ProtocolVersion, hello.Version)
176+ require.Equal(t, ports.IntentAttach, hello.Intent)
177+ require.True(t, hello.Proxied)
178+ require.Equal(t, key.Name, hello.Name)
179+ require.Equal(t, domain.Size{Cols: 80, Rows: 22}, hello.Size)
180+ require.Equal(t, uint8(maxUnackedOutputStates), hello.MaxOutputInFlight)
181+ require.Empty(t, d.sessions, "Hello alone must not publish a proxy")
182+
183+ tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 44, ports.CapabilityResume|ports.CapabilityProxied)}
184+ require.Empty(t, d.sessions, "Welcome alone must not publish a proxy")
185+ tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
186+
187+ created := awaitTestValue(t, result, "proxy handshake did not complete")
188+ require.NoError(t, created.err)
189+ require.Same(t, created.proxy, d.sessions[key.ID()])
190+ require.Equal(t, key, created.proxy.key)
191+ require.Equal(t, uint64(44), created.proxy.resumeToken)
192+ stopProxy(t, created.proxy)
193+}
194+
195+func TestProxyHandshakeRejectsInvalidWelcomeWithoutPublication(t *testing.T) {
196+ tests := []struct {
197+ name string
198+ first ports.Frame
199+ }{
200+ {name: "missing proxied capability", first: proxyWelcome("work", 1, ports.CapabilityResume)},
201+ {name: "malformed welcome", first: ports.Frame{Type: ports.MsgWelcome, Payload: []byte{0xff}}},
202+ {name: "wrong first type", first: proxyMeta("work")},
203+ }
204+ for _, tt := range tests {
205+ t.Run(tt.name, func(t *testing.T) {
206+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
207+ tr := newProxyTestTransport()
208+ tr.recv <- proxyRecv{frame: tt.first}
209+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr))
210+
211+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
212+ require.Error(t, err)
213+ require.Nil(t, proxy)
214+ require.Empty(t, d.sessions)
215+ select {
216+ case <-tr.closed:
217+ default:
218+ t.Fatal("failed handshake transport was not closed")
219+ }
220+ })
221+ }
222+}
223+
224+func TestProxyRegistryDedupesByExactStructuredKey(t *testing.T) {
225+ firstKey := domain.RemoteSessionKey{Host: "build.example", Name: "work"}
226+ secondKey := domain.RemoteSessionKey{Host: "build", Name: "example-work"}
227+ firstTransport := newProxyTestTransport()
228+ firstTransport.recv <- proxyRecv{frame: proxyWelcome(firstKey.Name, 1, ports.CapabilityProxied)}
229+ firstTransport.recv <- proxyRecv{frame: proxyMeta(firstKey.Name)}
230+ secondTransport := newProxyTestTransport()
231+ secondTransport.recv <- proxyRecv{frame: proxyWelcome(secondKey.Name, 2, ports.CapabilityProxied)}
232+ secondTransport.recv <- proxyRecv{frame: proxyMeta(secondKey.Name)}
233+ factory := portsmocks.NewMockRemoteDialerFactory(t)
234+ for key, transport := range map[domain.RemoteSessionKey]ports.Transport{firstKey: firstTransport, secondKey: secondTransport} {
235+ dialer := portsmocks.NewMockDialer(t)
236+ factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
237+ dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
238+ }
239+ d := newProxyTestDaemon(t, factory)
240+
241+ first, err := d.openProxySession(context.Background(), firstKey, domain.Size{Cols: 80, Rows: 24})
242+ require.NoError(t, err)
243+ deduped, err := d.openProxySession(context.Background(), firstKey, domain.Size{Cols: 100, Rows: 30})
244+ require.NoError(t, err)
245+ require.Same(t, first, deduped)
246+ second, err := d.openProxySession(context.Background(), secondKey, domain.Size{Cols: 80, Rows: 24})
247+ require.NoError(t, err)
248+ require.NotSame(t, first, second)
249+ require.Len(t, d.sessions, 2)
250+
251+ stopProxy(t, first)
252+ stopProxy(t, second)
253+}
254+
255+func TestProxyCancelDuringDialOrHandshakeNeverPublishes(t *testing.T) {
256+ t.Run("dial", func(t *testing.T) {
257+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
258+ factory := portsmocks.NewMockRemoteDialerFactory(t)
259+ dialer := portsmocks.NewMockDialer(t)
260+ factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
261+ dialEntered := make(chan struct{})
262+ dialer.EXPECT().Dial(mock.Anything).RunAndReturn(func(ctx context.Context) (ports.Transport, error) {
263+ close(dialEntered)
264+ <-ctx.Done()
265+ return nil, ctx.Err()
266+ }).Once()
267+ d := newProxyTestDaemon(t, factory)
268+ ctx, cancel := context.WithCancel(context.Background())
269+ result := make(chan struct {
270+ proxy *proxySession
271+ err error
272+ }, 1)
273+ go func() {
274+ proxy, err := d.openProxySession(ctx, key, domain.Size{Cols: 80, Rows: 24})
275+ result <- struct {
276+ proxy *proxySession
277+ err error
278+ }{proxy: proxy, err: err}
279+ }()
280+ awaitTestCompletion(t, dialEntered, "proxy dial did not start")
281+ cancel()
282+
283+ opened := awaitTestValue(t, result, "canceled proxy dial did not finish")
284+ proxy, err := opened.proxy, opened.err
285+ require.ErrorIs(t, err, context.Canceled)
286+ require.Nil(t, proxy)
287+ require.Empty(t, d.sessions)
288+ })
289+
290+ t.Run("handshake", func(t *testing.T) {
291+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
292+ tr := newProxyTestTransport()
293+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr))
294+ ctx, cancel := context.WithCancel(context.Background())
295+ result := make(chan error, 1)
296+ go func() {
297+ _, err := d.openProxySession(ctx, key, domain.Size{Cols: 80, Rows: 24})
298+ result <- err
299+ }()
300+ requireProxyHello(t, tr)
301+ cancel()
302+ require.ErrorIs(t, awaitTestValue(t, result, "canceled proxy handshake did not finish"), context.Canceled)
303+ require.Empty(t, d.sessions)
304+ select {
305+ case <-tr.closed:
306+ default:
307+ t.Fatal("cancellation did not close the handshaking transport")
308+ }
309+ })
310+}
311+
312+func TestProxyLinkResumesAndReplacementIsTerminal(t *testing.T) {
313+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
314+ first := newProxyTestTransport()
315+ first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 55, ports.CapabilityResume|ports.CapabilityProxied)}
316+ first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
317+ second := newProxyTestTransport()
318+ factory := proxyFactoryFor(t, key, first, second)
319+ d := newProxyTestDaemon(t, factory)
320+
321+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
322+ require.NoError(t, err)
323+ firstHello := requireProxyHello(t, first)
324+ require.Equal(t, ports.IntentAttach, firstHello.Intent)
325+
326+ first.recv <- proxyRecv{err: io.EOF}
327+ resumeHello := requireProxyHello(t, second)
328+ require.Equal(t, ports.IntentResume, resumeHello.Intent)
329+ require.Equal(t, uint64(55), resumeHello.ResumeToken)
330+ require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
331+ second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 66, ports.CapabilityResume|ports.CapabilityProxied)}
332+ second.recv <- proxyRecv{frame: proxyMeta(key.Name)}
333+ second.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced})}}
334+
335+ select {
336+ case <-proxy.done:
337+ case <-time.After(time.Second):
338+ t.Fatal("replacement did not terminate proxy link loop")
339+ }
340+ proxy.mu.Lock()
341+ require.True(t, proxy.expired)
342+ require.Equal(t, uint64(66), proxy.resumeToken)
343+ proxy.mu.Unlock()
344+ require.Same(t, proxy, d.sessions[key.ID()], "replacement leaves the expired proxy available for local presentation")
345+}
346+
347+func TestRunProxyTransportStopsReceivePumpAfterInvalidFrame(t *testing.T) {
348+ d, proxy, transport, generation := newProxyOutputSession(t)
349+ t.Cleanup(func() { _ = transport.Close() })
350+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}}
351+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}}
352+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}}
353+
354+ result := d.runProxyTransport(context.Background(), proxy, generation, transport)
355+ require.Equal(t, proxyLinkStop, result)
356+ select {
357+ case <-transport.closed:
358+ default:
359+ t.Fatal("runProxyTransport returned without closing its receive pump transport")
360+ }
361+ require.Zero(t, transport.receiving.Load(), "receive pump outlived transport consumer")
362+}
363+
364+func TestProxySenderSerializesAllTransportWrites(t *testing.T) {
365+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
366+ gate := make(chan struct{})
367+ entered := make(chan struct{}, 1)
368+ tr := newProxyTestTransportWithSendGate(gate, entered, 1)
369+ tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
370+ tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
371+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr))
372+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
373+ require.NoError(t, err)
374+ _ = requireProxyHello(t, tr)
375+ var wg sync.WaitGroup
376+ errs := make(chan error, 8)
377+ for range 8 {
378+ wg.Add(1)
379+ go func() {
380+ defer wg.Done()
381+ errs <- proxy.sendCurrent(ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})})
382+ }()
383+ }
384+ // Once the first sender enters Send, the others must remain behind sendMu.
385+ awaitTestCompletion(t, entered, "proxy sender did not enter transport")
386+ close(gate)
387+ wg.Wait()
388+ close(errs)
389+ for err := range errs {
390+ require.NoError(t, err)
391+ }
392+ require.False(t, tr.concurrent.Load(), "Transport.Send calls overlapped")
393+ stopProxy(t, proxy)
394+}
395+
396+func TestProxyHandshakeRejectsInvalidInitialMetadata(t *testing.T) {
397+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
398+ tr := newProxyTestTransport()
399+ tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
400+ tr.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgSessionMeta, Payload: []byte{0xff}}}
401+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr))
402+
403+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
404+ require.Error(t, err)
405+ require.Nil(t, proxy)
406+ require.Empty(t, d.sessions)
407+}
408+
409+func TestProxyOpenValidatesDependenciesAndKey(t *testing.T) {
410+ d := newProxyTestDaemon(t, nil)
411+ _, err := d.openProxySession(context.Background(), domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
412+ require.Error(t, err)
413+
414+ factory := portsmocks.NewMockRemoteDialerFactory(t)
415+ d = newProxyTestDaemon(t, factory)
416+ _, err = d.openProxySession(context.Background(), domain.RemoteSessionKey{Host: "arch", Name: "bad:name"}, domain.Size{Cols: 80, Rows: 24})
417+ require.Error(t, err)
418+ require.Empty(t, d.sessions)
419+}
420+
421+func newProxyOutputSession(t *testing.T) (*Daemon, *proxySession, *proxyTestTransport, uint64) {
422+ t.Helper()
423+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
424+ require.NoError(t, err)
425+ transport := newProxyTestTransport()
426+ generation, _ := proxy.installTransport(transport)
427+ return newProxyTestDaemon(t, nil), proxy, transport, generation
428+}
429+
430+func proxyScreenText(p *proxySession) string {
431+ p.mu.Lock()
432+ defer p.mu.Unlock()
433+ return string([]rune{p.screen.Frame.At(0, 0).Rune, p.screen.Frame.At(1, 0).Rune})
434+}
435+
436+func proxyOutputState(p *proxySession) (uint64, bool) {
437+ p.mu.Lock()
438+ defer p.mu.Unlock()
439+ return p.appliedState, p.awaitingReset
440+}
441+
442+func TestProxyOutputStateMachine(t *testing.T) {
443+ tests := []struct {
444+ name string
445+ initial *ports.Output
446+ output ports.Output
447+ wantAck uint64
448+ wantReset bool
449+ wantChanged bool
450+ wantState uint64
451+ wantAwaiting bool
452+ wantScreen string
453+ }{
454+ {
455+ name: "base zero initial reset",
456+ output: ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
457+ wantAck: 1,
458+ wantChanged: true,
459+ wantState: 1,
460+ wantScreen: "A ",
461+ },
462+ {
463+ name: "matching increment",
464+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
465+ output: ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")},
466+ wantAck: 2,
467+ wantChanged: true,
468+ wantState: 2,
469+ wantScreen: "AB",
470+ },
471+ {
472+ name: "duplicate old state requests reset",
473+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 2, Data: []byte("A")},
474+ output: ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")},
475+ wantReset: true,
476+ wantState: 2,
477+ wantAwaiting: true,
478+ wantScreen: "A ",
479+ },
480+ {
481+ name: "gap requests reset",
482+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
483+ output: ports.Output{BaseStateNum: 3, NewStateNum: 4, Data: []byte("B")},
484+ wantReset: true,
485+ wantState: 1,
486+ wantAwaiting: true,
487+ wantScreen: "A ",
488+ },
489+ {
490+ name: "stateless side effect does not advance state",
491+ output: ports.Output{NewStateNum: 0, Data: []byte("!")},
492+ wantChanged: true,
493+ wantScreen: "! ",
494+ },
495+ }
496+ for _, tt := range tests {
497+ t.Run(tt.name, func(t *testing.T) {
498+ _, proxy, _, generation := newProxyOutputSession(t)
499+ if tt.initial != nil {
500+ _, _, _ = proxy.applyOutputForGeneration(generation, *tt.initial)
501+ }
502+
503+ ack, reset, changed := proxy.applyOutput(tt.output)
504+ require.Equal(t, tt.wantAck, ack)
505+ require.Equal(t, tt.wantReset, reset)
506+ require.Equal(t, tt.wantChanged, changed)
507+ state, awaiting := proxyOutputState(proxy)
508+ require.Equal(t, tt.wantState, state)
509+ require.Equal(t, tt.wantAwaiting, awaiting)
510+ require.Equal(t, tt.wantScreen, proxyScreenText(proxy))
511+ })
512+ }
513+}
514+
515+func TestProxyOutputMismatchSendsOneResetUntilBaseZero(t *testing.T) {
516+ d, proxy, transport, generation := newProxyOutputSession(t)
517+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
518+ require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "initial output was not acknowledged").Type)
519+
520+ mismatch := ports.Output{BaseStateNum: 3, NewStateNum: 4, Data: []byte("discard")}
521+ require.NoError(t, d.handleProxyOutput(proxy, generation, mismatch))
522+ reset := awaitTestValue(t, transport.sent, "output mismatch did not request reset")
523+ require.Equal(t, ports.MsgOutputResetRequest, reset.Type)
524+ require.NoError(t, d.handleProxyOutput(proxy, generation, mismatch))
525+ select {
526+ case frame := <-transport.sent:
527+ t.Fatalf("second mismatch sent %v, want no frame", frame.Type)
528+ default:
529+ }
530+
531+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("R")}))
532+ ack := awaitTestValue(t, transport.sent, "reset output was not acknowledged")
533+ require.Equal(t, ports.MsgAck, ack.Type)
534+ state, awaiting := proxyOutputState(proxy)
535+ require.Equal(t, uint64(5), state)
536+ require.False(t, awaiting)
537+ require.Equal(t, "R ", proxyScreenText(proxy))
538+}
539+
540+func TestProxyOutputMalformedOrStaleCannotAdvanceState(t *testing.T) {
541+ t.Run("malformed", func(t *testing.T) {
542+ d, proxy, _, generation := newProxyOutputSession(t)
543+ result, err := d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}})
544+ require.Equal(t, proxyLinkStop, result)
545+ require.Error(t, err)
546+ state, awaiting := proxyOutputState(proxy)
547+ require.Zero(t, state)
548+ require.False(t, awaiting)
549+ })
550+
551+ t.Run("stale generation", func(t *testing.T) {
552+ d, proxy, transport, oldGeneration := newProxyOutputSession(t)
553+ newGeneration, _ := proxy.installTransport(newProxyTestTransport())
554+ require.NotEqual(t, oldGeneration, newGeneration)
555+ require.NoError(t, d.handleProxyOutput(proxy, oldGeneration, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
556+ state, awaiting := proxyOutputState(proxy)
557+ require.Zero(t, state)
558+ require.False(t, awaiting)
559+ require.Equal(t, " ", proxyScreenText(proxy))
560+ select {
561+ case frame := <-transport.sent:
562+ t.Fatalf("stale output sent %v", frame.Type)
563+ default:
564+ }
565+ })
566+}
567+
568+func TestProxyOutputAckFailureStillDoesNotApplyStaleState(t *testing.T) {
569+ d, proxy, transport, generation := newProxyOutputSession(t)
570+ transport.sendGate = make(chan struct{})
571+ require.NoError(t, transport.Close())
572+ err := d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})
573+ require.Error(t, err)
574+ state, _ := proxyOutputState(proxy)
575+ require.Equal(t, uint64(1), state, "ACK failure follows a successfully applied state")
576+}
577+
578+func TestProxyOutputReconnectRequiresBaseZero(t *testing.T) {
579+ d, proxy, first, generation := newProxyOutputSession(t)
580+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
581+ _ = awaitTestValue(t, first.sent, "initial output was not acknowledged") // ACK
582+
583+ second := newProxyTestTransport()
584+ generation, _ = proxy.installTransport(second)
585+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")}))
586+ frame := awaitTestValue(t, second.sent, "reconnected output did not request reset")
587+ require.Equal(t, ports.MsgOutputResetRequest, frame.Type)
588+ state, awaiting := proxyOutputState(proxy)
589+ require.Zero(t, state)
590+ require.True(t, awaiting)
591+ require.Equal(t, "A ", proxyScreenText(proxy))
592+
593+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 7, Data: []byte("R")}))
594+ frame = awaitTestValue(t, second.sent, "reset reconnected output was not acknowledged")
595+ require.Equal(t, ports.MsgAck, frame.Type)
596+ state, awaiting = proxyOutputState(proxy)
597+ require.Equal(t, uint64(7), state)
598+ require.False(t, awaiting)
599+}
600+
601+func TestProxyOutputResizeRequiresBaseZero(t *testing.T) {
602+ d, proxy, transport, generation := newProxyOutputSession(t)
603+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
604+ _ = awaitTestValue(t, transport.sent, "initial output was not acknowledged") // ACK
605+
606+ proxy.resetOutputState()
607+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")}))
608+ frame := awaitTestValue(t, transport.sent, "resize mismatch did not request reset")
609+ require.Equal(t, ports.MsgOutputResetRequest, frame.Type)
610+ state, awaiting := proxyOutputState(proxy)
611+ require.Zero(t, state)
612+ require.True(t, awaiting)
613+ require.Equal(t, "A ", proxyScreenText(proxy))
614+}
615+
616+func TestProxyOutputConcurrentCancelDoesNotHoldProxyLockDuringSend(t *testing.T) {
617+ d, proxy, transport, generation := newProxyOutputSession(t)
618+ gate := make(chan struct{})
619+ entered := make(chan struct{}, 1)
620+ transport.sendGate = gate
621+ transport.sendEntered = entered
622+
623+ result := make(chan error, 1)
624+ go func() {
625+ result <- d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})
626+ }()
627+ awaitTestCompletion(t, entered, "output sender did not enter transport")
628+
629+ locked := make(chan struct{})
630+ go func() {
631+ _, _ = proxyOutputState(proxy)
632+ close(locked)
633+ }()
634+ select {
635+ case <-locked:
636+ case <-time.After(time.Second):
637+ t.Fatal("proxy.mu was held while transport Send was blocked")
638+ }
639+ require.NoError(t, transport.Close())
640+ require.Error(t, awaitTestValue(t, result, "blocked output send did not return"))
641+}
@@ -0,0 +1,641 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+type proxyRecv struct {
20+ frame ports.Frame
21+ err error
22+}
23+
24+type proxyTestTransport struct {
25+ recv chan proxyRecv
26+ closed chan struct{}
27+ closeOnce sync.Once
28+ sent chan ports.Frame
29+
30+ sendGate <-chan struct{}
31+ sendEntered chan struct{}
32+ ungatedSends atomic.Int32
33+ sending atomic.Int32
34+ concurrent atomic.Bool
35+ receiving atomic.Int32
36+}
37+
38+func newProxyTestTransport() *proxyTestTransport {
39+ return newProxyTestTransportWithSendGate(nil, nil, 0)
40+}
41+
42+func newProxyTestTransportWithSendGate(gate <-chan struct{}, entered chan struct{}, ungatedSends int32) *proxyTestTransport {
43+ transport := &proxyTestTransport{
44+ recv: make(chan proxyRecv, 16),
45+ closed: make(chan struct{}),
46+ sent: make(chan ports.Frame, 32),
47+ sendGate: gate,
48+ sendEntered: entered,
49+ }
50+ transport.ungatedSends.Store(ungatedSends)
51+ return transport
52+}
53+
54+func (t *proxyTestTransport) Send(frame ports.Frame) error {
55+ if t.sending.Add(1) != 1 {
56+ t.concurrent.Store(true)
57+ }
58+ defer t.sending.Add(-1)
59+ if t.sendGate != nil && t.ungatedSends.Add(-1) < 0 {
60+ if t.sendEntered != nil {
61+ select {
62+ case t.sendEntered <- struct{}{}:
63+ default:
64+ }
65+ }
66+ select {
67+ case <-t.sendGate:
68+ case <-t.closed:
69+ return io.ErrClosedPipe
70+ }
71+ }
72+ select {
73+ case t.sent <- frame:
74+ return nil
75+ case <-t.closed:
76+ return io.ErrClosedPipe
77+ }
78+}
79+
80+func (t *proxyTestTransport) Recv() (ports.Frame, error) {
81+ t.receiving.Add(1)
82+ defer t.receiving.Add(-1)
83+ select {
84+ case item := <-t.recv:
85+ return item.frame, item.err
86+ case <-t.closed:
87+ return ports.Frame{}, io.EOF
88+ }
89+}
90+
91+func (t *proxyTestTransport) Close() error {
92+ t.closeOnce.Do(func() { close(t.closed) })
93+ return nil
94+}
95+
96+func proxyWelcome(name string, token uint64, capabilities uint32) ports.Frame {
97+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
98+ SessionID: "remote-id",
99+ SessionName: name,
100+ ResumeToken: token,
101+ Capabilities: capabilities,
102+ })}
103+}
104+
105+func proxyMeta(name string) ports.Frame {
106+ payload, err := ports.MarshalSessionMeta(ports.SessionMeta{
107+ SessionName: name,
108+ Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}},
109+ })
110+ if err != nil {
111+ panic(err)
112+ }
113+ return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}
114+}
115+
116+func newProxyTestDaemon(t *testing.T, factory ports.RemoteDialerFactory) *Daemon {
117+ t.Helper()
118+ return New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)),
119+ WithRemoteDiscovery(nil, nil, nil, factory, ports.RemoteTransportUDP))
120+}
121+
122+func proxyFactoryFor(t *testing.T, key domain.RemoteSessionKey, transports ...ports.Transport) ports.RemoteDialerFactory {
123+ t.Helper()
124+ factory := portsmocks.NewMockRemoteDialerFactory(t)
125+ for _, transport := range transports {
126+ dialer := portsmocks.NewMockDialer(t)
127+ factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
128+ dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
129+ }
130+ return factory
131+}
132+
133+func requireProxyHello(t *testing.T, tr *proxyTestTransport) ports.Hello {
134+ t.Helper()
135+ select {
136+ case frame := <-tr.sent:
137+ require.Equal(t, ports.MsgHello, frame.Type)
138+ hello, err := ports.UnmarshalHello(frame.Payload)
139+ require.NoError(t, err)
140+ return hello
141+ case <-time.After(time.Second):
142+ t.Fatal("timed out waiting for proxy Hello")
143+ return ports.Hello{}
144+ }
145+}
146+
147+func stopProxy(t *testing.T, p *proxySession) {
148+ t.Helper()
149+ p.stop()
150+ select {
151+ case <-p.done:
152+ case <-time.After(time.Second):
153+ t.Fatal("timed out waiting for proxy link to stop")
154+ }
155+}
156+
157+func TestProxyHandshakePublishesOnlyAfterWelcomeAndMetadata(t *testing.T) {
158+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
159+ tr := newProxyTestTransport()
160+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr))
161+
162+ result := make(chan struct {
163+ proxy *proxySession
164+ err error
165+ }, 1)
166+ go func() {
167+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
168+ result <- struct {
169+ proxy *proxySession
170+ err error
171+ }{proxy, err}
172+ }()
173+
174+ hello := requireProxyHello(t, tr)
175+ require.Equal(t, ports.ProtocolVersion, hello.Version)
176+ require.Equal(t, ports.IntentAttach, hello.Intent)
177+ require.True(t, hello.Proxied)
178+ require.Equal(t, key.Name, hello.Name)
179+ require.Equal(t, domain.Size{Cols: 80, Rows: 22}, hello.Size)
180+ require.Equal(t, uint8(maxUnackedOutputStates), hello.MaxOutputInFlight)
181+ require.Empty(t, d.sessions, "Hello alone must not publish a proxy")
182+
183+ tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 44, ports.CapabilityResume|ports.CapabilityProxied)}
184+ require.Empty(t, d.sessions, "Welcome alone must not publish a proxy")
185+ tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
186+
187+ created := awaitTestValue(t, result, "proxy handshake did not complete")
188+ require.NoError(t, created.err)
189+ require.Same(t, created.proxy, d.sessions[key.ID()])
190+ require.Equal(t, key, created.proxy.key)
191+ require.Equal(t, uint64(44), created.proxy.resumeToken)
192+ stopProxy(t, created.proxy)
193+}
194+
195+func TestProxyHandshakeRejectsInvalidWelcomeWithoutPublication(t *testing.T) {
196+ tests := []struct {
197+ name string
198+ first ports.Frame
199+ }{
200+ {name: "missing proxied capability", first: proxyWelcome("work", 1, ports.CapabilityResume)},
201+ {name: "malformed welcome", first: ports.Frame{Type: ports.MsgWelcome, Payload: []byte{0xff}}},
202+ {name: "wrong first type", first: proxyMeta("work")},
203+ }
204+ for _, tt := range tests {
205+ t.Run(tt.name, func(t *testing.T) {
206+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
207+ tr := newProxyTestTransport()
208+ tr.recv <- proxyRecv{frame: tt.first}
209+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr))
210+
211+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
212+ require.Error(t, err)
213+ require.Nil(t, proxy)
214+ require.Empty(t, d.sessions)
215+ select {
216+ case <-tr.closed:
217+ default:
218+ t.Fatal("failed handshake transport was not closed")
219+ }
220+ })
221+ }
222+}
223+
224+func TestProxyRegistryDedupesByExactStructuredKey(t *testing.T) {
225+ firstKey := domain.RemoteSessionKey{Host: "build.example", Name: "work"}
226+ secondKey := domain.RemoteSessionKey{Host: "build", Name: "example-work"}
227+ firstTransport := newProxyTestTransport()
228+ firstTransport.recv <- proxyRecv{frame: proxyWelcome(firstKey.Name, 1, ports.CapabilityProxied)}
229+ firstTransport.recv <- proxyRecv{frame: proxyMeta(firstKey.Name)}
230+ secondTransport := newProxyTestTransport()
231+ secondTransport.recv <- proxyRecv{frame: proxyWelcome(secondKey.Name, 2, ports.CapabilityProxied)}
232+ secondTransport.recv <- proxyRecv{frame: proxyMeta(secondKey.Name)}
233+ factory := portsmocks.NewMockRemoteDialerFactory(t)
234+ for key, transport := range map[domain.RemoteSessionKey]ports.Transport{firstKey: firstTransport, secondKey: secondTransport} {
235+ dialer := portsmocks.NewMockDialer(t)
236+ factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
237+ dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
238+ }
239+ d := newProxyTestDaemon(t, factory)
240+
241+ first, err := d.openProxySession(context.Background(), firstKey, domain.Size{Cols: 80, Rows: 24})
242+ require.NoError(t, err)
243+ deduped, err := d.openProxySession(context.Background(), firstKey, domain.Size{Cols: 100, Rows: 30})
244+ require.NoError(t, err)
245+ require.Same(t, first, deduped)
246+ second, err := d.openProxySession(context.Background(), secondKey, domain.Size{Cols: 80, Rows: 24})
247+ require.NoError(t, err)
248+ require.NotSame(t, first, second)
249+ require.Len(t, d.sessions, 2)
250+
251+ stopProxy(t, first)
252+ stopProxy(t, second)
253+}
254+
255+func TestProxyCancelDuringDialOrHandshakeNeverPublishes(t *testing.T) {
256+ t.Run("dial", func(t *testing.T) {
257+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
258+ factory := portsmocks.NewMockRemoteDialerFactory(t)
259+ dialer := portsmocks.NewMockDialer(t)
260+ factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
261+ dialEntered := make(chan struct{})
262+ dialer.EXPECT().Dial(mock.Anything).RunAndReturn(func(ctx context.Context) (ports.Transport, error) {
263+ close(dialEntered)
264+ <-ctx.Done()
265+ return nil, ctx.Err()
266+ }).Once()
267+ d := newProxyTestDaemon(t, factory)
268+ ctx, cancel := context.WithCancel(context.Background())
269+ result := make(chan struct {
270+ proxy *proxySession
271+ err error
272+ }, 1)
273+ go func() {
274+ proxy, err := d.openProxySession(ctx, key, domain.Size{Cols: 80, Rows: 24})
275+ result <- struct {
276+ proxy *proxySession
277+ err error
278+ }{proxy: proxy, err: err}
279+ }()
280+ awaitTestCompletion(t, dialEntered, "proxy dial did not start")
281+ cancel()
282+
283+ opened := awaitTestValue(t, result, "canceled proxy dial did not finish")
284+ proxy, err := opened.proxy, opened.err
285+ require.ErrorIs(t, err, context.Canceled)
286+ require.Nil(t, proxy)
287+ require.Empty(t, d.sessions)
288+ })
289+
290+ t.Run("handshake", func(t *testing.T) {
291+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
292+ tr := newProxyTestTransport()
293+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr))
294+ ctx, cancel := context.WithCancel(context.Background())
295+ result := make(chan error, 1)
296+ go func() {
297+ _, err := d.openProxySession(ctx, key, domain.Size{Cols: 80, Rows: 24})
298+ result <- err
299+ }()
300+ requireProxyHello(t, tr)
301+ cancel()
302+ require.ErrorIs(t, awaitTestValue(t, result, "canceled proxy handshake did not finish"), context.Canceled)
303+ require.Empty(t, d.sessions)
304+ select {
305+ case <-tr.closed:
306+ default:
307+ t.Fatal("cancellation did not close the handshaking transport")
308+ }
309+ })
310+}
311+
312+func TestProxyLinkResumesAndReplacementIsTerminal(t *testing.T) {
313+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
314+ first := newProxyTestTransport()
315+ first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 55, ports.CapabilityResume|ports.CapabilityProxied)}
316+ first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
317+ second := newProxyTestTransport()
318+ factory := proxyFactoryFor(t, key, first, second)
319+ d := newProxyTestDaemon(t, factory)
320+
321+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
322+ require.NoError(t, err)
323+ firstHello := requireProxyHello(t, first)
324+ require.Equal(t, ports.IntentAttach, firstHello.Intent)
325+
326+ first.recv <- proxyRecv{err: io.EOF}
327+ resumeHello := requireProxyHello(t, second)
328+ require.Equal(t, ports.IntentResume, resumeHello.Intent)
329+ require.Equal(t, uint64(55), resumeHello.ResumeToken)
330+ require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
331+ second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 66, ports.CapabilityResume|ports.CapabilityProxied)}
332+ second.recv <- proxyRecv{frame: proxyMeta(key.Name)}
333+ second.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced})}}
334+
335+ select {
336+ case <-proxy.done:
337+ case <-time.After(time.Second):
338+ t.Fatal("replacement did not terminate proxy link loop")
339+ }
340+ proxy.mu.Lock()
341+ require.True(t, proxy.expired)
342+ require.Equal(t, uint64(66), proxy.resumeToken)
343+ proxy.mu.Unlock()
344+ require.Same(t, proxy, d.sessions[key.ID()], "replacement leaves the expired proxy available for local presentation")
345+}
346+
347+func TestRunProxyTransportStopsReceivePumpAfterInvalidFrame(t *testing.T) {
348+ d, proxy, transport, generation := newProxyOutputSession(t)
349+ t.Cleanup(func() { _ = transport.Close() })
350+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}}
351+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}}
352+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}}
353+
354+ result := d.runProxyTransport(context.Background(), proxy, generation, transport)
355+ require.Equal(t, proxyLinkStop, result)
356+ select {
357+ case <-transport.closed:
358+ default:
359+ t.Fatal("runProxyTransport returned without closing its receive pump transport")
360+ }
361+ require.Zero(t, transport.receiving.Load(), "receive pump outlived transport consumer")
362+}
363+
364+func TestProxySenderSerializesAllTransportWrites(t *testing.T) {
365+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
366+ gate := make(chan struct{})
367+ entered := make(chan struct{}, 1)
368+ tr := newProxyTestTransportWithSendGate(gate, entered, 1)
369+ tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
370+ tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
371+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr))
372+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
373+ require.NoError(t, err)
374+ _ = requireProxyHello(t, tr)
375+ var wg sync.WaitGroup
376+ errs := make(chan error, 8)
377+ for range 8 {
378+ wg.Add(1)
379+ go func() {
380+ defer wg.Done()
381+ errs <- proxy.sendCurrent(ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})})
382+ }()
383+ }
384+ // Once the first sender enters Send, the others must remain behind sendMu.
385+ awaitTestCompletion(t, entered, "proxy sender did not enter transport")
386+ close(gate)
387+ wg.Wait()
388+ close(errs)
389+ for err := range errs {
390+ require.NoError(t, err)
391+ }
392+ require.False(t, tr.concurrent.Load(), "Transport.Send calls overlapped")
393+ stopProxy(t, proxy)
394+}
395+
396+func TestProxyHandshakeRejectsInvalidInitialMetadata(t *testing.T) {
397+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
398+ tr := newProxyTestTransport()
399+ tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
400+ tr.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgSessionMeta, Payload: []byte{0xff}}}
401+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr))
402+
403+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
404+ require.Error(t, err)
405+ require.Nil(t, proxy)
406+ require.Empty(t, d.sessions)
407+}
408+
409+func TestProxyOpenValidatesDependenciesAndKey(t *testing.T) {
410+ d := newProxyTestDaemon(t, nil)
411+ _, err := d.openProxySession(context.Background(), domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
412+ require.Error(t, err)
413+
414+ factory := portsmocks.NewMockRemoteDialerFactory(t)
415+ d = newProxyTestDaemon(t, factory)
416+ _, err = d.openProxySession(context.Background(), domain.RemoteSessionKey{Host: "arch", Name: "bad:name"}, domain.Size{Cols: 80, Rows: 24})
417+ require.Error(t, err)
418+ require.Empty(t, d.sessions)
419+}
420+
421+func newProxyOutputSession(t *testing.T) (*Daemon, *proxySession, *proxyTestTransport, uint64) {
422+ t.Helper()
423+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
424+ require.NoError(t, err)
425+ transport := newProxyTestTransport()
426+ generation, _ := proxy.installTransport(transport)
427+ return newProxyTestDaemon(t, nil), proxy, transport, generation
428+}
429+
430+func proxyScreenText(p *proxySession) string {
431+ p.mu.Lock()
432+ defer p.mu.Unlock()
433+ return string([]rune{p.screen.Frame.At(0, 0).Rune, p.screen.Frame.At(1, 0).Rune})
434+}
435+
436+func proxyOutputState(p *proxySession) (uint64, bool) {
437+ p.mu.Lock()
438+ defer p.mu.Unlock()
439+ return p.appliedState, p.awaitingReset
440+}
441+
442+func TestProxyOutputStateMachine(t *testing.T) {
443+ tests := []struct {
444+ name string
445+ initial *ports.Output
446+ output ports.Output
447+ wantAck uint64
448+ wantReset bool
449+ wantChanged bool
450+ wantState uint64
451+ wantAwaiting bool
452+ wantScreen string
453+ }{
454+ {
455+ name: "base zero initial reset",
456+ output: ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
457+ wantAck: 1,
458+ wantChanged: true,
459+ wantState: 1,
460+ wantScreen: "A ",
461+ },
462+ {
463+ name: "matching increment",
464+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
465+ output: ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")},
466+ wantAck: 2,
467+ wantChanged: true,
468+ wantState: 2,
469+ wantScreen: "AB",
470+ },
471+ {
472+ name: "duplicate old state requests reset",
473+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 2, Data: []byte("A")},
474+ output: ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")},
475+ wantReset: true,
476+ wantState: 2,
477+ wantAwaiting: true,
478+ wantScreen: "A ",
479+ },
480+ {
481+ name: "gap requests reset",
482+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
483+ output: ports.Output{BaseStateNum: 3, NewStateNum: 4, Data: []byte("B")},
484+ wantReset: true,
485+ wantState: 1,
486+ wantAwaiting: true,
487+ wantScreen: "A ",
488+ },
489+ {
490+ name: "stateless side effect does not advance state",
491+ output: ports.Output{NewStateNum: 0, Data: []byte("!")},
492+ wantChanged: true,
493+ wantScreen: "! ",
494+ },
495+ }
496+ for _, tt := range tests {
497+ t.Run(tt.name, func(t *testing.T) {
498+ _, proxy, _, generation := newProxyOutputSession(t)
499+ if tt.initial != nil {
500+ _, _, _ = proxy.applyOutputForGeneration(generation, *tt.initial)
501+ }
502+
503+ ack, reset, changed := proxy.applyOutput(tt.output)
504+ require.Equal(t, tt.wantAck, ack)
505+ require.Equal(t, tt.wantReset, reset)
506+ require.Equal(t, tt.wantChanged, changed)
507+ state, awaiting := proxyOutputState(proxy)
508+ require.Equal(t, tt.wantState, state)
509+ require.Equal(t, tt.wantAwaiting, awaiting)
510+ require.Equal(t, tt.wantScreen, proxyScreenText(proxy))
511+ })
512+ }
513+}
514+
515+func TestProxyOutputMismatchSendsOneResetUntilBaseZero(t *testing.T) {
516+ d, proxy, transport, generation := newProxyOutputSession(t)
517+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
518+ require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "initial output was not acknowledged").Type)
519+
520+ mismatch := ports.Output{BaseStateNum: 3, NewStateNum: 4, Data: []byte("discard")}
521+ require.NoError(t, d.handleProxyOutput(proxy, generation, mismatch))
522+ reset := awaitTestValue(t, transport.sent, "output mismatch did not request reset")
523+ require.Equal(t, ports.MsgOutputResetRequest, reset.Type)
524+ require.NoError(t, d.handleProxyOutput(proxy, generation, mismatch))
525+ select {
526+ case frame := <-transport.sent:
527+ t.Fatalf("second mismatch sent %v, want no frame", frame.Type)
528+ default:
529+ }
530+
531+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("R")}))
532+ ack := awaitTestValue(t, transport.sent, "reset output was not acknowledged")
533+ require.Equal(t, ports.MsgAck, ack.Type)
534+ state, awaiting := proxyOutputState(proxy)
535+ require.Equal(t, uint64(5), state)
536+ require.False(t, awaiting)
537+ require.Equal(t, "R ", proxyScreenText(proxy))
538+}
539+
540+func TestProxyOutputMalformedOrStaleCannotAdvanceState(t *testing.T) {
541+ t.Run("malformed", func(t *testing.T) {
542+ d, proxy, _, generation := newProxyOutputSession(t)
543+ result, err := d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}})
544+ require.Equal(t, proxyLinkStop, result)
545+ require.Error(t, err)
546+ state, awaiting := proxyOutputState(proxy)
547+ require.Zero(t, state)
548+ require.False(t, awaiting)
549+ })
550+
551+ t.Run("stale generation", func(t *testing.T) {
552+ d, proxy, transport, oldGeneration := newProxyOutputSession(t)
553+ newGeneration, _ := proxy.installTransport(newProxyTestTransport())
554+ require.NotEqual(t, oldGeneration, newGeneration)
555+ require.NoError(t, d.handleProxyOutput(proxy, oldGeneration, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
556+ state, awaiting := proxyOutputState(proxy)
557+ require.Zero(t, state)
558+ require.False(t, awaiting)
559+ require.Equal(t, " ", proxyScreenText(proxy))
560+ select {
561+ case frame := <-transport.sent:
562+ t.Fatalf("stale output sent %v", frame.Type)
563+ default:
564+ }
565+ })
566+}
567+
568+func TestProxyOutputAckFailureStillDoesNotApplyStaleState(t *testing.T) {
569+ d, proxy, transport, generation := newProxyOutputSession(t)
570+ transport.sendGate = make(chan struct{})
571+ require.NoError(t, transport.Close())
572+ err := d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})
573+ require.Error(t, err)
574+ state, _ := proxyOutputState(proxy)
575+ require.Equal(t, uint64(1), state, "ACK failure follows a successfully applied state")
576+}
577+
578+func TestProxyOutputReconnectRequiresBaseZero(t *testing.T) {
579+ d, proxy, first, generation := newProxyOutputSession(t)
580+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
581+ _ = awaitTestValue(t, first.sent, "initial output was not acknowledged") // ACK
582+
583+ second := newProxyTestTransport()
584+ generation, _ = proxy.installTransport(second)
585+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")}))
586+ frame := awaitTestValue(t, second.sent, "reconnected output did not request reset")
587+ require.Equal(t, ports.MsgOutputResetRequest, frame.Type)
588+ state, awaiting := proxyOutputState(proxy)
589+ require.Zero(t, state)
590+ require.True(t, awaiting)
591+ require.Equal(t, "A ", proxyScreenText(proxy))
592+
593+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 7, Data: []byte("R")}))
594+ frame = awaitTestValue(t, second.sent, "reset reconnected output was not acknowledged")
595+ require.Equal(t, ports.MsgAck, frame.Type)
596+ state, awaiting = proxyOutputState(proxy)
597+ require.Equal(t, uint64(7), state)
598+ require.False(t, awaiting)
599+}
600+
601+func TestProxyOutputResizeRequiresBaseZero(t *testing.T) {
602+ d, proxy, transport, generation := newProxyOutputSession(t)
603+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
604+ _ = awaitTestValue(t, transport.sent, "initial output was not acknowledged") // ACK
605+
606+ proxy.resetOutputState()
607+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")}))
608+ frame := awaitTestValue(t, transport.sent, "resize mismatch did not request reset")
609+ require.Equal(t, ports.MsgOutputResetRequest, frame.Type)
610+ state, awaiting := proxyOutputState(proxy)
611+ require.Zero(t, state)
612+ require.True(t, awaiting)
613+ require.Equal(t, "A ", proxyScreenText(proxy))
614+}
615+
616+func TestProxyOutputConcurrentCancelDoesNotHoldProxyLockDuringSend(t *testing.T) {
617+ d, proxy, transport, generation := newProxyOutputSession(t)
618+ gate := make(chan struct{})
619+ entered := make(chan struct{}, 1)
620+ transport.sendGate = gate
621+ transport.sendEntered = entered
622+
623+ result := make(chan error, 1)
624+ go func() {
625+ result <- d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})
626+ }()
627+ awaitTestCompletion(t, entered, "output sender did not enter transport")
628+
629+ locked := make(chan struct{})
630+ go func() {
631+ _, _ = proxyOutputState(proxy)
632+ close(locked)
633+ }()
634+ select {
635+ case <-locked:
636+ case <-time.After(time.Second):
637+ t.Fatal("proxy.mu was held while transport Send was blocked")
638+ }
639+ require.NoError(t, transport.Close())
640+ require.Error(t, awaitTestValue(t, result, "blocked output send did not return"))
641+}
A internal/usecase/daemon/proxy_render.go +130 -0
@@ -0,0 +1,130 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+ "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+const proxyRenderPaneID layout.PaneID = "proxy-content"
11+
12+// capturePrimary snapshots the remote VT while holding only proxy.mu. The
13+// snapshot is then composed by the ordinary local frame pipeline, which owns
14+// the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore
15+// never handed to a renderer.
16+func (p *proxySession) capturePrimary(ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) {
17+ if p == nil || ac == nil {
18+ return nil, false
19+ }
20+
21+ p.mu.Lock()
22+ if p.screen == nil {
23+ p.mu.Unlock()
24+ return nil, false
25+ }
26+ screen := p.screen
27+ frame := screen.Frame.Clone()
28+ damage := screen.CaptureDamage()
29+ cursorStyle, hasCursorStyle := screen.CursorStyle()
30+ cursor := capturedCursorInputs{
31+ row: screen.CursorRow(),
32+ col: screen.CursorCol(),
33+ style: cursorStyle,
34+ hasStyle: hasCursorStyle,
35+ visible: screen.CursorVisible(),
36+ renderable: frame.Width > 0 && frame.Height > 0,
37+ content: domain.Rect{Width: frame.Width, Height: frame.Height},
38+ }
39+ p.mu.Unlock()
40+
41+ content := domain.Rect{Width: frame.Width, Height: frame.Height}
42+ paneDamage := append([]renderer.Damage(nil), damage.Damage...)
43+ if uncertainDamage(paneDamage, frame.Width, frame.Height) {
44+ paneDamage = []renderer.Damage{renderer.FullRedraw()}
45+ }
46+ state := &capturedRenderState{
47+ attachment: ac,
48+ lease: request.lease,
49+ reset: request.reset,
50+ layout: capturedTabLayout{area: content, focus: proxyRenderPaneID, valid: true, fingerprint: "proxy"},
51+ bars: request.bars,
52+ theme: request.bars.theme,
53+ styles: request.styles,
54+ styleGeneration: request.styleGeneration,
55+ overlays: request.overlays,
56+ preview: request.preview,
57+ cursor: cursor,
58+ receipts: []damageReceipt{{proxy: p, proxyScreen: screen, generation: damage.Generation}},
59+ panes: []capturedPaneRenderState{{
60+ id: proxyRenderPaneID,
61+ frame: frame,
62+ damage: paneDamage,
63+ placement: layout.Placement{ID: proxyRenderPaneID, Content: content},
64+ focused: true,
65+ }},
66+ }
67+ return state, true
68+}
69+
70+// firstProxyPaintWithLease emits the same full first paint as a local
71+// transition, after making the remote content geometry match the local viewport.
72+func (d *Daemon) firstProxyPaintWithLease(p *proxySession, ac *attachedClient, lease *attachmentLease) {
73+ if p == nil || ac == nil {
74+ return
75+ }
76+ p.resize(ac.size)
77+ if rc := attachmentRenderCoordinator(p); rc != nil {
78+ if rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "proxy_render.go"}) {
79+ rc.fireCurrent(false)
80+ }
81+ return
82+ }
83+ d.invalidateRenderNow(p, ac, true, "proxy_render.go")
84+}
85+
86+// resizeProxyForLease accepts a local client resize only for its exact active
87+// attachment lease. It repaints local chrome after the remote content resize
88+// has been sent by the proxy's serialized sender.
89+func (d *Daemon) resizeProxyForLease(p *proxySession, ac *attachedClient, lease *attachmentLease, size domain.Size) {
90+ if p == nil || ac == nil || !size.Valid() {
91+ return
92+ }
93+ if rc := attachmentRenderCoordinator(p); rc != nil && !rc.leaseCurrent(lease, false) {
94+ return
95+ }
96+ if !p.resize(size) {
97+ return
98+ }
99+ if rc := attachmentRenderCoordinator(p); rc != nil {
100+ rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "proxy_render.go"})
101+ return
102+ }
103+ d.invalidateRender(p, ac, true, "proxy_render.go")
104+}
105+
106+// resize updates the local VT to the local content rectangle and sends that
107+// reduced geometry once to the remote daemon. The sender performs transport
108+// I/O only after proxy.mu has been released.
109+func (p *proxySession) resize(size domain.Size) bool {
110+ if p == nil || !size.Valid() {
111+ return false
112+ }
113+ content := contentSize(size, false)
114+ p.mu.Lock()
115+ if p.screen == nil || p.contentSize == content {
116+ available := p.screen != nil
117+ p.mu.Unlock()
118+ return available
119+ }
120+ p.screen.Resize(content.Cols, content.Rows)
121+ p.contentSize = content
122+ p.resetOutputStateLocked()
123+ generation := p.linkGeneration
124+ p.mu.Unlock()
125+
126+ return p.sendGeneration(generation, ports.Frame{
127+ Type: ports.MsgResize,
128+ Payload: ports.MarshalResize(ports.Resize{Size: content}),
129+ }) == nil
130+}
@@ -0,0 +1,130 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+ "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+const proxyRenderPaneID layout.PaneID = "proxy-content"
11+
12+// capturePrimary snapshots the remote VT while holding only proxy.mu. The
13+// snapshot is then composed by the ordinary local frame pipeline, which owns
14+// the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore
15+// never handed to a renderer.
16+func (p *proxySession) capturePrimary(ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) {
17+ if p == nil || ac == nil {
18+ return nil, false
19+ }
20+
21+ p.mu.Lock()
22+ if p.screen == nil {
23+ p.mu.Unlock()
24+ return nil, false
25+ }
26+ screen := p.screen
27+ frame := screen.Frame.Clone()
28+ damage := screen.CaptureDamage()
29+ cursorStyle, hasCursorStyle := screen.CursorStyle()
30+ cursor := capturedCursorInputs{
31+ row: screen.CursorRow(),
32+ col: screen.CursorCol(),
33+ style: cursorStyle,
34+ hasStyle: hasCursorStyle,
35+ visible: screen.CursorVisible(),
36+ renderable: frame.Width > 0 && frame.Height > 0,
37+ content: domain.Rect{Width: frame.Width, Height: frame.Height},
38+ }
39+ p.mu.Unlock()
40+
41+ content := domain.Rect{Width: frame.Width, Height: frame.Height}
42+ paneDamage := append([]renderer.Damage(nil), damage.Damage...)
43+ if uncertainDamage(paneDamage, frame.Width, frame.Height) {
44+ paneDamage = []renderer.Damage{renderer.FullRedraw()}
45+ }
46+ state := &capturedRenderState{
47+ attachment: ac,
48+ lease: request.lease,
49+ reset: request.reset,
50+ layout: capturedTabLayout{area: content, focus: proxyRenderPaneID, valid: true, fingerprint: "proxy"},
51+ bars: request.bars,
52+ theme: request.bars.theme,
53+ styles: request.styles,
54+ styleGeneration: request.styleGeneration,
55+ overlays: request.overlays,
56+ preview: request.preview,
57+ cursor: cursor,
58+ receipts: []damageReceipt{{proxy: p, proxyScreen: screen, generation: damage.Generation}},
59+ panes: []capturedPaneRenderState{{
60+ id: proxyRenderPaneID,
61+ frame: frame,
62+ damage: paneDamage,
63+ placement: layout.Placement{ID: proxyRenderPaneID, Content: content},
64+ focused: true,
65+ }},
66+ }
67+ return state, true
68+}
69+
70+// firstProxyPaintWithLease emits the same full first paint as a local
71+// transition, after making the remote content geometry match the local viewport.
72+func (d *Daemon) firstProxyPaintWithLease(p *proxySession, ac *attachedClient, lease *attachmentLease) {
73+ if p == nil || ac == nil {
74+ return
75+ }
76+ p.resize(ac.size)
77+ if rc := attachmentRenderCoordinator(p); rc != nil {
78+ if rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "proxy_render.go"}) {
79+ rc.fireCurrent(false)
80+ }
81+ return
82+ }
83+ d.invalidateRenderNow(p, ac, true, "proxy_render.go")
84+}
85+
86+// resizeProxyForLease accepts a local client resize only for its exact active
87+// attachment lease. It repaints local chrome after the remote content resize
88+// has been sent by the proxy's serialized sender.
89+func (d *Daemon) resizeProxyForLease(p *proxySession, ac *attachedClient, lease *attachmentLease, size domain.Size) {
90+ if p == nil || ac == nil || !size.Valid() {
91+ return
92+ }
93+ if rc := attachmentRenderCoordinator(p); rc != nil && !rc.leaseCurrent(lease, false) {
94+ return
95+ }
96+ if !p.resize(size) {
97+ return
98+ }
99+ if rc := attachmentRenderCoordinator(p); rc != nil {
100+ rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "proxy_render.go"})
101+ return
102+ }
103+ d.invalidateRender(p, ac, true, "proxy_render.go")
104+}
105+
106+// resize updates the local VT to the local content rectangle and sends that
107+// reduced geometry once to the remote daemon. The sender performs transport
108+// I/O only after proxy.mu has been released.
109+func (p *proxySession) resize(size domain.Size) bool {
110+ if p == nil || !size.Valid() {
111+ return false
112+ }
113+ content := contentSize(size, false)
114+ p.mu.Lock()
115+ if p.screen == nil || p.contentSize == content {
116+ available := p.screen != nil
117+ p.mu.Unlock()
118+ return available
119+ }
120+ p.screen.Resize(content.Cols, content.Rows)
121+ p.contentSize = content
122+ p.resetOutputStateLocked()
123+ generation := p.linkGeneration
124+ p.mu.Unlock()
125+
126+ return p.sendGeneration(generation, ports.Frame{
127+ Type: ports.MsgResize,
128+ Payload: ports.MarshalResize(ports.Resize{Size: content}),
129+ }) == nil
130+}
A internal/usecase/daemon/proxy_render_test.go +134 -0
@@ -0,0 +1,134 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestProxyRenderComposesRemoteVTUnderExactlyOneLocalChrome(t *testing.T) {
13+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
14+ require.NoError(t, err)
15+ proxy.mu.Lock()
16+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
17+ proxy.mu.Unlock()
18+ _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2;3Hremote")})
19+ require.True(t, changed)
20+
21+ state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{
22+ bars: barState{status: proxy.statusSegments(false)},
23+ reset: true,
24+ })
25+ require.True(t, ok)
26+ composed := composeFrame(*state, composeCacheInput{})
27+
28+ require.Equal(t, 6, composed.frame.Height)
29+ require.Contains(t, proxyRowText(composed.frame.Row(0)), "shell", "one local top bar")
30+ require.Contains(t, proxyRowText(composed.frame.Row(5)), "work@arch", "one local bottom bar")
31+ require.NotContains(t, proxyRowText(composed.frame.Row(1)), "shell")
32+ require.NotContains(t, proxyRowText(composed.frame.Row(4)), "work@arch")
33+ require.Equal(t, 'r', composed.frame.At(2, 2).Rune, "ANSI was applied only by the local VT")
34+ require.False(t, composed.cursor.hidden)
35+ require.Equal(t, 2, composed.cursor.row, "cursor includes exactly the one local top bar")
36+ require.Contains(t, composed.damage, renderer.FullRedraw())
37+}
38+
39+func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) {
40+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
41+ require.NoError(t, err)
42+ proxy.mu.Lock()
43+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
44+ proxy.mu.Unlock()
45+ _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")})
46+ require.True(t, changed)
47+
48+ d := newTestDaemon(t, nil, stubClock{})
49+ transport, sent := newCapturingTransport(t)
50+ ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}
51+ ac.setSession(proxy)
52+ proxy.sessionCore.mu.Lock()
53+ proxy.client = ac
54+ proxy.sessionCore.mu.Unlock()
55+
56+ d.paint(proxy, ac, true, nil)
57+ frame := awaitTestValue(t, sent, "handoff to remote proxy did not paint")
58+ require.Equal(t, ports.MsgOutput, frame.Type, "handoff to remote proxy gets a local first paint")
59+}
60+
61+func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
62+ d, local, ac, sent := newManualSessionWithPTYs(t, newQuietPTY())
63+ localCoordinator := d.attachCoordinator(local, nil, ac, true)
64+ localToken := attachmentToken(local, ac, ac.transport())
65+ localToken.lease = localCoordinator.attachmentLease(ac)
66+
67+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, ac.size)
68+ require.NoError(t, err)
69+ proxy.mu.Lock()
70+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
71+ proxy.mu.Unlock()
72+ _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")})
73+ require.True(t, changed)
74+ d.mu.Lock()
75+ require.True(t, d.registerSessionLocked(proxy))
76+ d.mu.Unlock()
77+
78+ toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
79+ source: local, target: proxy, next: ac,
80+ expectedRole: attachmentActive, targetRole: attachmentActive,
81+ expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
82+ action: "test transition", ready: true,
83+ })
84+ require.NoError(t, err)
85+ require.NotNil(t, toProxy.published.lease, "proxy transition installs a coordinator lease")
86+ require.True(t, d.firstPaintForTransition(toProxy.published))
87+ require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to proxy did not paint").Type)
88+
89+ toLocal, err := d.transitionAttachment(attachmentTransitionRequest{
90+ source: proxy, target: local, next: ac,
91+ expectedRole: attachmentActive, targetRole: attachmentActive,
92+ expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
93+ action: "test transition", ready: true,
94+ })
95+ require.NoError(t, err)
96+ require.True(t, d.firstPaintForTransition(toLocal.published))
97+ require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to local session did not paint").Type)
98+}
99+
100+func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) {
101+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 12, Rows: 8})
102+ require.NoError(t, err)
103+ transport := newProxyTestTransport()
104+ proxy.mu.Lock()
105+ proxy.transport = transport
106+ proxy.linkGeneration = 1
107+ proxy.mu.Unlock()
108+
109+ require.True(t, proxy.resize(domain.Size{Cols: 20, Rows: 10}))
110+ frame := awaitTestValue(t, transport.sent, "proxy resize did not reach remote transport")
111+ require.Equal(t, ports.MsgResize, frame.Type)
112+ resize, err := ports.UnmarshalResize(frame.Payload)
113+ require.NoError(t, err)
114+ require.Equal(t, domain.Size{Cols: 20, Rows: 8}, resize.Size)
115+ select {
116+ case extra := <-transport.sent:
117+ t.Fatalf("unexpected second remote resize: %#v", extra)
118+ default:
119+ }
120+
121+ state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{reset: true})
122+ require.True(t, ok)
123+ composed := composeFrame(*state, composeCacheInput{})
124+ require.Equal(t, 10, composed.frame.Height, "local viewport remains full-sized")
125+ require.Equal(t, 8, state.layout.area.Height)
126+}
127+
128+func proxyRowText(row []renderer.Cell) string {
129+ runes := make([]rune, len(row))
130+ for i, cell := range row {
131+ runes[i] = cell.Rune
132+ }
133+ return string(runes)
134+}
@@ -0,0 +1,134 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestProxyRenderComposesRemoteVTUnderExactlyOneLocalChrome(t *testing.T) {
13+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
14+ require.NoError(t, err)
15+ proxy.mu.Lock()
16+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
17+ proxy.mu.Unlock()
18+ _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2;3Hremote")})
19+ require.True(t, changed)
20+
21+ state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{
22+ bars: barState{status: proxy.statusSegments(false)},
23+ reset: true,
24+ })
25+ require.True(t, ok)
26+ composed := composeFrame(*state, composeCacheInput{})
27+
28+ require.Equal(t, 6, composed.frame.Height)
29+ require.Contains(t, proxyRowText(composed.frame.Row(0)), "shell", "one local top bar")
30+ require.Contains(t, proxyRowText(composed.frame.Row(5)), "work@arch", "one local bottom bar")
31+ require.NotContains(t, proxyRowText(composed.frame.Row(1)), "shell")
32+ require.NotContains(t, proxyRowText(composed.frame.Row(4)), "work@arch")
33+ require.Equal(t, 'r', composed.frame.At(2, 2).Rune, "ANSI was applied only by the local VT")
34+ require.False(t, composed.cursor.hidden)
35+ require.Equal(t, 2, composed.cursor.row, "cursor includes exactly the one local top bar")
36+ require.Contains(t, composed.damage, renderer.FullRedraw())
37+}
38+
39+func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) {
40+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
41+ require.NoError(t, err)
42+ proxy.mu.Lock()
43+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
44+ proxy.mu.Unlock()
45+ _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")})
46+ require.True(t, changed)
47+
48+ d := newTestDaemon(t, nil, stubClock{})
49+ transport, sent := newCapturingTransport(t)
50+ ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}
51+ ac.setSession(proxy)
52+ proxy.sessionCore.mu.Lock()
53+ proxy.client = ac
54+ proxy.sessionCore.mu.Unlock()
55+
56+ d.paint(proxy, ac, true, nil)
57+ frame := awaitTestValue(t, sent, "handoff to remote proxy did not paint")
58+ require.Equal(t, ports.MsgOutput, frame.Type, "handoff to remote proxy gets a local first paint")
59+}
60+
61+func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
62+ d, local, ac, sent := newManualSessionWithPTYs(t, newQuietPTY())
63+ localCoordinator := d.attachCoordinator(local, nil, ac, true)
64+ localToken := attachmentToken(local, ac, ac.transport())
65+ localToken.lease = localCoordinator.attachmentLease(ac)
66+
67+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, ac.size)
68+ require.NoError(t, err)
69+ proxy.mu.Lock()
70+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
71+ proxy.mu.Unlock()
72+ _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")})
73+ require.True(t, changed)
74+ d.mu.Lock()
75+ require.True(t, d.registerSessionLocked(proxy))
76+ d.mu.Unlock()
77+
78+ toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
79+ source: local, target: proxy, next: ac,
80+ expectedRole: attachmentActive, targetRole: attachmentActive,
81+ expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
82+ action: "test transition", ready: true,
83+ })
84+ require.NoError(t, err)
85+ require.NotNil(t, toProxy.published.lease, "proxy transition installs a coordinator lease")
86+ require.True(t, d.firstPaintForTransition(toProxy.published))
87+ require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to proxy did not paint").Type)
88+
89+ toLocal, err := d.transitionAttachment(attachmentTransitionRequest{
90+ source: proxy, target: local, next: ac,
91+ expectedRole: attachmentActive, targetRole: attachmentActive,
92+ expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
93+ action: "test transition", ready: true,
94+ })
95+ require.NoError(t, err)
96+ require.True(t, d.firstPaintForTransition(toLocal.published))
97+ require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to local session did not paint").Type)
98+}
99+
100+func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) {
101+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 12, Rows: 8})
102+ require.NoError(t, err)
103+ transport := newProxyTestTransport()
104+ proxy.mu.Lock()
105+ proxy.transport = transport
106+ proxy.linkGeneration = 1
107+ proxy.mu.Unlock()
108+
109+ require.True(t, proxy.resize(domain.Size{Cols: 20, Rows: 10}))
110+ frame := awaitTestValue(t, transport.sent, "proxy resize did not reach remote transport")
111+ require.Equal(t, ports.MsgResize, frame.Type)
112+ resize, err := ports.UnmarshalResize(frame.Payload)
113+ require.NoError(t, err)
114+ require.Equal(t, domain.Size{Cols: 20, Rows: 8}, resize.Size)
115+ select {
116+ case extra := <-transport.sent:
117+ t.Fatalf("unexpected second remote resize: %#v", extra)
118+ default:
119+ }
120+
121+ state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{reset: true})
122+ require.True(t, ok)
123+ composed := composeFrame(*state, composeCacheInput{})
124+ require.Equal(t, 10, composed.frame.Height, "local viewport remains full-sized")
125+ require.Equal(t, 8, state.layout.area.Height)
126+}
127+
128+func proxyRowText(row []renderer.Cell) string {
129+ runes := make([]rune, len(row))
130+ for i, cell := range row {
131+ runes[i] = cell.Rune
132+ }
133+ return string(runes)
134+}
A internal/usecase/daemon/proxy_session.go +419 -0
@@ -0,0 +1,419 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/pkg/vt"
15+)
16+
17+// proxySession is the local attachment identity for one structured remote
18+// session key. mu is a leaf lock: callers may take it below d.mu or sessionCore
19+// locks, but must not acquire another architecture lock or perform transport I/O
20+// while holding it. All Transport.Send calls are owned by sendMu.
21+type proxySession struct {
22+ sessionCore
23+ key domain.RemoteSessionKey
24+
25+ mu sync.Mutex
26+ generation uint64
27+ linkGeneration uint64
28+ transport ports.Transport
29+ resumeToken uint64
30+ appliedState uint64
31+ awaitingReset bool
32+ screen *vt.Screen
33+ meta ports.SessionMeta
34+ attentionAt []time.Time
35+ linkState ports.LinkState
36+ expired bool
37+ warm *proxyWarmTimer
38+ cancel context.CancelFunc
39+ done chan struct{}
40+ doneOnce sync.Once
41+ clientID [16]byte
42+ contentSize domain.Size
43+
44+ sendMu sync.Mutex
45+ inputNext uint64
46+ // commandMu permits at most one interactive palette request to be
47+ // outstanding without blocking input, ACK, resize, or liveness sends.
48+ commandMu sync.Mutex
49+ commandNext uint64
50+ commandPending map[uint64]proxyCommandPending
51+}
52+
53+func (p *proxySession) core() *sessionCore {
54+ if p == nil {
55+ return nil
56+ }
57+ return &p.sessionCore
58+}
59+
60+func (p *proxySession) isProxy() bool { return true }
61+
62+// replaceSessionMetaLocked atomically replaces the relayed snapshot while
63+// preserving the first-observed timestamp of tabs that remain attentive.
64+// Callers must validate meta and hold p.mu.
65+func (p *proxySession) replaceSessionMetaLocked(meta ports.SessionMeta, now time.Time) {
66+ attentionAt := make([]time.Time, len(meta.Tabs))
67+ for i, tab := range meta.Tabs {
68+ if !tab.Attention {
69+ continue
70+ }
71+ if i < len(p.meta.Tabs) && p.meta.Tabs[i].Attention && i < len(p.attentionAt) {
72+ attentionAt[i] = p.attentionAt[i]
73+ continue
74+ }
75+ attentionAt[i] = now
76+ }
77+ p.meta = cloneSessionMeta(meta)
78+ p.attentionAt = attentionAt
79+}
80+
81+// applyProxySessionMeta validates and atomically applies relayed metadata only
82+// for the exact current link generation. A remote session rename cannot mutate
83+// the immutable local identity, so it expires that exact proxy instead.
84+func (d *Daemon) applyProxySessionMeta(p *proxySession, generation uint64, meta ports.SessionMeta) (bool, error) {
85+ if d == nil || p == nil {
86+ return false, errors.New("proxy session: metadata target is unavailable")
87+ }
88+ if _, err := ports.MarshalSessionMeta(meta); err != nil {
89+ return false, fmt.Errorf("proxy session: invalid metadata: %w", err)
90+ }
91+ meta = cloneSessionMeta(meta)
92+ now := d.clock.Now()
93+
94+ p.mu.Lock()
95+ if p.linkGeneration != generation {
96+ p.mu.Unlock()
97+ return false, nil
98+ }
99+ if meta.SessionName != p.key.Name {
100+ transport := p.transport
101+ p.mu.Unlock()
102+ if transport != nil {
103+ d.markProxyReplaced(p, generation, transport)
104+ }
105+ return false, fmt.Errorf("proxy session: metadata identity mismatch: got %q", meta.SessionName)
106+ }
107+ p.replaceSessionMetaLocked(meta, now)
108+ p.mu.Unlock()
109+
110+ d.pokeAttentionTicker()
111+ p.sessionCore.mu.Lock()
112+ ac := p.client
113+ p.sessionCore.mu.Unlock()
114+ if ac != nil {
115+ d.invalidateRender(p, ac, false, "proxy_session.go")
116+ }
117+ return true, nil
118+}
119+
120+func (p *proxySession) snapshotView(opts viewOptions) sessionView {
121+ if p == nil {
122+ return sessionView{}
123+ }
124+ p.mu.Lock()
125+ meta := cloneSessionMeta(p.meta)
126+ attentionAt := append([]time.Time(nil), p.attentionAt...)
127+ name := p.name
128+ expired := p.expired
129+ if expired {
130+ name += " [expired]"
131+ }
132+ p.mu.Unlock()
133+
134+ view := sessionView{
135+ id: p.id,
136+ name: name,
137+ expired: expired,
138+ createdAt: p.createdAt,
139+ mruAt: p.mruAt.Load(),
140+ active: int(meta.Active),
141+ tabCount: len(meta.Tabs),
142+ cannotAcceptMoves: true,
143+ }
144+ p.sessionCore.mu.Lock()
145+ view.attached = p.client != nil
146+ p.sessionCore.mu.Unlock()
147+ if opts.tabDetails {
148+ view.tabs = make([]tabView, len(meta.Tabs))
149+ for i, tab := range meta.Tabs {
150+ view.tabs[i] = tabView{name: tab.Name, attention: tab.Attention}
151+ if i < len(attentionAt) {
152+ view.tabs[i].attentionAt = attentionAt[i]
153+ }
154+ view.hasAttention = view.hasAttention || tab.Attention
155+ }
156+ } else {
157+ for _, tab := range meta.Tabs {
158+ view.hasAttention = view.hasAttention || tab.Attention
159+ }
160+ }
161+ return view
162+}
163+
164+func (p *proxySession) statusSegments(_ bool) statusSnapshot {
165+ if p == nil {
166+ return statusSnapshot{}
167+ }
168+ name := p.lifecycleDisplayName()
169+ p.mu.Lock()
170+ meta := cloneSessionMeta(p.meta)
171+ p.mu.Unlock()
172+ snapshot := statusSnapshot{session: name, tabs: make([]statusTab, len(meta.Tabs))}
173+ for i, tab := range meta.Tabs {
174+ snapshot.tabs[i] = statusTab{
175+ name: tab.Name,
176+ active: uint16(i) == meta.Active,
177+ attention: tab.Attention,
178+ }
179+ }
180+ return snapshot
181+}
182+
183+func (p *proxySession) activateTargetLocked(tabIndex int) bool {
184+ if tabIndex < 0 {
185+ return true
186+ }
187+ p.mu.Lock()
188+ defer p.mu.Unlock()
189+ return tabIndex < len(p.meta.Tabs)
190+}
191+
192+func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySession, error) {
193+ if err := key.Validate(); err != nil {
194+ return nil, fmt.Errorf("proxy session key: %w", err)
195+ }
196+ content := contentSize(size, false)
197+ var clientID [16]byte
198+ if _, err := io.ReadFull(rand.Reader, clientID[:]); err != nil {
199+ return nil, fmt.Errorf("generate proxy client id: %w", err)
200+ }
201+ p := &proxySession{
202+ sessionCore: sessionCore{
203+ id: key.ID(),
204+ name: key.Display(),
205+ caps: sessionCapabilities{cannotAcceptMoves: true, cannotYieldMoves: true},
206+ },
207+ key: key,
208+ generation: 1,
209+ screen: vt.NewScreen(content.Cols, content.Rows),
210+ linkState: ports.LinkStateConnected,
211+ done: make(chan struct{}),
212+ clientID: clientID,
213+ contentSize: content,
214+ commandPending: make(map[uint64]proxyCommandPending),
215+ }
216+ return p, nil
217+}
218+
219+type proxyConstruction struct {
220+ done chan struct{}
221+ err error
222+}
223+
224+var errProxySessionExpired = errors.New("proxy session: remote attachment has expired")
225+
226+// openProxySession returns the exact already-published proxy for key, or elects
227+// one constructor for that structured key. Waiters never dial: they wait
228+// cancellably, then revalidate the exact registry winner under d.mu. The
229+// reservation itself is held only under d.mu; dial and handshake I/O run after
230+// releasing every daemon, core, and proxy architecture lock.
231+func (d *Daemon) openProxySession(ctx context.Context, key domain.RemoteSessionKey, size domain.Size) (*proxySession, error) {
232+ if d == nil {
233+ return nil, errors.New("proxy session: nil daemon")
234+ }
235+ if ctx == nil {
236+ ctx = context.Background()
237+ }
238+ if err := key.Validate(); err != nil {
239+ return nil, fmt.Errorf("proxy session: %w", err)
240+ }
241+ if d.remoteDialerFactory == nil {
242+ return nil, errors.New("proxy session: remote dialer is not configured")
243+ }
244+
245+ for {
246+ d.mu.Lock()
247+ existing, found, err := d.proxyForKeyLocked(key)
248+ if err != nil {
249+ d.mu.Unlock()
250+ return nil, err
251+ }
252+ if found {
253+ d.mu.Unlock()
254+ return existing, nil
255+ }
256+ if d.closing {
257+ d.mu.Unlock()
258+ return nil, errors.New("proxy session: daemon is shutting down")
259+ }
260+ if construction := d.proxyConstructions[key]; construction != nil {
261+ done := construction.done
262+ d.mu.Unlock()
263+ select {
264+ case <-ctx.Done():
265+ return nil, ctx.Err()
266+ case <-done:
267+ }
268+ if err := ctx.Err(); err != nil {
269+ return nil, err
270+ }
271+
272+ // Publication and construction completion are ordered under d.mu.
273+ // Revalidate rather than trusting a stale candidate pointer.
274+ d.mu.Lock()
275+ winner, registered, collisionErr := d.proxyForKeyLocked(key)
276+ constructionErr := construction.err
277+ d.mu.Unlock()
278+ if collisionErr != nil {
279+ return nil, collisionErr
280+ }
281+ if registered {
282+ return winner, nil
283+ }
284+ if constructionErr != nil &&
285+ !errors.Is(constructionErr, context.Canceled) &&
286+ !errors.Is(constructionErr, context.DeadlineExceeded) {
287+ return nil, constructionErr
288+ }
289+ // A canceled owner does not poison independent waiters. Re-elect
290+ // under d.mu if this caller still wants the proxy.
291+ continue
292+ }
293+
294+ construction := &proxyConstruction{done: make(chan struct{})}
295+ if d.proxyConstructions == nil {
296+ d.proxyConstructions = make(map[domain.RemoteSessionKey]*proxyConstruction)
297+ }
298+ d.proxyConstructions[key] = construction
299+ d.mu.Unlock()
300+
301+ proxy, constructionErr := d.constructProxySession(ctx, key, size)
302+ d.mu.Lock()
303+ construction.err = constructionErr
304+ if d.proxyConstructions[key] == construction {
305+ delete(d.proxyConstructions, key)
306+ close(construction.done)
307+ }
308+ d.mu.Unlock()
309+ return proxy, constructionErr
310+ }
311+}
312+
313+// proxyForKeyLocked validates the opaque session-ID collision while d.mu is
314+// held. A true result is always the exact currently registered proxy.
315+func (d *Daemon) proxyForKeyLocked(key domain.RemoteSessionKey) (*proxySession, bool, error) {
316+ existing := d.sessions[key.ID()]
317+ if existing == nil {
318+ return nil, false, nil
319+ }
320+ proxy, ok := existing.(*proxySession)
321+ if !ok || proxy.key != key {
322+ return nil, false, errors.New("proxy session: remote identity collision")
323+ }
324+ proxy.mu.Lock()
325+ expired := proxy.expired
326+ proxy.mu.Unlock()
327+ if expired {
328+ return nil, false, errProxySessionExpired
329+ }
330+ return proxy, true, nil
331+}
332+
333+// constructProxySession owns one reserved key. The caller has released d.mu
334+// before entering this function, and no architecture lock spans handshake I/O.
335+func (d *Daemon) constructProxySession(ctx context.Context, key domain.RemoteSessionKey, size domain.Size) (*proxySession, error) {
336+ proxy, err := newProxySession(key, size)
337+ if err != nil {
338+ return nil, err
339+ }
340+ root := context.Background()
341+ if d.serveCtx != nil {
342+ root = d.serveCtx
343+ }
344+ linkCtx, cancel := context.WithCancel(root)
345+ proxy.cancel = cancel
346+ published := false
347+ defer func() {
348+ if !published {
349+ proxy.disposeUnpublished()
350+ }
351+ }()
352+
353+ if err := d.dialProxyHandshake(ctx, proxy, ports.IntentAttach); err != nil {
354+ return nil, err
355+ }
356+ if err := ctx.Err(); err != nil {
357+ return nil, err
358+ }
359+
360+ d.mu.Lock()
361+ if err := ctx.Err(); err != nil {
362+ d.mu.Unlock()
363+ return nil, err
364+ }
365+ if existing, found, err := d.proxyForKeyLocked(key); err != nil {
366+ d.mu.Unlock()
367+ return nil, err
368+ } else if found {
369+ d.mu.Unlock()
370+ return existing, nil
371+ }
372+ if d.closing || !d.registerSessionLocked(proxy) {
373+ d.mu.Unlock()
374+ return nil, errors.New("proxy session: daemon rejected publication")
375+ }
376+ published = true
377+ d.mu.Unlock()
378+
379+ go d.runProxyLink(linkCtx, proxy)
380+ return proxy, nil
381+}
382+
383+func (p *proxySession) finish() {
384+ if p != nil {
385+ p.doneOnce.Do(func() { close(p.done) })
386+ }
387+}
388+
389+func (p *proxySession) disposeUnpublished() {
390+ if p == nil {
391+ return
392+ }
393+ p.stop()
394+ p.finish()
395+}
396+
397+func (p *proxySession) stop() {
398+ if p == nil {
399+ return
400+ }
401+ if p.cancel != nil {
402+ p.cancel()
403+ }
404+ p.closeCurrentTransport()
405+}
406+
407+func (p *proxySession) closeCurrentTransport() {
408+ if p == nil {
409+ return
410+ }
411+ p.mu.Lock()
412+ transport := p.transport
413+ p.mu.Unlock()
414+ if transport != nil {
415+ _ = transport.Close()
416+ }
417+}
418+
419+var _ attachmentSession = (*proxySession)(nil)
@@ -0,0 +1,419 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/pkg/vt"
15+)
16+
17+// proxySession is the local attachment identity for one structured remote
18+// session key. mu is a leaf lock: callers may take it below d.mu or sessionCore
19+// locks, but must not acquire another architecture lock or perform transport I/O
20+// while holding it. All Transport.Send calls are owned by sendMu.
21+type proxySession struct {
22+ sessionCore
23+ key domain.RemoteSessionKey
24+
25+ mu sync.Mutex
26+ generation uint64
27+ linkGeneration uint64
28+ transport ports.Transport
29+ resumeToken uint64
30+ appliedState uint64
31+ awaitingReset bool
32+ screen *vt.Screen
33+ meta ports.SessionMeta
34+ attentionAt []time.Time
35+ linkState ports.LinkState
36+ expired bool
37+ warm *proxyWarmTimer
38+ cancel context.CancelFunc
39+ done chan struct{}
40+ doneOnce sync.Once
41+ clientID [16]byte
42+ contentSize domain.Size
43+
44+ sendMu sync.Mutex
45+ inputNext uint64
46+ // commandMu permits at most one interactive palette request to be
47+ // outstanding without blocking input, ACK, resize, or liveness sends.
48+ commandMu sync.Mutex
49+ commandNext uint64
50+ commandPending map[uint64]proxyCommandPending
51+}
52+
53+func (p *proxySession) core() *sessionCore {
54+ if p == nil {
55+ return nil
56+ }
57+ return &p.sessionCore
58+}
59+
60+func (p *proxySession) isProxy() bool { return true }
61+
62+// replaceSessionMetaLocked atomically replaces the relayed snapshot while
63+// preserving the first-observed timestamp of tabs that remain attentive.
64+// Callers must validate meta and hold p.mu.
65+func (p *proxySession) replaceSessionMetaLocked(meta ports.SessionMeta, now time.Time) {
66+ attentionAt := make([]time.Time, len(meta.Tabs))
67+ for i, tab := range meta.Tabs {
68+ if !tab.Attention {
69+ continue
70+ }
71+ if i < len(p.meta.Tabs) && p.meta.Tabs[i].Attention && i < len(p.attentionAt) {
72+ attentionAt[i] = p.attentionAt[i]
73+ continue
74+ }
75+ attentionAt[i] = now
76+ }
77+ p.meta = cloneSessionMeta(meta)
78+ p.attentionAt = attentionAt
79+}
80+
81+// applyProxySessionMeta validates and atomically applies relayed metadata only
82+// for the exact current link generation. A remote session rename cannot mutate
83+// the immutable local identity, so it expires that exact proxy instead.
84+func (d *Daemon) applyProxySessionMeta(p *proxySession, generation uint64, meta ports.SessionMeta) (bool, error) {
85+ if d == nil || p == nil {
86+ return false, errors.New("proxy session: metadata target is unavailable")
87+ }
88+ if _, err := ports.MarshalSessionMeta(meta); err != nil {
89+ return false, fmt.Errorf("proxy session: invalid metadata: %w", err)
90+ }
91+ meta = cloneSessionMeta(meta)
92+ now := d.clock.Now()
93+
94+ p.mu.Lock()
95+ if p.linkGeneration != generation {
96+ p.mu.Unlock()
97+ return false, nil
98+ }
99+ if meta.SessionName != p.key.Name {
100+ transport := p.transport
101+ p.mu.Unlock()
102+ if transport != nil {
103+ d.markProxyReplaced(p, generation, transport)
104+ }
105+ return false, fmt.Errorf("proxy session: metadata identity mismatch: got %q", meta.SessionName)
106+ }
107+ p.replaceSessionMetaLocked(meta, now)
108+ p.mu.Unlock()
109+
110+ d.pokeAttentionTicker()
111+ p.sessionCore.mu.Lock()
112+ ac := p.client
113+ p.sessionCore.mu.Unlock()
114+ if ac != nil {
115+ d.invalidateRender(p, ac, false, "proxy_session.go")
116+ }
117+ return true, nil
118+}
119+
120+func (p *proxySession) snapshotView(opts viewOptions) sessionView {
121+ if p == nil {
122+ return sessionView{}
123+ }
124+ p.mu.Lock()
125+ meta := cloneSessionMeta(p.meta)
126+ attentionAt := append([]time.Time(nil), p.attentionAt...)
127+ name := p.name
128+ expired := p.expired
129+ if expired {
130+ name += " [expired]"
131+ }
132+ p.mu.Unlock()
133+
134+ view := sessionView{
135+ id: p.id,
136+ name: name,
137+ expired: expired,
138+ createdAt: p.createdAt,
139+ mruAt: p.mruAt.Load(),
140+ active: int(meta.Active),
141+ tabCount: len(meta.Tabs),
142+ cannotAcceptMoves: true,
143+ }
144+ p.sessionCore.mu.Lock()
145+ view.attached = p.client != nil
146+ p.sessionCore.mu.Unlock()
147+ if opts.tabDetails {
148+ view.tabs = make([]tabView, len(meta.Tabs))
149+ for i, tab := range meta.Tabs {
150+ view.tabs[i] = tabView{name: tab.Name, attention: tab.Attention}
151+ if i < len(attentionAt) {
152+ view.tabs[i].attentionAt = attentionAt[i]
153+ }
154+ view.hasAttention = view.hasAttention || tab.Attention
155+ }
156+ } else {
157+ for _, tab := range meta.Tabs {
158+ view.hasAttention = view.hasAttention || tab.Attention
159+ }
160+ }
161+ return view
162+}
163+
164+func (p *proxySession) statusSegments(_ bool) statusSnapshot {
165+ if p == nil {
166+ return statusSnapshot{}
167+ }
168+ name := p.lifecycleDisplayName()
169+ p.mu.Lock()
170+ meta := cloneSessionMeta(p.meta)
171+ p.mu.Unlock()
172+ snapshot := statusSnapshot{session: name, tabs: make([]statusTab, len(meta.Tabs))}
173+ for i, tab := range meta.Tabs {
174+ snapshot.tabs[i] = statusTab{
175+ name: tab.Name,
176+ active: uint16(i) == meta.Active,
177+ attention: tab.Attention,
178+ }
179+ }
180+ return snapshot
181+}
182+
183+func (p *proxySession) activateTargetLocked(tabIndex int) bool {
184+ if tabIndex < 0 {
185+ return true
186+ }
187+ p.mu.Lock()
188+ defer p.mu.Unlock()
189+ return tabIndex < len(p.meta.Tabs)
190+}
191+
192+func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySession, error) {
193+ if err := key.Validate(); err != nil {
194+ return nil, fmt.Errorf("proxy session key: %w", err)
195+ }
196+ content := contentSize(size, false)
197+ var clientID [16]byte
198+ if _, err := io.ReadFull(rand.Reader, clientID[:]); err != nil {
199+ return nil, fmt.Errorf("generate proxy client id: %w", err)
200+ }
201+ p := &proxySession{
202+ sessionCore: sessionCore{
203+ id: key.ID(),
204+ name: key.Display(),
205+ caps: sessionCapabilities{cannotAcceptMoves: true, cannotYieldMoves: true},
206+ },
207+ key: key,
208+ generation: 1,
209+ screen: vt.NewScreen(content.Cols, content.Rows),
210+ linkState: ports.LinkStateConnected,
211+ done: make(chan struct{}),
212+ clientID: clientID,
213+ contentSize: content,
214+ commandPending: make(map[uint64]proxyCommandPending),
215+ }
216+ return p, nil
217+}
218+
219+type proxyConstruction struct {
220+ done chan struct{}
221+ err error
222+}
223+
224+var errProxySessionExpired = errors.New("proxy session: remote attachment has expired")
225+
226+// openProxySession returns the exact already-published proxy for key, or elects
227+// one constructor for that structured key. Waiters never dial: they wait
228+// cancellably, then revalidate the exact registry winner under d.mu. The
229+// reservation itself is held only under d.mu; dial and handshake I/O run after
230+// releasing every daemon, core, and proxy architecture lock.
231+func (d *Daemon) openProxySession(ctx context.Context, key domain.RemoteSessionKey, size domain.Size) (*proxySession, error) {
232+ if d == nil {
233+ return nil, errors.New("proxy session: nil daemon")
234+ }
235+ if ctx == nil {
236+ ctx = context.Background()
237+ }
238+ if err := key.Validate(); err != nil {
239+ return nil, fmt.Errorf("proxy session: %w", err)
240+ }
241+ if d.remoteDialerFactory == nil {
242+ return nil, errors.New("proxy session: remote dialer is not configured")
243+ }
244+
245+ for {
246+ d.mu.Lock()
247+ existing, found, err := d.proxyForKeyLocked(key)
248+ if err != nil {
249+ d.mu.Unlock()
250+ return nil, err
251+ }
252+ if found {
253+ d.mu.Unlock()
254+ return existing, nil
255+ }
256+ if d.closing {
257+ d.mu.Unlock()
258+ return nil, errors.New("proxy session: daemon is shutting down")
259+ }
260+ if construction := d.proxyConstructions[key]; construction != nil {
261+ done := construction.done
262+ d.mu.Unlock()
263+ select {
264+ case <-ctx.Done():
265+ return nil, ctx.Err()
266+ case <-done:
267+ }
268+ if err := ctx.Err(); err != nil {
269+ return nil, err
270+ }
271+
272+ // Publication and construction completion are ordered under d.mu.
273+ // Revalidate rather than trusting a stale candidate pointer.
274+ d.mu.Lock()
275+ winner, registered, collisionErr := d.proxyForKeyLocked(key)
276+ constructionErr := construction.err
277+ d.mu.Unlock()
278+ if collisionErr != nil {
279+ return nil, collisionErr
280+ }
281+ if registered {
282+ return winner, nil
283+ }
284+ if constructionErr != nil &&
285+ !errors.Is(constructionErr, context.Canceled) &&
286+ !errors.Is(constructionErr, context.DeadlineExceeded) {
287+ return nil, constructionErr
288+ }
289+ // A canceled owner does not poison independent waiters. Re-elect
290+ // under d.mu if this caller still wants the proxy.
291+ continue
292+ }
293+
294+ construction := &proxyConstruction{done: make(chan struct{})}
295+ if d.proxyConstructions == nil {
296+ d.proxyConstructions = make(map[domain.RemoteSessionKey]*proxyConstruction)
297+ }
298+ d.proxyConstructions[key] = construction
299+ d.mu.Unlock()
300+
301+ proxy, constructionErr := d.constructProxySession(ctx, key, size)
302+ d.mu.Lock()
303+ construction.err = constructionErr
304+ if d.proxyConstructions[key] == construction {
305+ delete(d.proxyConstructions, key)
306+ close(construction.done)
307+ }
308+ d.mu.Unlock()
309+ return proxy, constructionErr
310+ }
311+}
312+
313+// proxyForKeyLocked validates the opaque session-ID collision while d.mu is
314+// held. A true result is always the exact currently registered proxy.
315+func (d *Daemon) proxyForKeyLocked(key domain.RemoteSessionKey) (*proxySession, bool, error) {
316+ existing := d.sessions[key.ID()]
317+ if existing == nil {
318+ return nil, false, nil
319+ }
320+ proxy, ok := existing.(*proxySession)
321+ if !ok || proxy.key != key {
322+ return nil, false, errors.New("proxy session: remote identity collision")
323+ }
324+ proxy.mu.Lock()
325+ expired := proxy.expired
326+ proxy.mu.Unlock()
327+ if expired {
328+ return nil, false, errProxySessionExpired
329+ }
330+ return proxy, true, nil
331+}
332+
333+// constructProxySession owns one reserved key. The caller has released d.mu
334+// before entering this function, and no architecture lock spans handshake I/O.
335+func (d *Daemon) constructProxySession(ctx context.Context, key domain.RemoteSessionKey, size domain.Size) (*proxySession, error) {
336+ proxy, err := newProxySession(key, size)
337+ if err != nil {
338+ return nil, err
339+ }
340+ root := context.Background()
341+ if d.serveCtx != nil {
342+ root = d.serveCtx
343+ }
344+ linkCtx, cancel := context.WithCancel(root)
345+ proxy.cancel = cancel
346+ published := false
347+ defer func() {
348+ if !published {
349+ proxy.disposeUnpublished()
350+ }
351+ }()
352+
353+ if err := d.dialProxyHandshake(ctx, proxy, ports.IntentAttach); err != nil {
354+ return nil, err
355+ }
356+ if err := ctx.Err(); err != nil {
357+ return nil, err
358+ }
359+
360+ d.mu.Lock()
361+ if err := ctx.Err(); err != nil {
362+ d.mu.Unlock()
363+ return nil, err
364+ }
365+ if existing, found, err := d.proxyForKeyLocked(key); err != nil {
366+ d.mu.Unlock()
367+ return nil, err
368+ } else if found {
369+ d.mu.Unlock()
370+ return existing, nil
371+ }
372+ if d.closing || !d.registerSessionLocked(proxy) {
373+ d.mu.Unlock()
374+ return nil, errors.New("proxy session: daemon rejected publication")
375+ }
376+ published = true
377+ d.mu.Unlock()
378+
379+ go d.runProxyLink(linkCtx, proxy)
380+ return proxy, nil
381+}
382+
383+func (p *proxySession) finish() {
384+ if p != nil {
385+ p.doneOnce.Do(func() { close(p.done) })
386+ }
387+}
388+
389+func (p *proxySession) disposeUnpublished() {
390+ if p == nil {
391+ return
392+ }
393+ p.stop()
394+ p.finish()
395+}
396+
397+func (p *proxySession) stop() {
398+ if p == nil {
399+ return
400+ }
401+ if p.cancel != nil {
402+ p.cancel()
403+ }
404+ p.closeCurrentTransport()
405+}
406+
407+func (p *proxySession) closeCurrentTransport() {
408+ if p == nil {
409+ return
410+ }
411+ p.mu.Lock()
412+ transport := p.transport
413+ p.mu.Unlock()
414+ if transport != nil {
415+ _ = transport.Close()
416+ }
417+}
418+
419+var _ attachmentSession = (*proxySession)(nil)
M internal/usecase/daemon/recent_sessions.go +1 -1
@@ -17,7 +17,7 @@ type recentSession struct {
1717
1818 // recentSessions returns the current session's capped named-session MRU list.
1919 // Callers may retain the returned values for the lifetime of an interaction.
20-func (d *Daemon) recentSessions(current *session) []recentSession {
20+func (d *Daemon) recentSessions(current attachmentSession) []recentSession {
2121 if d == nil {
2222 return nil
2323 }
@@ -17,7 +17,7 @@ type recentSession struct {
17 17
18 // recentSessions returns the current session's capped named-session MRU list.18 // recentSessions returns the current session's capped named-session MRU list.
19 // Callers may retain the returned values for the lifetime of an interaction.19 // Callers may retain the returned values for the lifetime of an interaction.
20-func (d *Daemon) recentSessions(current *session) []recentSession {20+func (d *Daemon) recentSessions(current attachmentSession) []recentSession {
21 if d == nil {21 if d == nil {
22 return nil22 return nil
23 }23 }
M internal/usecase/daemon/render.go +40 -34
@@ -292,13 +292,10 @@ func (b *runtimeMarkBatch) flush() {
292292 }
293293
294294 func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {
295- // Phase 1 has only the local implementation. Keep all local preparation
296- // byte-identical while routing the attachment-facing capture through the
297- // interface; proxy-specific preparation is added with proxy rendering.
298- sess, ok := localSession(entry)
299- if !ok {
300- return
301- }
295+ // Local sessions retain their tab/PTY preparation below. Proxy sessions
296+ // enter the same attachment pipeline but supply their VT snapshot through
297+ // attachmentSession.capturePrimary.
298+ sess, local := localSession(entry)
302299 marks := d.newRuntimeMarkBatch()
303300 if lease != nil {
304301 token := attachmentToken(entry, ac, ac.transport())
@@ -310,18 +307,21 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
310307 marks.roleEffect = ticket
311308 defer ticket.End()
312309 }
313- tb := sess.activeTab()
314- if tb == nil {
315- return
310+ var tb *tab
311+ if local {
312+ tb = sess.activeTab()
313+ if tb == nil {
314+ return
315+ }
316316 }
317317
318318 ac.sendMu.Lock()
319319 // sendMu is the attachment ownership boundary. Check the session's
320320 // published identity while holding it so a deadline captured before an
321321 // attach/replace cannot emit on either the old or new output chain.
322- sess.mu.Lock()
323- owned := sess.client == ac
324- sess.mu.Unlock()
322+ entry.core().mu.Lock()
323+ owned := entry.core().client == ac
324+ entry.core().mu.Unlock()
325325 if !owned || ac.currentAttachmentSession() != entry {
326326 ac.sendMu.Unlock()
327327 return
@@ -359,40 +359,46 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
359359 }
360360 }()
361361 preview := snapshotPickerPreview(nil)
362- if overlays.previewTab != tb {
362+ if local && overlays.previewTab != tb {
363363 preview = snapshotPickerPreview(overlays.previewTab)
364364 }
365- d.refreshSessionFocusedTitles(sess)
365+ if local {
366+ d.refreshSessionFocusedTitles(sess)
367+ }
366368 // Contextual ranks are completely captured under paletteMu. Compose them
367369 // without reading the live MRU, whose order may have changed mid-interaction.
368370 statusFeedback := overlays.statusFeedback
369371 if statusFeedback == "" && overlays.resizeActive {
370372 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
371373 }
372- bars := d.barStateForPaletteHints(sess, statusFeedback, overlays.paletteHints, overlays.paletteRecent)
374+ bars := d.barStateForAttachmentPaletteHints(entry, statusFeedback, overlays.paletteHints, overlays.paletteRecent)
373375 applied := ac.getAppliedTheme()
374376 bars.theme = applied.Raw
375- attentionVisible := pulseVisible(bars.attentionFrame)
376- repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
377+ if local {
378+ attentionVisible := pulseVisible(bars.attentionFrame)
379+ repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
380+ }
377381
378382 paletteCfg := d.currentPaletteConfig()
379383 floatingCfg := d.currentFloatingConfig()
380384 // Title refresh may inspect process state, so it remains before the capture
381385 // boundary and outside tab/pane ownership locks.
382- tb.mu.Lock()
383- titleIDs := ac.renderScratch.titleIDs[:0]
384- if tb.tree != nil {
385- titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
386- }
387- ac.renderScratch.titleIDs = titleIDs
388- floating := tb.floating.pane
389- hasFloating := tb.floating.state == floatingVisible && floating != nil
390- tb.mu.Unlock()
391- for _, id := range titleIDs {
392- d.refreshPaneTitle(sess, id)
393- }
394- if hasFloating {
395- d.refreshPaneDisplayTitle(sess, floating, false)
386+ if local {
387+ tb.mu.Lock()
388+ titleIDs := ac.renderScratch.titleIDs[:0]
389+ if tb.tree != nil {
390+ titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
391+ }
392+ ac.renderScratch.titleIDs = titleIDs
393+ floating := tb.floating.pane
394+ hasFloating := tb.floating.state == floatingVisible && floating != nil
395+ tb.mu.Unlock()
396+ for _, id := range titleIDs {
397+ d.refreshPaneTitle(sess, id)
398+ }
399+ if hasFloating {
400+ d.refreshPaneDisplayTitle(sess, floating, false)
401+ }
396402 }
397403
398404 capturedOverlays := capturedOverlayRenderState{
@@ -419,7 +425,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
419425 if ac.renderStages.capture != nil {
420426 ac.renderStages.capture()
421427 }
422- if overlays.pickerActive && overlays.previewTab == tb {
428+ if local && overlays.pickerActive && overlays.previewTab == tb {
423429 previewState := *state
424430 previewState.overlays = capturedOverlayRenderState{}
425431 previewState.reset = true
@@ -432,7 +438,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
432438 if ac.renderStages.compose != nil {
433439 ac.renderStages.compose()
434440 }
435- d.emitFrame(sess, ac, state, composed, &marks)
441+ d.emitFrame(entry, ac, state, composed, &marks)
436442 }
437443
438444 var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
@@ -292,13 +292,10 @@ func (b *runtimeMarkBatch) flush() {
292 }292 }
293 293
294 func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {294 func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {
295- // Phase 1 has only the local implementation. Keep all local preparation295+ // Local sessions retain their tab/PTY preparation below. Proxy sessions
296- // byte-identical while routing the attachment-facing capture through the296+ // enter the same attachment pipeline but supply their VT snapshot through
297- // interface; proxy-specific preparation is added with proxy rendering.297+ // attachmentSession.capturePrimary.
298- sess, ok := localSession(entry)298+ sess, local := localSession(entry)
299- if !ok {
300- return
301- }
302 marks := d.newRuntimeMarkBatch()299 marks := d.newRuntimeMarkBatch()
303 if lease != nil {300 if lease != nil {
304 token := attachmentToken(entry, ac, ac.transport())301 token := attachmentToken(entry, ac, ac.transport())
@@ -310,18 +307,21 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
310 marks.roleEffect = ticket307 marks.roleEffect = ticket
311 defer ticket.End()308 defer ticket.End()
312 }309 }
313- tb := sess.activeTab()310+ var tb *tab
314- if tb == nil {311+ if local {
315- return312+ tb = sess.activeTab()
313+ if tb == nil {
314+ return
315+ }
316 }316 }
317 317
318 ac.sendMu.Lock()318 ac.sendMu.Lock()
319 // sendMu is the attachment ownership boundary. Check the session's319 // sendMu is the attachment ownership boundary. Check the session's
320 // published identity while holding it so a deadline captured before an320 // published identity while holding it so a deadline captured before an
321 // attach/replace cannot emit on either the old or new output chain.321 // attach/replace cannot emit on either the old or new output chain.
322- sess.mu.Lock()322+ entry.core().mu.Lock()
323- owned := sess.client == ac323+ owned := entry.core().client == ac
324- sess.mu.Unlock()324+ entry.core().mu.Unlock()
325 if !owned || ac.currentAttachmentSession() != entry {325 if !owned || ac.currentAttachmentSession() != entry {
326 ac.sendMu.Unlock()326 ac.sendMu.Unlock()
327 return327 return
@@ -359,40 +359,46 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
359 }359 }
360 }()360 }()
361 preview := snapshotPickerPreview(nil)361 preview := snapshotPickerPreview(nil)
362- if overlays.previewTab != tb {362+ if local && overlays.previewTab != tb {
363 preview = snapshotPickerPreview(overlays.previewTab)363 preview = snapshotPickerPreview(overlays.previewTab)
364 }364 }
365- d.refreshSessionFocusedTitles(sess)365+ if local {
366+ d.refreshSessionFocusedTitles(sess)
367+ }
366 // Contextual ranks are completely captured under paletteMu. Compose them368 // Contextual ranks are completely captured under paletteMu. Compose them
367 // without reading the live MRU, whose order may have changed mid-interaction.369 // without reading the live MRU, whose order may have changed mid-interaction.
368 statusFeedback := overlays.statusFeedback370 statusFeedback := overlays.statusFeedback
369 if statusFeedback == "" && overlays.resizeActive {371 if statusFeedback == "" && overlays.resizeActive {
370 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"372 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
371 }373 }
372- bars := d.barStateForPaletteHints(sess, statusFeedback, overlays.paletteHints, overlays.paletteRecent)374+ bars := d.barStateForAttachmentPaletteHints(entry, statusFeedback, overlays.paletteHints, overlays.paletteRecent)
373 applied := ac.getAppliedTheme()375 applied := ac.getAppliedTheme()
374 bars.theme = applied.Raw376 bars.theme = applied.Raw
375- attentionVisible := pulseVisible(bars.attentionFrame)377+ if local {
376- repaintAttachedClients = sess.ackAttention(tb, attentionVisible)378+ attentionVisible := pulseVisible(bars.attentionFrame)
379+ repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
380+ }
377 381
378 paletteCfg := d.currentPaletteConfig()382 paletteCfg := d.currentPaletteConfig()
379 floatingCfg := d.currentFloatingConfig()383 floatingCfg := d.currentFloatingConfig()
380 // Title refresh may inspect process state, so it remains before the capture384 // Title refresh may inspect process state, so it remains before the capture
381 // boundary and outside tab/pane ownership locks.385 // boundary and outside tab/pane ownership locks.
382- tb.mu.Lock()386+ if local {
383- titleIDs := ac.renderScratch.titleIDs[:0]387+ tb.mu.Lock()
384- if tb.tree != nil {388+ titleIDs := ac.renderScratch.titleIDs[:0]
385- titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)389+ if tb.tree != nil {
386- }390+ titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
387- ac.renderScratch.titleIDs = titleIDs391+ }
388- floating := tb.floating.pane392+ ac.renderScratch.titleIDs = titleIDs
389- hasFloating := tb.floating.state == floatingVisible && floating != nil393+ floating := tb.floating.pane
390- tb.mu.Unlock()394+ hasFloating := tb.floating.state == floatingVisible && floating != nil
391- for _, id := range titleIDs {395+ tb.mu.Unlock()
392- d.refreshPaneTitle(sess, id)396+ for _, id := range titleIDs {
393- }397+ d.refreshPaneTitle(sess, id)
394- if hasFloating {398+ }
395- d.refreshPaneDisplayTitle(sess, floating, false)399+ if hasFloating {
400+ d.refreshPaneDisplayTitle(sess, floating, false)
401+ }
396 }402 }
397 403
398 capturedOverlays := capturedOverlayRenderState{404 capturedOverlays := capturedOverlayRenderState{
@@ -419,7 +425,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
419 if ac.renderStages.capture != nil {425 if ac.renderStages.capture != nil {
420 ac.renderStages.capture()426 ac.renderStages.capture()
421 }427 }
422- if overlays.pickerActive && overlays.previewTab == tb {428+ if local && overlays.pickerActive && overlays.previewTab == tb {
423 previewState := *state429 previewState := *state
424 previewState.overlays = capturedOverlayRenderState{}430 previewState.overlays = capturedOverlayRenderState{}
425 previewState.reset = true431 previewState.reset = true
@@ -432,7 +438,7 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
432 if ac.renderStages.compose != nil {438 if ac.renderStages.compose != nil {
433 ac.renderStages.compose()439 ac.renderStages.compose()
434 }440 }
435- d.emitFrame(sess, ac, state, composed, &marks)441+ d.emitFrame(entry, ac, state, composed, &marks)
436 }442 }
437 443
438 var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles444 var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
M internal/usecase/daemon/render_capture.go +5 -2
@@ -8,6 +8,7 @@ import (
88 themeui "github.com/bnema/vev/internal/usecase/theme"
99 "github.com/bnema/vev/internal/usecase/ui"
1010 "github.com/bnema/vev/pkg/renderer"
11+ "github.com/bnema/vev/pkg/vt"
1112 )
1213
1314 // capturedRenderState is the immutable hand-off from authoritative daemon
@@ -35,8 +36,10 @@ type renderCaptureScratch struct {
3536 }
3637
3738 type damageReceipt struct {
38- pane *pane
39- generation uint64
39+ pane *pane
40+ proxy *proxySession
41+ proxyScreen *vt.Screen
42+ generation uint64
4043 }
4144
4245 type capturedRenderState struct {
@@ -8,6 +8,7 @@ import (
8 themeui "github.com/bnema/vev/internal/usecase/theme"8 themeui "github.com/bnema/vev/internal/usecase/theme"
9 "github.com/bnema/vev/internal/usecase/ui"9 "github.com/bnema/vev/internal/usecase/ui"
10 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
11+ "github.com/bnema/vev/pkg/vt"
11 )12 )
12 13
13 // capturedRenderState is the immutable hand-off from authoritative daemon14 // capturedRenderState is the immutable hand-off from authoritative daemon
@@ -35,8 +36,10 @@ type renderCaptureScratch struct {
35 }36 }
36 37
37 type damageReceipt struct {38 type damageReceipt struct {
38- pane *pane39+ pane *pane
39- generation uint6440+ proxy *proxySession
41+ proxyScreen *vt.Screen
42+ generation uint64
40 }43 }
41 44
42 type capturedRenderState struct {45 type capturedRenderState struct {
M internal/usecase/daemon/render_pipeline.go +33 -20
@@ -417,18 +417,25 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte
417417 // the next transaction.
418418 func commitDamageReceipts(receipts []damageReceipt) {
419419 for _, receipt := range receipts {
420- if receipt.pane == nil {
420+ if receipt.pane != nil {
421+ receipt.pane.mu.Lock()
422+ receipt.pane.screen.AcknowledgeDamage(receipt.generation)
423+ receipt.pane.mu.Unlock()
421424 continue
422425 }
423- receipt.pane.mu.Lock()
424- receipt.pane.screen.AcknowledgeDamage(receipt.generation)
425- receipt.pane.mu.Unlock()
426+ if receipt.proxy != nil {
427+ receipt.proxy.mu.Lock()
428+ if receipt.proxy.screen == receipt.proxyScreen {
429+ receipt.proxy.screen.AcknowledgeDamage(receipt.generation)
430+ }
431+ receipt.proxy.mu.Unlock()
432+ }
426433 }
427434 }
428435
429436 // emitFrame is the sole side-effecting half of the pipeline. The caller holds
430437 // sendMu for the complete capture/compose/emit transaction.
431-func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
438+func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
432439 var ownedMarks runtimeMarkBatch
433440 var marks *runtimeMarkBatch
434441 if len(batches) != 0 {
@@ -440,21 +447,21 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
440447 // follows emitFrame's release of attachment ownership.
441448 defer marks.flush()
442449 }
443- if sess == nil || ac == nil || state == nil {
450+ if entry == nil || entry.core() == nil || ac == nil || state == nil {
444451 if ac != nil {
445452 ac.sendMu.Unlock()
446453 }
447454 return false
448455 }
449- sess.mu.Lock()
450- owned := sess.client == ac
451- sess.mu.Unlock()
452- if !owned || state.attachment != ac || ac.currentSession() != sess {
456+ entry.core().mu.Lock()
457+ owned := entry.core().client == ac
458+ entry.core().mu.Unlock()
459+ if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
453460 ac.sendMu.Unlock()
454461 return false
455462 }
456463 if state.lease != nil {
457- rc := sess.renderCoordinator()
464+ rc := attachmentRenderCoordinator(entry)
458465 if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
459466 ac.sendMu.Unlock()
460467 return false
@@ -465,20 +472,22 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
465472 endDiff(0, err == nil)
466473 if err != nil {
467474 ac.sendMu.Unlock()
468- d.log.Error("render draw failed", "err", err, "session", sess.name)
475+ d.log.Error("render draw failed", "err", err, "session", entry.core().name)
469476 // Without a coordinator reportError repaints synchronously. Suppress only
470477 // that nested notice repaint; leave the guard before returning so a later,
471478 // independent failed transaction can still notify the user.
472- if sess.renderCoordinator() == nil {
473- if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
479+ if sess, ok := localSession(entry); ok {
480+ if sess.renderCoordinator() == nil {
481+ if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
482+ return true
483+ }
484+ d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
485+ ac.prepareFailureFallback.Store(false)
474486 return true
475487 }
476488 d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
477- ac.prepareFailureFallback.Store(false)
478- return true
489+ d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
479490 }
480- d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
481- d.invalidateRender(sess, ac, true, "render_pipeline.go:prepare-failed")
482491 return true
483492 }
484493 data := append([]byte(nil), prepared.data...)
@@ -491,7 +500,9 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
491500 sendTransport := ac.transportSnapshot()
492501 sendTr = sendTransport.transport
493502 if ac.proxied {
494- sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)
503+ if sess, ok := localSession(entry); ok {
504+ sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)
505+ }
495506 }
496507 if len(data) > 0 {
497508 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
@@ -538,7 +549,9 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
538549 // admission first. Detachment freezes the gate and therefore cannot mutate
539550 // ownership until any enclosing admitted operation has also ended.
540551 if marks.roleEffect == nil {
541- d.detachOnSendError(sess, ac, sendTr)
552+ if sess, ok := localSession(entry); ok {
553+ d.detachOnSendError(sess, ac, sendTr)
554+ }
542555 } else {
543556 // Capture the exact admitted capability, including its coordinator
544557 // lease, before End permits a replacement publication. Reserve cleanup
@@ -417,18 +417,25 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte
417 // the next transaction.417 // the next transaction.
418 func commitDamageReceipts(receipts []damageReceipt) {418 func commitDamageReceipts(receipts []damageReceipt) {
419 for _, receipt := range receipts {419 for _, receipt := range receipts {
420- if receipt.pane == nil {420+ if receipt.pane != nil {
421+ receipt.pane.mu.Lock()
422+ receipt.pane.screen.AcknowledgeDamage(receipt.generation)
423+ receipt.pane.mu.Unlock()
421 continue424 continue
422 }425 }
423- receipt.pane.mu.Lock()426+ if receipt.proxy != nil {
424- receipt.pane.screen.AcknowledgeDamage(receipt.generation)427+ receipt.proxy.mu.Lock()
425- receipt.pane.mu.Unlock()428+ if receipt.proxy.screen == receipt.proxyScreen {
429+ receipt.proxy.screen.AcknowledgeDamage(receipt.generation)
430+ }
431+ receipt.proxy.mu.Unlock()
432+ }
426 }433 }
427 }434 }
428 435
429 // emitFrame is the sole side-effecting half of the pipeline. The caller holds436 // emitFrame is the sole side-effecting half of the pipeline. The caller holds
430 // sendMu for the complete capture/compose/emit transaction.437 // sendMu for the complete capture/compose/emit transaction.
431-func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {438+func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
432 var ownedMarks runtimeMarkBatch439 var ownedMarks runtimeMarkBatch
433 var marks *runtimeMarkBatch440 var marks *runtimeMarkBatch
434 if len(batches) != 0 {441 if len(batches) != 0 {
@@ -440,21 +447,21 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
440 // follows emitFrame's release of attachment ownership.447 // follows emitFrame's release of attachment ownership.
441 defer marks.flush()448 defer marks.flush()
442 }449 }
443- if sess == nil || ac == nil || state == nil {450+ if entry == nil || entry.core() == nil || ac == nil || state == nil {
444 if ac != nil {451 if ac != nil {
445 ac.sendMu.Unlock()452 ac.sendMu.Unlock()
446 }453 }
447 return false454 return false
448 }455 }
449- sess.mu.Lock()456+ entry.core().mu.Lock()
450- owned := sess.client == ac457+ owned := entry.core().client == ac
451- sess.mu.Unlock()458+ entry.core().mu.Unlock()
452- if !owned || state.attachment != ac || ac.currentSession() != sess {459+ if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
453 ac.sendMu.Unlock()460 ac.sendMu.Unlock()
454 return false461 return false
455 }462 }
456 if state.lease != nil {463 if state.lease != nil {
457- rc := sess.renderCoordinator()464+ rc := attachmentRenderCoordinator(entry)
458 if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {465 if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
459 ac.sendMu.Unlock()466 ac.sendMu.Unlock()
460 return false467 return false
@@ -465,20 +472,22 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
465 endDiff(0, err == nil)472 endDiff(0, err == nil)
466 if err != nil {473 if err != nil {
467 ac.sendMu.Unlock()474 ac.sendMu.Unlock()
468- d.log.Error("render draw failed", "err", err, "session", sess.name)475+ d.log.Error("render draw failed", "err", err, "session", entry.core().name)
469 // Without a coordinator reportError repaints synchronously. Suppress only476 // Without a coordinator reportError repaints synchronously. Suppress only
470 // that nested notice repaint; leave the guard before returning so a later,477 // that nested notice repaint; leave the guard before returning so a later,
471 // independent failed transaction can still notify the user.478 // independent failed transaction can still notify the user.
472- if sess.renderCoordinator() == nil {479+ if sess, ok := localSession(entry); ok {
473- if !ac.prepareFailureFallback.CompareAndSwap(false, true) {480+ if sess.renderCoordinator() == nil {
481+ if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
482+ return true
483+ }
484+ d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
485+ ac.prepareFailureFallback.Store(false)
474 return true486 return true
475 }487 }
476 d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))488 d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
477- ac.prepareFailureFallback.Store(false)489+ d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
478- return true
479 }490 }
480- d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
481- d.invalidateRender(sess, ac, true, "render_pipeline.go:prepare-failed")
482 return true491 return true
483 }492 }
484 data := append([]byte(nil), prepared.data...)493 data := append([]byte(nil), prepared.data...)
@@ -491,7 +500,9 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
491 sendTransport := ac.transportSnapshot()500 sendTransport := ac.transportSnapshot()
492 sendTr = sendTransport.transport501 sendTr = sendTransport.transport
493 if ac.proxied {502 if ac.proxied {
494- sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)503+ if sess, ok := localSession(entry); ok {
504+ sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)
505+ }
495 }506 }
496 if len(data) > 0 {507 if len(data) > 0 {
497 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))508 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
@@ -538,7 +549,9 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
538 // admission first. Detachment freezes the gate and therefore cannot mutate549 // admission first. Detachment freezes the gate and therefore cannot mutate
539 // ownership until any enclosing admitted operation has also ended.550 // ownership until any enclosing admitted operation has also ended.
540 if marks.roleEffect == nil {551 if marks.roleEffect == nil {
541- d.detachOnSendError(sess, ac, sendTr)552+ if sess, ok := localSession(entry); ok {
553+ d.detachOnSendError(sess, ac, sendTr)
554+ }
542 } else {555 } else {
543 // Capture the exact admitted capability, including its coordinator556 // Capture the exact admitted capability, including its coordinator
544 // lease, before End permits a replacement publication. Reserve cleanup557 // lease, before End permits a replacement publication. Reserve cleanup
M internal/usecase/daemon/status.go +31 -10
@@ -225,19 +225,40 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession
225225 // palette interaction has captured recent-session hints. Normal palette state
226226 // continues to use the canonical, live MRU list.
227227 func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
228- ranked := rankedRecentForHints(hints, recent)
229- if ranked != nil {
230- return d.barStateForContextual(cur, statusFeedback, ranked)
228+ var entry attachmentSession
229+ if cur != nil {
230+ entry = cur
231231 }
232- return d.barStateFor(cur, statusFeedback)
232+ return d.barStateForAttachmentPaletteHints(entry, statusFeedback, hints, recent)
233233 }
234234
235-// barStateForContextual composes ranks captured for the palette interaction.
236-// It deliberately does not call recentSessions: live MRU changes must not
237-// affect contextual rendering, and palette rendering must not acquire d.mu.
238-func (d *Daemon) barStateForContextual(cur *session, statusFeedback string, ranked []rankedRecent) barState {
239- state := d.barStateBase(cur, statusFeedback)
240- state.rankedRecent = ranked
235+// barStateForAttachmentPaletteHints composes daemon-owned chrome for either
236+// attachment implementation. Bar scripts remain local-session-only because
237+// their existing execution contract depends on local tabs and PTYs.
238+func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
239+ if attachmentSessionCore(cur) == nil {
240+ cur = nil
241+ }
242+ ranked := rankedRecentForHints(hints, recent)
243+ state := barState{statusFeedback: statusFeedback}
244+ if d != nil {
245+ state.attentionFrame = d.attentionFrame()
246+ }
247+ if cur != nil {
248+ includeTerminalTitle := true
249+ if d != nil {
250+ includeTerminalTitle = d.currentTabsConfig().TerminalTitle
251+ }
252+ state.status = cur.statusSegments(includeTerminalTitle)
253+ }
254+ if local, ok := localSession(cur); ok && d != nil {
255+ state.topRight, state.bottomRight = d.barScriptSnapshot(local)
256+ }
257+ if ranked != nil {
258+ state.rankedRecent = ranked
259+ } else if d != nil {
260+ state.mru = d.recentSessions(cur)
261+ }
241262 return state
242263 }
243264
@@ -225,19 +225,40 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession
225 // palette interaction has captured recent-session hints. Normal palette state225 // palette interaction has captured recent-session hints. Normal palette state
226 // continues to use the canonical, live MRU list.226 // continues to use the canonical, live MRU list.
227 func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {227 func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
228- ranked := rankedRecentForHints(hints, recent)228+ var entry attachmentSession
229- if ranked != nil {229+ if cur != nil {
230- return d.barStateForContextual(cur, statusFeedback, ranked)230+ entry = cur
231 }231 }
232- return d.barStateFor(cur, statusFeedback)232+ return d.barStateForAttachmentPaletteHints(entry, statusFeedback, hints, recent)
233 }233 }
234 234
235-// barStateForContextual composes ranks captured for the palette interaction.235+// barStateForAttachmentPaletteHints composes daemon-owned chrome for either
236-// It deliberately does not call recentSessions: live MRU changes must not236+// attachment implementation. Bar scripts remain local-session-only because
237-// affect contextual rendering, and palette rendering must not acquire d.mu.237+// their existing execution contract depends on local tabs and PTYs.
238-func (d *Daemon) barStateForContextual(cur *session, statusFeedback string, ranked []rankedRecent) barState {238+func (d *Daemon) barStateForAttachmentPaletteHints(cur attachmentSession, statusFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
239- state := d.barStateBase(cur, statusFeedback)239+ if attachmentSessionCore(cur) == nil {
240- state.rankedRecent = ranked240+ cur = nil
241+ }
242+ ranked := rankedRecentForHints(hints, recent)
243+ state := barState{statusFeedback: statusFeedback}
244+ if d != nil {
245+ state.attentionFrame = d.attentionFrame()
246+ }
247+ if cur != nil {
248+ includeTerminalTitle := true
249+ if d != nil {
250+ includeTerminalTitle = d.currentTabsConfig().TerminalTitle
251+ }
252+ state.status = cur.statusSegments(includeTerminalTitle)
253+ }
254+ if local, ok := localSession(cur); ok && d != nil {
255+ state.topRight, state.bottomRight = d.barScriptSnapshot(local)
256+ }
257+ if ranked != nil {
258+ state.rankedRecent = ranked
259+ } else if d != nil {
260+ state.mru = d.recentSessions(cur)
261+ }
241 return state262 return state
242 }263 }
243 264
M internal/usecase/daemon/status_test.go +47 -0
@@ -24,6 +24,19 @@ import (
2424
2525 // --- test doubles -----------------------------------------------------------
2626
27+type coreNilAttachment struct{}
28+
29+func (*coreNilAttachment) core() *sessionCore { return nil }
30+func (*coreNilAttachment) snapshotView(viewOptions) sessionView {
31+ return sessionView{}
32+}
33+func (*coreNilAttachment) statusSegments(bool) statusSnapshot { return statusSnapshot{} }
34+func (*coreNilAttachment) capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool) {
35+ return nil, false
36+}
37+func (*coreNilAttachment) activateTargetLocked(int) bool { return false }
38+func (*coreNilAttachment) isProxy() bool { return false }
39+
2740 // stubClock returns timers whose channel never fires, so a scheduler under it
2841 // blocks in its debounce loop until the session context is cancelled. Used by
2942
@@ -962,6 +975,40 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
962975 t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))
963976 }
964977
978+func TestLockAttachmentSessionsRejectsNilEntriesWithoutPanic(t *testing.T) {
979+ var nilEntry attachmentSession
980+ var typedNil *session
981+ valid := &session{sessionCore: sessionCore{id: "valid"}}
982+
983+ for _, tc := range []struct {
984+ name string
985+ a attachmentSession
986+ b attachmentSession
987+ }{
988+ {name: "nil interfaces", a: nilEntry, b: nilEntry},
989+ {name: "typed nil", a: typedNil, b: valid},
990+ {name: "core nil", a: &coreNilAttachment{}, b: valid},
991+ } {
992+ t.Run(tc.name, func(t *testing.T) {
993+ require.NotPanics(t, func() {
994+ unlock := lockAttachmentSessions(tc.a, tc.b)
995+ unlock()
996+ })
997+ })
998+ }
999+}
1000+
1001+func TestBarStateForPaletteHintsNormalizesTypedNilSession(t *testing.T) {
1002+ d := newTestDaemon(t, nil, stubClock{})
1003+ var sess *session
1004+
1005+ require.NotPanics(t, func() {
1006+ state := d.barStateForPaletteHints(sess, "feedback", nil, nil)
1007+ require.Equal(t, "feedback", state.statusFeedback)
1008+ require.Empty(t, state.status.session)
1009+ })
1010+}
1011+
9651012 func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.T) {
9661013 p, releasePTY := newBlockingPTY(t)
9671014 d, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -24,6 +24,19 @@ import (
24 24
25 // --- test doubles -----------------------------------------------------------25 // --- test doubles -----------------------------------------------------------
26 26
27+type coreNilAttachment struct{}
28+
29+func (*coreNilAttachment) core() *sessionCore { return nil }
30+func (*coreNilAttachment) snapshotView(viewOptions) sessionView {
31+ return sessionView{}
32+}
33+func (*coreNilAttachment) statusSegments(bool) statusSnapshot { return statusSnapshot{} }
34+func (*coreNilAttachment) capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool) {
35+ return nil, false
36+}
37+func (*coreNilAttachment) activateTargetLocked(int) bool { return false }
38+func (*coreNilAttachment) isProxy() bool { return false }
39+
27 // stubClock returns timers whose channel never fires, so a scheduler under it40 // stubClock returns timers whose channel never fires, so a scheduler under it
28 // blocks in its debounce loop until the session context is cancelled. Used by41 // blocks in its debounce loop until the session context is cancelled. Used by
29 42
@@ -962,6 +975,40 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
962 t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))975 t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))
963 }976 }
964 977
978+func TestLockAttachmentSessionsRejectsNilEntriesWithoutPanic(t *testing.T) {
979+ var nilEntry attachmentSession
980+ var typedNil *session
981+ valid := &session{sessionCore: sessionCore{id: "valid"}}
982+
983+ for _, tc := range []struct {
984+ name string
985+ a attachmentSession
986+ b attachmentSession
987+ }{
988+ {name: "nil interfaces", a: nilEntry, b: nilEntry},
989+ {name: "typed nil", a: typedNil, b: valid},
990+ {name: "core nil", a: &coreNilAttachment{}, b: valid},
991+ } {
992+ t.Run(tc.name, func(t *testing.T) {
993+ require.NotPanics(t, func() {
994+ unlock := lockAttachmentSessions(tc.a, tc.b)
995+ unlock()
996+ })
997+ })
998+ }
999+}
1000+
1001+func TestBarStateForPaletteHintsNormalizesTypedNilSession(t *testing.T) {
1002+ d := newTestDaemon(t, nil, stubClock{})
1003+ var sess *session
1004+
1005+ require.NotPanics(t, func() {
1006+ state := d.barStateForPaletteHints(sess, "feedback", nil, nil)
1007+ require.Equal(t, "feedback", state.statusFeedback)
1008+ require.Empty(t, state.status.session)
1009+ })
1010+}
1011+
965 func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.T) {1012 func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.T) {
966 p, releasePTY := newBlockingPTY(t)1013 p, releasePTY := newBlockingPTY(t)
967 d, sess, _, _ := newManualSessionWithPTYs(t, p)1014 d, sess, _, _ := newManualSessionWithPTYs(t, p)

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