detached 3f4b8b3

Merge pull request #159 from bnema/feat/proxy-session-core

feat(remote): add proxy session link and rendering
commit
3f4b8b33c77501ed129db025910c2f71da58999b
author
bnema · · 2026-07-31 15:27 UTC
committer
GitHub · · 2026-07-31 15:27 UTC
merge of
a832d60 + 845794a · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
22 files · +3107 -93
M internal/usecase/daemon/attachment_role.go +9 -0
@@ -4,6 +4,15 @@ import "github.com/bnema/vev/internal/ports"
44
55 type attachmentRole uint8
66
7+// attachmentSessionCore normalizes nil interfaces, typed-nil implementations,
8+// and implementations that deliberately expose no attachment core.
9+func attachmentSessionCore(entry attachmentSession) *sessionCore {
10+ if entry == nil {
11+ return nil
12+ }
13+ return entry.core()
14+}
15+
716 const (
817 attachmentDetached attachmentRole = iota
918 attachmentActive
@@ -4,6 +4,15 @@ import "github.com/bnema/vev/internal/ports"
4 4
5 type attachmentRole uint85 type attachmentRole uint8
6 6
7+// attachmentSessionCore normalizes nil interfaces, typed-nil implementations,
8+// and implementations that deliberately expose no attachment core.
9+func attachmentSessionCore(entry attachmentSession) *sessionCore {
10+ if entry == nil {
11+ return nil
12+ }
13+ return entry.core()
14+}
15+
7 const (16 const (
8 attachmentDetached attachmentRole = iota17 attachmentDetached attachmentRole = iota
9 attachmentActive18 attachmentActive
M internal/usecase/daemon/attachment_transition_publish.go +15 -12
@@ -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 ||
@@ -289,19 +287,24 @@ func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
289287 // lockAttachmentSessions gives every two-session transition one stable order.
290288 // Session IDs are immutable and unique while their lifecycles are registered.
291289 func lockAttachmentSessions(a, b attachmentSession) func() {
292- if a == b {
293- a.core().mu.Lock()
294- return a.core().mu.Unlock
290+ aCore := attachmentSessionCore(a)
291+ bCore := attachmentSessionCore(b)
292+ if aCore == nil || bCore == nil {
293+ return func() {}
294+ }
295+ if aCore == bCore {
296+ aCore.mu.Lock()
297+ return aCore.mu.Unlock
295298 }
296- first, second := a, b
297- if first.core().id > second.core().id {
299+ first, second := aCore, bCore
300+ if first.id > second.id {
298301 first, second = second, first
299302 }
300- first.core().mu.Lock()
301- second.core().mu.Lock()
303+ first.mu.Lock()
304+ second.mu.Lock()
302305 return func() {
303- second.core().mu.Unlock()
304- first.core().mu.Unlock()
306+ second.mu.Unlock()
307+ first.mu.Unlock()
305308 }
306309 }
307310
@@ -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 ||
@@ -289,19 +287,24 @@ func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
289 // lockAttachmentSessions gives every two-session transition one stable order.287 // lockAttachmentSessions gives every two-session transition one stable order.
290 // Session IDs are immutable and unique while their lifecycles are registered.288 // Session IDs are immutable and unique while their lifecycles are registered.
291 func lockAttachmentSessions(a, b attachmentSession) func() {289 func lockAttachmentSessions(a, b attachmentSession) func() {
292- if a == b {290+ aCore := attachmentSessionCore(a)
293- a.core().mu.Lock()291+ bCore := attachmentSessionCore(b)
294- return a.core().mu.Unlock292+ if aCore == nil || bCore == nil {
293+ return func() {}
294+ }
295+ if aCore == bCore {
296+ aCore.mu.Lock()
297+ return aCore.mu.Unlock
295 }298 }
296- first, second := a, b299+ first, second := aCore, bCore
297- if first.core().id > second.core().id {300+ if first.id > second.id {
298 first, second = second, first301 first, second = second, first
299 }302 }
300- first.core().mu.Lock()303+ first.mu.Lock()
301- second.core().mu.Lock()304+ second.mu.Lock()
302 return func() {305 return func() {
303- second.core().mu.Unlock()306+ second.mu.Unlock()
304- first.core().mu.Unlock()307+ first.mu.Unlock()
305 }308 }
306 }309 }
307 310
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:
M internal/usecase/daemon/client_transport_errors.go +28 -0
@@ -107,6 +107,34 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
107107 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
108108 }
109109
110+// detachProxyOnSendError is detachOnSendError for an attachment that has no
111+// local session behind it. Proxy attachments own no resumable parking
112+// lifecycle, so the remote link is retained as a warm headless session instead
113+// of being parked for the same client to resume.
114+func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, failed ports.Transport) {
115+ if d == nil || p == nil || ac == nil {
116+ return
117+ }
118+ expected := transportSnapshot{}
119+ if failed != nil {
120+ expected = ac.transportSnapshot()
121+ if expected.transport != failed {
122+ return
123+ }
124+ }
125+ if !d.detachProxyIfCurrentTransport(p, ac, expected) {
126+ return
127+ }
128+ if rc := p.coordinator.Load(); rc != nil {
129+ rc.noteDetach(ac)
130+ }
131+ d.unregisterPreview(ac)
132+ ac.clearPreviousSession()
133+ _ = ac.closeCapturedTransport(failed)
134+ d.armProxyWarm(p)
135+ d.log.Warn("detached proxy client after send error", "host", p.key.Host, "session", p.key.Name)
136+}
137+
110138 func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {
111139 d.detachOnRoleSendErrorUntil(token, failed, nil)
112140 }
@@ -107,6 +107,34 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
107 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)107 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
108 }108 }
109 109
110+// detachProxyOnSendError is detachOnSendError for an attachment that has no
111+// local session behind it. Proxy attachments own no resumable parking
112+// lifecycle, so the remote link is retained as a warm headless session instead
113+// of being parked for the same client to resume.
114+func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, failed ports.Transport) {
115+ if d == nil || p == nil || ac == nil {
116+ return
117+ }
118+ expected := transportSnapshot{}
119+ if failed != nil {
120+ expected = ac.transportSnapshot()
121+ if expected.transport != failed {
122+ return
123+ }
124+ }
125+ if !d.detachProxyIfCurrentTransport(p, ac, expected) {
126+ return
127+ }
128+ if rc := p.coordinator.Load(); rc != nil {
129+ rc.noteDetach(ac)
130+ }
131+ d.unregisterPreview(ac)
132+ ac.clearPreviousSession()
133+ _ = ac.closeCapturedTransport(failed)
134+ d.armProxyWarm(p)
135+ d.log.Warn("detached proxy client after send error", "host", p.key.Host, "session", p.key.Name)
136+}
137+
110 func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {138 func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {
111 d.detachOnRoleSendErrorUntil(token, failed, nil)139 d.detachOnRoleSendErrorUntil(token, failed, nil)
112 }140 }
M internal/usecase/daemon/daemon.go +4 -1
@@ -73,7 +73,10 @@ type Daemon struct {
7373 stopped map[string]stoppedSession
7474 // creating reserves names while durable creation I/O runs without mu.
7575 creating map[string]struct{}
76- nextID uint64
76+ // proxyConstructions serializes remote IntentAttach handshakes by structured
77+ // key. Entries are guarded by mu and never retain it across dial or link I/O.
78+ proxyConstructions map[domain.RemoteSessionKey]*proxyConstruction
79+ nextID uint64
7780 // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
7881 // mark. It is guarded by mu and prevents a wall-clock regression from
7982 // reusing a lifecycle identity.
@@ -73,7 +73,10 @@ type Daemon struct {
73 stopped map[string]stoppedSession73 stopped map[string]stoppedSession
74 // creating reserves names while durable creation I/O runs without mu.74 // creating reserves names while durable creation I/O runs without mu.
75 creating map[string]struct{}75 creating map[string]struct{}
76- nextID uint6476+ // proxyConstructions serializes remote IntentAttach handshakes by structured
77+ // key. Entries are guarded by mu and never retain it across dial or link I/O.
78+ proxyConstructions map[domain.RemoteSessionKey]*proxyConstruction
79+ nextID uint64
77 // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water80 // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
78 // mark. It is guarded by mu and prevents a wall-clock regression from81 // mark. It is guarded by mu and prevents a wall-clock regression from
79 // reusing a lifecycle identity.82 // reusing a lifecycle identity.
A internal/usecase/daemon/proxy_lifecycle.go +294 -0
@@ -0,0 +1,294 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+// Proxy lifecycle owns the dormant-expiry timer and the link-state publication
11+// of one remote attachment. It extends the package lock ordering documented at
12+// the top of client.go: every function in this file acquires
13+// d.mu -> p.sessionCore.mu -> p.mu, in that order and never any other, and
14+// holds them only to validate and publish state. Clock, Timer, transport,
15+// coordinator, and repaint work always runs after all three are released.
16+
17+const proxyWarmDuration = 5 * time.Minute
18+
19+// proxyWarmTimer is an exact lifecycle capability. Both pointer identity and
20+// generation must still match before its callback may remove a registry entry.
21+type proxyWarmTimer struct {
22+ generation uint64
23+ timer ports.Timer
24+ cancel chan struct{}
25+ done chan struct{}
26+ cancelOnce sync.Once
27+}
28+
29+func (t *proxyWarmTimer) stop() {
30+ if t == nil {
31+ return
32+ }
33+ if t.timer != nil {
34+ t.timer.Stop()
35+ }
36+ t.cancelOnce.Do(func() { close(t.cancel) })
37+}
38+
39+// armProxyWarm reserves and publishes a fresh five-minute timer only while the
40+// exact proxy remains registered and headless. Clock and Timer methods are
41+// external operations and therefore run outside architecture locks.
42+//
43+// The lifecycle generation is bumped and the incumbent token released only in
44+// phase 2, atomically with publishing the replacement. A failed revalidation
45+// therefore leaves the already-armed token both installed and current, so the
46+// proxy can never be left registered without a path out of the live registry.
47+func (d *Daemon) armProxyWarm(p *proxySession) bool {
48+ if d == nil || p == nil || d.clock == nil {
49+ return false
50+ }
51+ d.mu.Lock()
52+ if d.sessions[p.id] != p || d.closing {
53+ d.mu.Unlock()
54+ return false
55+ }
56+ p.sessionCore.mu.Lock()
57+ if p.client != nil {
58+ p.sessionCore.mu.Unlock()
59+ d.mu.Unlock()
60+ return false
61+ }
62+ p.mu.Lock()
63+ generation := p.generation
64+ old := p.warm
65+ p.mu.Unlock()
66+ p.sessionCore.mu.Unlock()
67+ d.mu.Unlock()
68+
69+ timer := d.clock.NewTimer(proxyWarmDuration)
70+ if timer == nil {
71+ return false
72+ }
73+ token := &proxyWarmTimer{
74+ timer: timer,
75+ cancel: make(chan struct{}),
76+ done: make(chan struct{}),
77+ }
78+
79+ d.mu.Lock()
80+ valid := d.sessions[p.id] == p && !d.closing
81+ if valid {
82+ p.sessionCore.mu.Lock()
83+ valid = p.client == nil
84+ if valid {
85+ p.mu.Lock()
86+ valid = p.generation == generation && p.warm == old
87+ if valid {
88+ p.generation++
89+ token.generation = p.generation
90+ p.warm = token
91+ }
92+ p.mu.Unlock()
93+ }
94+ p.sessionCore.mu.Unlock()
95+ }
96+ d.mu.Unlock()
97+ if !valid {
98+ timer.Stop()
99+ close(token.done)
100+ return false
101+ }
102+ // The incumbent token is retired only once its successor owns the exact
103+ // lifecycle generation, never before.
104+ old.stop()
105+
106+ go func() {
107+ defer close(token.done)
108+ select {
109+ case <-timer.C():
110+ d.expireWarmProxy(p, token)
111+ case <-token.cancel:
112+ }
113+ }()
114+ return true
115+}
116+
117+// expireWarmProxy removes only the exact dormant proxy pointer and lifecycle
118+// generation. The canonical d.mu -> core.mu -> proxy.mu order is held only for
119+// validation/publication; cancellation, coordinator teardown, transport close,
120+// and daemon completion happen afterward.
121+func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool {
122+ if d == nil || p == nil || token == nil {
123+ return false
124+ }
125+ d.mu.Lock()
126+ if d.sessions[p.id] != p {
127+ d.mu.Unlock()
128+ return false
129+ }
130+ p.sessionCore.mu.Lock()
131+ if p.client != nil {
132+ p.sessionCore.mu.Unlock()
133+ d.mu.Unlock()
134+ return false
135+ }
136+ p.mu.Lock()
137+ if p.warm != token || p.generation != token.generation {
138+ p.mu.Unlock()
139+ p.sessionCore.mu.Unlock()
140+ d.mu.Unlock()
141+ return false
142+ }
143+ // Removal from the registry is what commits this expiry, so it is attempted
144+ // before any terminal lifecycle state is published. unregisterSessionLocked
145+ // only reads d.sessions, which this call already owns; it acquires no lock.
146+ if !d.unregisterSessionLocked(p) {
147+ p.mu.Unlock()
148+ p.sessionCore.mu.Unlock()
149+ d.mu.Unlock()
150+ return false
151+ }
152+ p.warm = nil
153+ p.generation++
154+ p.expired = true
155+ cancel := p.cancel
156+ transport := p.transport
157+ coordinator := p.coordinator.Load()
158+ p.mu.Unlock()
159+ p.sessionCore.mu.Unlock()
160+ empty := len(d.sessions) == 0
161+ if empty {
162+ d.closing = true
163+ }
164+ d.mu.Unlock()
165+
166+ if cancel != nil {
167+ cancel()
168+ }
169+ if transport != nil {
170+ _ = transport.Close()
171+ }
172+ if coordinator != nil {
173+ coordinator.beginSessionTeardown().finish()
174+ coordinator.waitForTimerWorkers()
175+ }
176+ p.finish()
177+ if empty {
178+ d.doneOnce.Do(func() { close(d.done) })
179+ }
180+ return true
181+}
182+
183+// withProxyLocked evaluates one lifecycle predicate under the canonical
184+// d.mu -> p.sessionCore.mu -> p.mu order, but only while p is still the exact
185+// registered session. update owns every state mutation it wants published and
186+// reports whether it applied. Repaint is deliberately after all architecture
187+// locks have been released.
188+func (d *Daemon) withProxyLocked(p *proxySession, update func() bool) bool {
189+ if d == nil || p == nil {
190+ return false
191+ }
192+ d.mu.Lock()
193+ if d.sessions[p.id] != p {
194+ d.mu.Unlock()
195+ return false
196+ }
197+ p.sessionCore.mu.Lock()
198+ p.mu.Lock()
199+ current := update()
200+ ac := p.client
201+ p.mu.Unlock()
202+ p.sessionCore.mu.Unlock()
203+ d.mu.Unlock()
204+ if current && ac != nil {
205+ d.invalidateRender(p, ac, false, "proxy_lifecycle.go")
206+ }
207+ return current
208+}
209+
210+// updateProxyLinkState publishes one transport callback only if the exact proxy,
211+// link generation, and transport remain current.
212+func (d *Daemon) updateProxyLinkState(p *proxySession, generation uint64, transport ports.Transport, state ports.LinkState) bool {
213+ if d == nil || p == nil || transport == nil {
214+ return false
215+ }
216+ return d.withProxyLocked(p, func() bool {
217+ current := !p.expired && p.linkGeneration == generation && p.transport == transport
218+ if current {
219+ p.linkState = state
220+ }
221+ return current
222+ })
223+}
224+
225+// updateProxyDisconnectedState publishes a state after the exact generation's
226+// transport has retired. It is used while resume dialing is pending or failed.
227+func (d *Daemon) updateProxyDisconnectedState(p *proxySession, generation uint64, state ports.LinkState) bool {
228+ if d == nil || p == nil {
229+ return false
230+ }
231+ return d.withProxyLocked(p, func() bool {
232+ current := !p.expired && p.linkGeneration == generation && p.transport == nil
233+ if current {
234+ p.linkState = state
235+ }
236+ return current
237+ })
238+}
239+
240+func (d *Daemon) repaintProxyLifecycle(p *proxySession) {
241+ d.withProxyLocked(p, func() bool {
242+ return !p.expired && p.transport != nil
243+ })
244+}
245+
246+// markProxyReplaced makes ReasonReplaced terminal for this exact remote link.
247+// Once published, no later state callback or resume attempt can revive it. An
248+// already-armed dormant expiry remains the lifecycle capability for this exact
249+// proxy and generation; replacement must not invalidate or clear that only path
250+// out of the live registry. A dormant proxy without a timer is armed afterward,
251+// outside every architecture lock.
252+func (d *Daemon) markProxyReplaced(p *proxySession, generation uint64, transport ports.Transport) bool {
253+ if d == nil || p == nil || transport == nil {
254+ return false
255+ }
256+ // Arming and repainting are mutually exclusive: needsWarm requires a headless
257+ // proxy, and the helper only repaints an attached one. Deferring the arm until
258+ // after the helper's repaint therefore preserves the published order.
259+ var needsWarm bool
260+ current := d.withProxyLocked(p, func() bool {
261+ current := p.linkGeneration == generation && p.transport == transport
262+ if current {
263+ p.expired = true
264+ p.linkState = ports.LinkStateDead
265+ p.generation++
266+ if p.warm != nil {
267+ // Rekey the already-published exact timer instead of stopping it. This
268+ // preserves its deadline while invalidating older lifecycle observers.
269+ p.warm.generation = p.generation
270+ }
271+ }
272+ needsWarm = current && p.client == nil && p.warm == nil
273+ return current
274+ })
275+ if needsWarm {
276+ d.armProxyWarm(p)
277+ }
278+ return current
279+}
280+
281+func (p *proxySession) lifecycleDisplayName() string {
282+ if p == nil {
283+ return ""
284+ }
285+ p.mu.Lock()
286+ defer p.mu.Unlock()
287+ if p.expired {
288+ return p.name + " [expired]"
289+ }
290+ if p.linkState == ports.LinkStateConnected {
291+ return p.name
292+ }
293+ return p.name + " [" + p.linkState.String() + "]"
294+}
@@ -0,0 +1,294 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+// Proxy lifecycle owns the dormant-expiry timer and the link-state publication
11+// of one remote attachment. It extends the package lock ordering documented at
12+// the top of client.go: every function in this file acquires
13+// d.mu -> p.sessionCore.mu -> p.mu, in that order and never any other, and
14+// holds them only to validate and publish state. Clock, Timer, transport,
15+// coordinator, and repaint work always runs after all three are released.
16+
17+const proxyWarmDuration = 5 * time.Minute
18+
19+// proxyWarmTimer is an exact lifecycle capability. Both pointer identity and
20+// generation must still match before its callback may remove a registry entry.
21+type proxyWarmTimer struct {
22+ generation uint64
23+ timer ports.Timer
24+ cancel chan struct{}
25+ done chan struct{}
26+ cancelOnce sync.Once
27+}
28+
29+func (t *proxyWarmTimer) stop() {
30+ if t == nil {
31+ return
32+ }
33+ if t.timer != nil {
34+ t.timer.Stop()
35+ }
36+ t.cancelOnce.Do(func() { close(t.cancel) })
37+}
38+
39+// armProxyWarm reserves and publishes a fresh five-minute timer only while the
40+// exact proxy remains registered and headless. Clock and Timer methods are
41+// external operations and therefore run outside architecture locks.
42+//
43+// The lifecycle generation is bumped and the incumbent token released only in
44+// phase 2, atomically with publishing the replacement. A failed revalidation
45+// therefore leaves the already-armed token both installed and current, so the
46+// proxy can never be left registered without a path out of the live registry.
47+func (d *Daemon) armProxyWarm(p *proxySession) bool {
48+ if d == nil || p == nil || d.clock == nil {
49+ return false
50+ }
51+ d.mu.Lock()
52+ if d.sessions[p.id] != p || d.closing {
53+ d.mu.Unlock()
54+ return false
55+ }
56+ p.sessionCore.mu.Lock()
57+ if p.client != nil {
58+ p.sessionCore.mu.Unlock()
59+ d.mu.Unlock()
60+ return false
61+ }
62+ p.mu.Lock()
63+ generation := p.generation
64+ old := p.warm
65+ p.mu.Unlock()
66+ p.sessionCore.mu.Unlock()
67+ d.mu.Unlock()
68+
69+ timer := d.clock.NewTimer(proxyWarmDuration)
70+ if timer == nil {
71+ return false
72+ }
73+ token := &proxyWarmTimer{
74+ timer: timer,
75+ cancel: make(chan struct{}),
76+ done: make(chan struct{}),
77+ }
78+
79+ d.mu.Lock()
80+ valid := d.sessions[p.id] == p && !d.closing
81+ if valid {
82+ p.sessionCore.mu.Lock()
83+ valid = p.client == nil
84+ if valid {
85+ p.mu.Lock()
86+ valid = p.generation == generation && p.warm == old
87+ if valid {
88+ p.generation++
89+ token.generation = p.generation
90+ p.warm = token
91+ }
92+ p.mu.Unlock()
93+ }
94+ p.sessionCore.mu.Unlock()
95+ }
96+ d.mu.Unlock()
97+ if !valid {
98+ timer.Stop()
99+ close(token.done)
100+ return false
101+ }
102+ // The incumbent token is retired only once its successor owns the exact
103+ // lifecycle generation, never before.
104+ old.stop()
105+
106+ go func() {
107+ defer close(token.done)
108+ select {
109+ case <-timer.C():
110+ d.expireWarmProxy(p, token)
111+ case <-token.cancel:
112+ }
113+ }()
114+ return true
115+}
116+
117+// expireWarmProxy removes only the exact dormant proxy pointer and lifecycle
118+// generation. The canonical d.mu -> core.mu -> proxy.mu order is held only for
119+// validation/publication; cancellation, coordinator teardown, transport close,
120+// and daemon completion happen afterward.
121+func (d *Daemon) expireWarmProxy(p *proxySession, token *proxyWarmTimer) bool {
122+ if d == nil || p == nil || token == nil {
123+ return false
124+ }
125+ d.mu.Lock()
126+ if d.sessions[p.id] != p {
127+ d.mu.Unlock()
128+ return false
129+ }
130+ p.sessionCore.mu.Lock()
131+ if p.client != nil {
132+ p.sessionCore.mu.Unlock()
133+ d.mu.Unlock()
134+ return false
135+ }
136+ p.mu.Lock()
137+ if p.warm != token || p.generation != token.generation {
138+ p.mu.Unlock()
139+ p.sessionCore.mu.Unlock()
140+ d.mu.Unlock()
141+ return false
142+ }
143+ // Removal from the registry is what commits this expiry, so it is attempted
144+ // before any terminal lifecycle state is published. unregisterSessionLocked
145+ // only reads d.sessions, which this call already owns; it acquires no lock.
146+ if !d.unregisterSessionLocked(p) {
147+ p.mu.Unlock()
148+ p.sessionCore.mu.Unlock()
149+ d.mu.Unlock()
150+ return false
151+ }
152+ p.warm = nil
153+ p.generation++
154+ p.expired = true
155+ cancel := p.cancel
156+ transport := p.transport
157+ coordinator := p.coordinator.Load()
158+ p.mu.Unlock()
159+ p.sessionCore.mu.Unlock()
160+ empty := len(d.sessions) == 0
161+ if empty {
162+ d.closing = true
163+ }
164+ d.mu.Unlock()
165+
166+ if cancel != nil {
167+ cancel()
168+ }
169+ if transport != nil {
170+ _ = transport.Close()
171+ }
172+ if coordinator != nil {
173+ coordinator.beginSessionTeardown().finish()
174+ coordinator.waitForTimerWorkers()
175+ }
176+ p.finish()
177+ if empty {
178+ d.doneOnce.Do(func() { close(d.done) })
179+ }
180+ return true
181+}
182+
183+// withProxyLocked evaluates one lifecycle predicate under the canonical
184+// d.mu -> p.sessionCore.mu -> p.mu order, but only while p is still the exact
185+// registered session. update owns every state mutation it wants published and
186+// reports whether it applied. Repaint is deliberately after all architecture
187+// locks have been released.
188+func (d *Daemon) withProxyLocked(p *proxySession, update func() bool) bool {
189+ if d == nil || p == nil {
190+ return false
191+ }
192+ d.mu.Lock()
193+ if d.sessions[p.id] != p {
194+ d.mu.Unlock()
195+ return false
196+ }
197+ p.sessionCore.mu.Lock()
198+ p.mu.Lock()
199+ current := update()
200+ ac := p.client
201+ p.mu.Unlock()
202+ p.sessionCore.mu.Unlock()
203+ d.mu.Unlock()
204+ if current && ac != nil {
205+ d.invalidateRender(p, ac, false, "proxy_lifecycle.go")
206+ }
207+ return current
208+}
209+
210+// updateProxyLinkState publishes one transport callback only if the exact proxy,
211+// link generation, and transport remain current.
212+func (d *Daemon) updateProxyLinkState(p *proxySession, generation uint64, transport ports.Transport, state ports.LinkState) bool {
213+ if d == nil || p == nil || transport == nil {
214+ return false
215+ }
216+ return d.withProxyLocked(p, func() bool {
217+ current := !p.expired && p.linkGeneration == generation && p.transport == transport
218+ if current {
219+ p.linkState = state
220+ }
221+ return current
222+ })
223+}
224+
225+// updateProxyDisconnectedState publishes a state after the exact generation's
226+// transport has retired. It is used while resume dialing is pending or failed.
227+func (d *Daemon) updateProxyDisconnectedState(p *proxySession, generation uint64, state ports.LinkState) bool {
228+ if d == nil || p == nil {
229+ return false
230+ }
231+ return d.withProxyLocked(p, func() bool {
232+ current := !p.expired && p.linkGeneration == generation && p.transport == nil
233+ if current {
234+ p.linkState = state
235+ }
236+ return current
237+ })
238+}
239+
240+func (d *Daemon) repaintProxyLifecycle(p *proxySession) {
241+ d.withProxyLocked(p, func() bool {
242+ return !p.expired && p.transport != nil
243+ })
244+}
245+
246+// markProxyReplaced makes ReasonReplaced terminal for this exact remote link.
247+// Once published, no later state callback or resume attempt can revive it. An
248+// already-armed dormant expiry remains the lifecycle capability for this exact
249+// proxy and generation; replacement must not invalidate or clear that only path
250+// out of the live registry. A dormant proxy without a timer is armed afterward,
251+// outside every architecture lock.
252+func (d *Daemon) markProxyReplaced(p *proxySession, generation uint64, transport ports.Transport) bool {
253+ if d == nil || p == nil || transport == nil {
254+ return false
255+ }
256+ // Arming and repainting are mutually exclusive: needsWarm requires a headless
257+ // proxy, and the helper only repaints an attached one. Deferring the arm until
258+ // after the helper's repaint therefore preserves the published order.
259+ var needsWarm bool
260+ current := d.withProxyLocked(p, func() bool {
261+ current := p.linkGeneration == generation && p.transport == transport
262+ if current {
263+ p.expired = true
264+ p.linkState = ports.LinkStateDead
265+ p.generation++
266+ if p.warm != nil {
267+ // Rekey the already-published exact timer instead of stopping it. This
268+ // preserves its deadline while invalidating older lifecycle observers.
269+ p.warm.generation = p.generation
270+ }
271+ }
272+ needsWarm = current && p.client == nil && p.warm == nil
273+ return current
274+ })
275+ if needsWarm {
276+ d.armProxyWarm(p)
277+ }
278+ return current
279+}
280+
281+func (p *proxySession) lifecycleDisplayName() string {
282+ if p == nil {
283+ return ""
284+ }
285+ p.mu.Lock()
286+ defer p.mu.Unlock()
287+ if p.expired {
288+ return p.name + " [expired]"
289+ }
290+ if p.linkState == ports.LinkStateConnected {
291+ return p.name
292+ }
293+ return p.name + " [" + p.linkState.String() + "]"
294+}
A internal/usecase/daemon/proxy_lifecycle_test.go +270 -0
@@ -0,0 +1,270 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// warmHookClock runs a one-shot hook while armProxyWarm sits between its
14+// reservation and its publication, so a lifecycle race can be interleaved
15+// deterministically instead of being waited for.
16+type warmHookClock struct {
17+ timers chan *signalTimer
18+
19+ mu sync.Mutex
20+ hook func()
21+}
22+
23+func (c *warmHookClock) Now() time.Time { return time.Time{} }
24+
25+func (c *warmHookClock) NewTimer(d time.Duration) ports.Timer {
26+ timer := &signalTimer{ch: make(chan time.Time, 1), duration: d}
27+ c.mu.Lock()
28+ hook := c.hook
29+ c.hook = nil
30+ c.mu.Unlock()
31+ if hook != nil {
32+ hook()
33+ }
34+ if c.timers != nil {
35+ c.timers <- timer
36+ }
37+ return timer
38+}
39+
40+func (c *warmHookClock) setHook(hook func()) {
41+ c.mu.Lock()
42+ c.hook = hook
43+ c.mu.Unlock()
44+}
45+
46+func registerLifecycleProxy(t *testing.T, d *Daemon, key domain.RemoteSessionKey) *proxySession {
47+ t.Helper()
48+ p, err := newProxySession(key, domain.Size{Cols: 80, Rows: 24})
49+ require.NoError(t, err)
50+ d.mu.Lock()
51+ registered := d.registerSessionLocked(p)
52+ d.mu.Unlock()
53+ require.True(t, registered, "lifecycle fixture proxy was not published")
54+ return p
55+}
56+
57+func newProxyLifecycleFixture(t *testing.T) (*Daemon, *proxySession, *warmHookClock) {
58+ t.Helper()
59+ clock := &warmHookClock{timers: make(chan *signalTimer, 4)}
60+ d := newProxyTestDaemon(t, nil, clock)
61+ return d, registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "work"}), clock
62+}
63+
64+func proxyWarmToken(p *proxySession) (*proxyWarmTimer, uint64) {
65+ p.mu.Lock()
66+ defer p.mu.Unlock()
67+ return p.warm, p.generation
68+}
69+
70+func setProxyLifecycleClient(p *proxySession, ac *attachedClient) {
71+ p.sessionCore.mu.Lock()
72+ p.client = ac
73+ p.sessionCore.mu.Unlock()
74+}
75+
76+func proxyRegistered(d *Daemon, p *proxySession) bool {
77+ d.mu.Lock()
78+ defer d.mu.Unlock()
79+ return d.sessions[p.id] == p
80+}
81+
82+func proxyExpired(p *proxySession) bool {
83+ p.mu.Lock()
84+ defer p.mu.Unlock()
85+ return p.expired
86+}
87+
88+func armWarmTimer(t *testing.T, d *Daemon, p *proxySession, clock *warmHookClock) (*proxyWarmTimer, *signalTimer) {
89+ t.Helper()
90+ require.True(t, d.armProxyWarm(p), "warm timer was not published")
91+ timer := awaitTestValue(t, clock.timers, "warm timer was never created")
92+ token, _ := proxyWarmToken(p)
93+ require.NotNil(t, token, "published arm left no warm token")
94+ return token, timer
95+}
96+
97+// TestArmProxyWarmKeepsExpiryPathWhenPublicationFails pins the two-phase arm:
98+// a re-arm that loses its revalidation must leave the incumbent timer both
99+// installed and current, otherwise a registered proxy is left with no path out
100+// of the live registry.
101+func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
102+ tests := []struct {
103+ name string
104+ race func(d *Daemon, p *proxySession)
105+ undo func(d *Daemon, p *proxySession)
106+ }{
107+ {
108+ name: "client attaches during arm",
109+ race: func(_ *Daemon, p *proxySession) { setProxyLifecycleClient(p, &attachedClient{}) },
110+ undo: func(_ *Daemon, p *proxySession) { setProxyLifecycleClient(p, nil) },
111+ },
112+ {
113+ name: "replacement rekeys the lifecycle generation",
114+ race: func(_ *Daemon, p *proxySession) {
115+ p.mu.Lock()
116+ p.expired = true
117+ p.generation++
118+ if p.warm != nil {
119+ p.warm.generation = p.generation
120+ }
121+ p.mu.Unlock()
122+ },
123+ },
124+ {
125+ name: "daemon starts closing during arm",
126+ race: func(d *Daemon, _ *proxySession) {
127+ d.mu.Lock()
128+ d.closing = true
129+ d.mu.Unlock()
130+ },
131+ undo: func(d *Daemon, _ *proxySession) {
132+ d.mu.Lock()
133+ d.closing = false
134+ d.mu.Unlock()
135+ },
136+ },
137+ }
138+ for _, tt := range tests {
139+ t.Run(tt.name, func(t *testing.T) {
140+ d, p, clock := newProxyLifecycleFixture(t)
141+ token, timer := armWarmTimer(t, d, p, clock)
142+
143+ clock.setHook(func() { tt.race(d, p) })
144+ require.False(t, d.armProxyWarm(p), "raced re-arm must not publish a replacement")
145+ awaitTestValue(t, clock.timers, "raced re-arm never reached its clock")
146+
147+ retained, generation := proxyWarmToken(p)
148+ require.NotNil(t, retained, "failed re-arm stripped the proxy of its only expiry path")
149+ require.Same(t, token, retained, "failed re-arm replaced the armed expiry timer")
150+ require.Equal(t, generation, retained.generation, "retained timer no longer owns the lifecycle generation")
151+ require.True(t, proxyRegistered(d, p), "a failed re-arm must not unregister the proxy")
152+
153+ if tt.undo != nil {
154+ tt.undo(d, p)
155+ }
156+ timer.ch <- time.Time{}
157+ awaitTestCompletion(t, token.done, "retained warm timer never ran to completion")
158+ require.False(t, proxyRegistered(d, p), "retained warm timer did not expire the dormant proxy")
159+ require.True(t, proxyExpired(p), "expiry did not publish terminal lifecycle state")
160+ awaitTestCompletion(t, d.done, "last proxy expiry did not complete the daemon")
161+ })
162+ }
163+}
164+
165+func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
166+ t.Run("rekeys an already armed timer", func(t *testing.T) {
167+ d, p, clock := newProxyLifecycleFixture(t)
168+ transport := newProxyTestTransport()
169+ t.Cleanup(func() { _ = transport.Close() })
170+ generation, _ := p.installTransport(transport)
171+ token, timer := armWarmTimer(t, d, p, clock)
172+
173+ require.True(t, d.markProxyReplaced(p, generation, transport))
174+ require.Empty(t, clock.timers, "replacement armed a second timer instead of rekeying")
175+
176+ rekeyed, lifecycle := proxyWarmToken(p)
177+ require.Same(t, token, rekeyed, "replacement replaced the armed timer")
178+ require.Equal(t, lifecycle, rekeyed.generation, "replacement did not rekey the armed timer")
179+ require.True(t, proxyExpired(p))
180+ p.mu.Lock()
181+ state := p.linkState
182+ p.mu.Unlock()
183+ require.Equal(t, ports.LinkStateDead, state)
184+
185+ timer.ch <- time.Time{}
186+ awaitTestCompletion(t, token.done, "rekeyed warm timer never ran to completion")
187+ require.False(t, proxyRegistered(d, p), "rekeyed warm timer did not expire the proxy")
188+ })
189+
190+ t.Run("arms an expiry for a headless proxy", func(t *testing.T) {
191+ d, p, clock := newProxyLifecycleFixture(t)
192+ transport := newProxyTestTransport()
193+ t.Cleanup(func() { _ = transport.Close() })
194+ generation, _ := p.installTransport(transport)
195+
196+ require.True(t, d.markProxyReplaced(p, generation, transport))
197+ timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer")
198+ token, lifecycle := proxyWarmToken(p)
199+ require.NotNil(t, token, "replaced headless proxy has no expiry path")
200+ require.Equal(t, lifecycle, token.generation)
201+
202+ timer.ch <- time.Time{}
203+ awaitTestCompletion(t, token.done, "warm timer never ran to completion")
204+ require.False(t, proxyRegistered(d, p))
205+ })
206+
207+ t.Run("stale link generation publishes nothing", func(t *testing.T) {
208+ d, p, _ := newProxyLifecycleFixture(t)
209+ transport := newProxyTestTransport()
210+ t.Cleanup(func() { _ = transport.Close() })
211+ generation, _ := p.installTransport(transport)
212+
213+ require.False(t, d.markProxyReplaced(p, generation-1, transport))
214+ require.False(t, proxyExpired(p))
215+ warm, _ := proxyWarmToken(p)
216+ require.Nil(t, warm, "a rejected replacement must not arm an expiry")
217+ })
218+}
219+
220+func TestExpireWarmProxyCompletesDaemonOnlyOnLastSession(t *testing.T) {
221+ t.Run("last session completes the daemon", func(t *testing.T) {
222+ d, p, clock := newProxyLifecycleFixture(t)
223+ token, timer := armWarmTimer(t, d, p, clock)
224+
225+ timer.ch <- time.Time{}
226+ awaitTestCompletion(t, token.done, "warm timer never ran to completion")
227+ awaitTestCompletion(t, p.done, "expiry did not finish the proxy")
228+ awaitTestCompletion(t, d.done, "last session expiry did not complete the daemon")
229+ require.False(t, proxyRegistered(d, p))
230+ require.True(t, proxyExpired(p))
231+ })
232+
233+ t.Run("surviving session keeps the daemon running", func(t *testing.T) {
234+ d, p, clock := newProxyLifecycleFixture(t)
235+ survivor := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "other"})
236+ token, timer := armWarmTimer(t, d, p, clock)
237+
238+ timer.ch <- time.Time{}
239+ awaitTestCompletion(t, token.done, "warm timer never ran to completion")
240+ require.False(t, proxyRegistered(d, p))
241+ require.True(t, proxyRegistered(d, survivor))
242+ select {
243+ case <-d.done:
244+ t.Fatal("daemon completed while a session was still registered")
245+ default:
246+ }
247+ })
248+}
249+
250+// TestExpireWarmProxyIgnoresSupersededToken proves the expiry callback is exact:
251+// a token that lost its lifecycle generation must publish no terminal state and
252+// must not unregister the proxy.
253+func TestExpireWarmProxyIgnoresSupersededToken(t *testing.T) {
254+ d, p, clock := newProxyLifecycleFixture(t)
255+ stale, _ := armWarmTimer(t, d, p, clock)
256+ current, currentTimer := armWarmTimer(t, d, p, clock)
257+ require.NotSame(t, stale, current, "re-arm did not publish a replacement timer")
258+ awaitTestCompletion(t, stale.done, "superseded warm timer was not canceled")
259+
260+ require.False(t, d.expireWarmProxy(p, stale), "a superseded token expired the proxy")
261+ require.True(t, proxyRegistered(d, p))
262+ require.False(t, proxyExpired(p))
263+ installed, generation := proxyWarmToken(p)
264+ require.Same(t, current, installed)
265+ require.Equal(t, generation, installed.generation)
266+
267+ currentTimer.ch <- time.Time{}
268+ awaitTestCompletion(t, current.done, "current warm timer never ran to completion")
269+ require.False(t, proxyRegistered(d, p))
270+}
@@ -0,0 +1,270 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// warmHookClock runs a one-shot hook while armProxyWarm sits between its
14+// reservation and its publication, so a lifecycle race can be interleaved
15+// deterministically instead of being waited for.
16+type warmHookClock struct {
17+ timers chan *signalTimer
18+
19+ mu sync.Mutex
20+ hook func()
21+}
22+
23+func (c *warmHookClock) Now() time.Time { return time.Time{} }
24+
25+func (c *warmHookClock) NewTimer(d time.Duration) ports.Timer {
26+ timer := &signalTimer{ch: make(chan time.Time, 1), duration: d}
27+ c.mu.Lock()
28+ hook := c.hook
29+ c.hook = nil
30+ c.mu.Unlock()
31+ if hook != nil {
32+ hook()
33+ }
34+ if c.timers != nil {
35+ c.timers <- timer
36+ }
37+ return timer
38+}
39+
40+func (c *warmHookClock) setHook(hook func()) {
41+ c.mu.Lock()
42+ c.hook = hook
43+ c.mu.Unlock()
44+}
45+
46+func registerLifecycleProxy(t *testing.T, d *Daemon, key domain.RemoteSessionKey) *proxySession {
47+ t.Helper()
48+ p, err := newProxySession(key, domain.Size{Cols: 80, Rows: 24})
49+ require.NoError(t, err)
50+ d.mu.Lock()
51+ registered := d.registerSessionLocked(p)
52+ d.mu.Unlock()
53+ require.True(t, registered, "lifecycle fixture proxy was not published")
54+ return p
55+}
56+
57+func newProxyLifecycleFixture(t *testing.T) (*Daemon, *proxySession, *warmHookClock) {
58+ t.Helper()
59+ clock := &warmHookClock{timers: make(chan *signalTimer, 4)}
60+ d := newProxyTestDaemon(t, nil, clock)
61+ return d, registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "work"}), clock
62+}
63+
64+func proxyWarmToken(p *proxySession) (*proxyWarmTimer, uint64) {
65+ p.mu.Lock()
66+ defer p.mu.Unlock()
67+ return p.warm, p.generation
68+}
69+
70+func setProxyLifecycleClient(p *proxySession, ac *attachedClient) {
71+ p.sessionCore.mu.Lock()
72+ p.client = ac
73+ p.sessionCore.mu.Unlock()
74+}
75+
76+func proxyRegistered(d *Daemon, p *proxySession) bool {
77+ d.mu.Lock()
78+ defer d.mu.Unlock()
79+ return d.sessions[p.id] == p
80+}
81+
82+func proxyExpired(p *proxySession) bool {
83+ p.mu.Lock()
84+ defer p.mu.Unlock()
85+ return p.expired
86+}
87+
88+func armWarmTimer(t *testing.T, d *Daemon, p *proxySession, clock *warmHookClock) (*proxyWarmTimer, *signalTimer) {
89+ t.Helper()
90+ require.True(t, d.armProxyWarm(p), "warm timer was not published")
91+ timer := awaitTestValue(t, clock.timers, "warm timer was never created")
92+ token, _ := proxyWarmToken(p)
93+ require.NotNil(t, token, "published arm left no warm token")
94+ return token, timer
95+}
96+
97+// TestArmProxyWarmKeepsExpiryPathWhenPublicationFails pins the two-phase arm:
98+// a re-arm that loses its revalidation must leave the incumbent timer both
99+// installed and current, otherwise a registered proxy is left with no path out
100+// of the live registry.
101+func TestArmProxyWarmKeepsExpiryPathWhenPublicationFails(t *testing.T) {
102+ tests := []struct {
103+ name string
104+ race func(d *Daemon, p *proxySession)
105+ undo func(d *Daemon, p *proxySession)
106+ }{
107+ {
108+ name: "client attaches during arm",
109+ race: func(_ *Daemon, p *proxySession) { setProxyLifecycleClient(p, &attachedClient{}) },
110+ undo: func(_ *Daemon, p *proxySession) { setProxyLifecycleClient(p, nil) },
111+ },
112+ {
113+ name: "replacement rekeys the lifecycle generation",
114+ race: func(_ *Daemon, p *proxySession) {
115+ p.mu.Lock()
116+ p.expired = true
117+ p.generation++
118+ if p.warm != nil {
119+ p.warm.generation = p.generation
120+ }
121+ p.mu.Unlock()
122+ },
123+ },
124+ {
125+ name: "daemon starts closing during arm",
126+ race: func(d *Daemon, _ *proxySession) {
127+ d.mu.Lock()
128+ d.closing = true
129+ d.mu.Unlock()
130+ },
131+ undo: func(d *Daemon, _ *proxySession) {
132+ d.mu.Lock()
133+ d.closing = false
134+ d.mu.Unlock()
135+ },
136+ },
137+ }
138+ for _, tt := range tests {
139+ t.Run(tt.name, func(t *testing.T) {
140+ d, p, clock := newProxyLifecycleFixture(t)
141+ token, timer := armWarmTimer(t, d, p, clock)
142+
143+ clock.setHook(func() { tt.race(d, p) })
144+ require.False(t, d.armProxyWarm(p), "raced re-arm must not publish a replacement")
145+ awaitTestValue(t, clock.timers, "raced re-arm never reached its clock")
146+
147+ retained, generation := proxyWarmToken(p)
148+ require.NotNil(t, retained, "failed re-arm stripped the proxy of its only expiry path")
149+ require.Same(t, token, retained, "failed re-arm replaced the armed expiry timer")
150+ require.Equal(t, generation, retained.generation, "retained timer no longer owns the lifecycle generation")
151+ require.True(t, proxyRegistered(d, p), "a failed re-arm must not unregister the proxy")
152+
153+ if tt.undo != nil {
154+ tt.undo(d, p)
155+ }
156+ timer.ch <- time.Time{}
157+ awaitTestCompletion(t, token.done, "retained warm timer never ran to completion")
158+ require.False(t, proxyRegistered(d, p), "retained warm timer did not expire the dormant proxy")
159+ require.True(t, proxyExpired(p), "expiry did not publish terminal lifecycle state")
160+ awaitTestCompletion(t, d.done, "last proxy expiry did not complete the daemon")
161+ })
162+ }
163+}
164+
165+func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
166+ t.Run("rekeys an already armed timer", func(t *testing.T) {
167+ d, p, clock := newProxyLifecycleFixture(t)
168+ transport := newProxyTestTransport()
169+ t.Cleanup(func() { _ = transport.Close() })
170+ generation, _ := p.installTransport(transport)
171+ token, timer := armWarmTimer(t, d, p, clock)
172+
173+ require.True(t, d.markProxyReplaced(p, generation, transport))
174+ require.Empty(t, clock.timers, "replacement armed a second timer instead of rekeying")
175+
176+ rekeyed, lifecycle := proxyWarmToken(p)
177+ require.Same(t, token, rekeyed, "replacement replaced the armed timer")
178+ require.Equal(t, lifecycle, rekeyed.generation, "replacement did not rekey the armed timer")
179+ require.True(t, proxyExpired(p))
180+ p.mu.Lock()
181+ state := p.linkState
182+ p.mu.Unlock()
183+ require.Equal(t, ports.LinkStateDead, state)
184+
185+ timer.ch <- time.Time{}
186+ awaitTestCompletion(t, token.done, "rekeyed warm timer never ran to completion")
187+ require.False(t, proxyRegistered(d, p), "rekeyed warm timer did not expire the proxy")
188+ })
189+
190+ t.Run("arms an expiry for a headless proxy", func(t *testing.T) {
191+ d, p, clock := newProxyLifecycleFixture(t)
192+ transport := newProxyTestTransport()
193+ t.Cleanup(func() { _ = transport.Close() })
194+ generation, _ := p.installTransport(transport)
195+
196+ require.True(t, d.markProxyReplaced(p, generation, transport))
197+ timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer")
198+ token, lifecycle := proxyWarmToken(p)
199+ require.NotNil(t, token, "replaced headless proxy has no expiry path")
200+ require.Equal(t, lifecycle, token.generation)
201+
202+ timer.ch <- time.Time{}
203+ awaitTestCompletion(t, token.done, "warm timer never ran to completion")
204+ require.False(t, proxyRegistered(d, p))
205+ })
206+
207+ t.Run("stale link generation publishes nothing", func(t *testing.T) {
208+ d, p, _ := newProxyLifecycleFixture(t)
209+ transport := newProxyTestTransport()
210+ t.Cleanup(func() { _ = transport.Close() })
211+ generation, _ := p.installTransport(transport)
212+
213+ require.False(t, d.markProxyReplaced(p, generation-1, transport))
214+ require.False(t, proxyExpired(p))
215+ warm, _ := proxyWarmToken(p)
216+ require.Nil(t, warm, "a rejected replacement must not arm an expiry")
217+ })
218+}
219+
220+func TestExpireWarmProxyCompletesDaemonOnlyOnLastSession(t *testing.T) {
221+ t.Run("last session completes the daemon", func(t *testing.T) {
222+ d, p, clock := newProxyLifecycleFixture(t)
223+ token, timer := armWarmTimer(t, d, p, clock)
224+
225+ timer.ch <- time.Time{}
226+ awaitTestCompletion(t, token.done, "warm timer never ran to completion")
227+ awaitTestCompletion(t, p.done, "expiry did not finish the proxy")
228+ awaitTestCompletion(t, d.done, "last session expiry did not complete the daemon")
229+ require.False(t, proxyRegistered(d, p))
230+ require.True(t, proxyExpired(p))
231+ })
232+
233+ t.Run("surviving session keeps the daemon running", func(t *testing.T) {
234+ d, p, clock := newProxyLifecycleFixture(t)
235+ survivor := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "other"})
236+ token, timer := armWarmTimer(t, d, p, clock)
237+
238+ timer.ch <- time.Time{}
239+ awaitTestCompletion(t, token.done, "warm timer never ran to completion")
240+ require.False(t, proxyRegistered(d, p))
241+ require.True(t, proxyRegistered(d, survivor))
242+ select {
243+ case <-d.done:
244+ t.Fatal("daemon completed while a session was still registered")
245+ default:
246+ }
247+ })
248+}
249+
250+// TestExpireWarmProxyIgnoresSupersededToken proves the expiry callback is exact:
251+// a token that lost its lifecycle generation must publish no terminal state and
252+// must not unregister the proxy.
253+func TestExpireWarmProxyIgnoresSupersededToken(t *testing.T) {
254+ d, p, clock := newProxyLifecycleFixture(t)
255+ stale, _ := armWarmTimer(t, d, p, clock)
256+ current, currentTimer := armWarmTimer(t, d, p, clock)
257+ require.NotSame(t, stale, current, "re-arm did not publish a replacement timer")
258+ awaitTestCompletion(t, stale.done, "superseded warm timer was not canceled")
259+
260+ require.False(t, d.expireWarmProxy(p, stale), "a superseded token expired the proxy")
261+ require.True(t, proxyRegistered(d, p))
262+ require.False(t, proxyExpired(p))
263+ installed, generation := proxyWarmToken(p)
264+ require.Same(t, current, installed)
265+ require.Equal(t, generation, installed.generation)
266+
267+ currentTimer.ch <- time.Time{}
268+ awaitTestCompletion(t, current.done, "current warm timer never ran to completion")
269+ require.False(t, proxyRegistered(d, p))
270+}
A internal/usecase/daemon/proxy_link.go +601 -0
@@ -0,0 +1,601 @@
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+ // errProxyLinkSend marks a reply that could not be written to the remote.
26+ // The received frame was well formed, so the link is resumable rather than
27+ // terminally broken.
28+ errProxyLinkSend = errors.New("proxy session: link reply send failed")
29+)
30+
31+type proxyCommandOutcome struct {
32+ result ports.CommandResult
33+ err error
34+}
35+
36+type proxyCommandPending struct {
37+ generation uint64
38+ outcome chan proxyCommandOutcome
39+}
40+
41+type proxyRecvResult struct {
42+ frame ports.Frame
43+ err error
44+}
45+
46+type proxyLinkResult uint8
47+
48+const (
49+ proxyLinkStop proxyLinkResult = iota
50+ proxyLinkResume
51+ proxyLinkReplaced
52+)
53+
54+// dialProxyHandshake installs a candidate transport, sends the proxied Hello
55+// through the session's sole sender, and requires Welcome followed immediately
56+// by valid authoritative metadata. The candidate is never registry-visible.
57+func (d *Daemon) dialProxyHandshake(ctx context.Context, p *proxySession, intent uint8) error {
58+ if err := ctx.Err(); err != nil {
59+ return err
60+ }
61+ dialer, err := d.remoteDialerFactory.DialerForRemote(p.key.Host, p.key.Name, d.remoteTransportMode, d.log)
62+ if err != nil {
63+ return fmt.Errorf("proxy session: select remote dialer: %w", err)
64+ }
65+ if dialer == nil {
66+ return errors.New("proxy session: remote dialer factory returned nil dialer")
67+ }
68+ if err := ctx.Err(); err != nil {
69+ return err
70+ }
71+ transport, err := dialer.Dial(ctx)
72+ if err != nil {
73+ return fmt.Errorf("proxy session: dial: %w", err)
74+ }
75+ if transport == nil {
76+ return errors.New("proxy session: remote dialer returned nil transport")
77+ }
78+ if err := ctx.Err(); err != nil {
79+ _ = transport.Close()
80+ return err
81+ }
82+
83+ generation, previous := p.installTransport(transport)
84+ if previous != nil && previous != transport {
85+ _ = previous.Close()
86+ }
87+ failed := true
88+ defer func() {
89+ if failed {
90+ p.retireTransport(generation, transport)
91+ _ = transport.Close()
92+ }
93+ }()
94+
95+ p.mu.Lock()
96+ resumeToken := p.resumeToken
97+ clientID := p.clientID
98+ size := p.contentSize
99+ p.mu.Unlock()
100+ if intent != ports.IntentResume {
101+ resumeToken = 0
102+ }
103+ hello := ports.Hello{
104+ Version: ports.ProtocolVersion,
105+ Intent: intent,
106+ Proxied: true,
107+ ClientID: clientID,
108+ ResumeToken: resumeToken,
109+ Name: p.key.Name,
110+ Size: size,
111+ MaxOutputInFlight: proxyOutputWindow(transport),
112+ }
113+ if err := p.sendGeneration(generation, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
114+ return fmt.Errorf("proxy session: send hello: %w", err)
115+ }
116+
117+ welcome, meta, err := recvProxyHandshake(ctx, transport, p.key.Name)
118+ if err != nil {
119+ return err
120+ }
121+ if err := ctx.Err(); err != nil {
122+ return err
123+ }
124+
125+ p.mu.Lock()
126+ if p.linkGeneration != generation || p.transport != transport {
127+ p.mu.Unlock()
128+ return errors.New("proxy session: link replaced during handshake")
129+ }
130+ p.resumeToken = welcome.ResumeToken
131+ p.replaceSessionMetaLocked(meta, d.clock.Now())
132+ p.linkState = ports.LinkStateConnected
133+ p.expired = false
134+ p.mu.Unlock()
135+ failed = false
136+ return nil
137+}
138+
139+func proxyOutputWindow(transport ports.Transport) uint8 {
140+ if _, ok := transport.(ports.DatagramTransport); ok {
141+ return 1
142+ }
143+ return maxUnackedOutputStates
144+}
145+
146+func recvProxyHandshake(ctx context.Context, transport ports.Transport, sessionName string) (ports.Welcome, ports.SessionMeta, error) {
147+ frame, err := recvProxyHandshakeFrame(ctx, transport)
148+ if err != nil {
149+ return ports.Welcome{}, ports.SessionMeta{}, fmt.Errorf("proxy session: receive welcome: %w", err)
150+ }
151+ welcome, err := validateProxyWelcome(frame, sessionName)
152+ if err != nil {
153+ return ports.Welcome{}, ports.SessionMeta{}, err
154+ }
155+
156+ frame, err = recvProxyHandshakeFrame(ctx, transport)
157+ if err != nil {
158+ return ports.Welcome{}, ports.SessionMeta{}, fmt.Errorf("proxy session: receive initial metadata: %w", err)
159+ }
160+ meta, err := validateProxyInitialMeta(frame, sessionName)
161+ if err != nil {
162+ return ports.Welcome{}, ports.SessionMeta{}, err
163+ }
164+ return welcome, meta, nil
165+}
166+
167+func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, error) {
168+ if frame.Type == ports.MsgError {
169+ remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
170+ if err != nil {
171+ return ports.Welcome{}, fmt.Errorf("proxy session: malformed remote error: %w", err)
172+ }
173+ return ports.Welcome{}, fmt.Errorf("proxy session: remote rejected handshake (%d): %s", remoteErr.Code, remoteErr.Text)
174+ }
175+ if frame.Type != ports.MsgWelcome {
176+ return ports.Welcome{}, fmt.Errorf("proxy session: first server frame is %d, want Welcome", frame.Type)
177+ }
178+ welcome, err := ports.UnmarshalWelcome(frame.Payload)
179+ if err != nil {
180+ return ports.Welcome{}, fmt.Errorf("proxy session: decode welcome: %w", err)
181+ }
182+ if welcome.Capabilities&ports.CapabilityProxied == 0 {
183+ return ports.Welcome{}, errors.New("proxy session: remote did not negotiate proxied capability")
184+ }
185+ if welcome.SessionID == "" || welcome.SessionName != sessionName {
186+ return ports.Welcome{}, fmt.Errorf("proxy session: welcome identity mismatch: got %q", welcome.SessionName)
187+ }
188+ return welcome, nil
189+}
190+
191+func validateProxyInitialMeta(frame ports.Frame, sessionName string) (ports.SessionMeta, error) {
192+ if frame.Type != ports.MsgSessionMeta {
193+ return ports.SessionMeta{}, fmt.Errorf("proxy session: second server frame is %d, want SessionMeta", frame.Type)
194+ }
195+ meta, err := ports.UnmarshalSessionMeta(frame.Payload)
196+ if err != nil {
197+ return ports.SessionMeta{}, fmt.Errorf("proxy session: decode initial metadata: %w", err)
198+ }
199+ if meta.SessionName != sessionName {
200+ return ports.SessionMeta{}, fmt.Errorf("proxy session: metadata identity mismatch: got %q", meta.SessionName)
201+ }
202+ return meta, nil
203+}
204+
205+func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {
206+ result := make(chan proxyRecvResult, 1)
207+ go func() {
208+ frame, err := transport.Recv()
209+ result <- proxyRecvResult{frame: frame, err: err}
210+ }()
211+ select {
212+ case received := <-result:
213+ return received.frame, received.err
214+ case <-ctx.Done():
215+ _ = transport.Close()
216+ received := <-result // Transport.Close is required to unblock Recv.
217+ if received.err != nil && !errors.Is(received.err, io.EOF) {
218+ return ports.Frame{}, errors.Join(ctx.Err(), received.err)
219+ }
220+ return ports.Frame{}, ctx.Err()
221+ }
222+}
223+
224+func (p *proxySession) installTransport(transport ports.Transport) (uint64, ports.Transport) {
225+ // Link replacement participates in the same sender serialization as every
226+ // frame. A request can therefore only be published wholly before the
227+ // generation changes or wholly after it.
228+ p.sendMu.Lock()
229+ defer p.sendMu.Unlock()
230+ p.mu.Lock()
231+ previous := p.transport
232+ p.linkGeneration++
233+ generation := p.linkGeneration
234+ // State numbers belong to one remote output stream. A replacement link
235+ // must prove its state with a base-zero paint before an incremental frame
236+ // can advance this proxy.
237+ p.resetOutputStateLocked()
238+ p.transport = transport
239+ p.linkState = ports.LinkStateConnected
240+ p.mu.Unlock()
241+ p.failPendingCommandsLocked(errProxyCommandGeneration)
242+ return generation, previous
243+}
244+
245+func (p *proxySession) retireTransport(generation uint64, transport ports.Transport) {
246+ p.sendMu.Lock()
247+ defer p.sendMu.Unlock()
248+ p.mu.Lock()
249+ current := p.linkGeneration == generation && p.transport == transport
250+ if current {
251+ p.transport = nil
252+ }
253+ p.mu.Unlock()
254+ if current {
255+ p.failPendingCommandsLocked(errProxyCommandUnavailable)
256+ }
257+}
258+
259+// Every proxy Transport.Send path is serialized by sendMu: Hello, input,
260+// resize, ACK/reset, ping/pong, and command traffic. The leaf proxy lock is
261+// held only long enough to revalidate
262+// the immutable link snapshot; I/O happens after releasing it.
263+func (p *proxySession) sendCurrent(frame ports.Frame) error {
264+ p.mu.Lock()
265+ generation := p.linkGeneration
266+ p.mu.Unlock()
267+ return p.sendGeneration(generation, frame)
268+}
269+
270+func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error {
271+ p.sendMu.Lock()
272+ defer p.sendMu.Unlock()
273+ p.mu.Lock()
274+ if p.linkGeneration != generation || p.transport == nil {
275+ p.mu.Unlock()
276+ return errors.New("proxy session: stale or unavailable link")
277+ }
278+ transport := p.transport
279+ p.mu.Unlock()
280+ return transport.Send(frame)
281+}
282+
283+// sendCommand sends one correlated attached palette command and waits for its
284+// exact result. commandMu serializes interactive requests while sendMu remains
285+// available to all other link traffic during the wait.
286+func (p *proxySession) sendCommand(ctx context.Context, clock ports.Clock, slug string, args []string) (ports.CommandResult, error) {
287+ if p == nil || clock == nil {
288+ return ports.CommandResult{}, errProxyCommandUnavailable
289+ }
290+ if ctx == nil {
291+ ctx = context.Background()
292+ }
293+ if err := ctx.Err(); err != nil {
294+ return ports.CommandResult{}, err
295+ }
296+ p.commandMu.Lock()
297+ defer p.commandMu.Unlock()
298+ if err := ctx.Err(); err != nil {
299+ return ports.CommandResult{}, err
300+ }
301+
302+ p.sendMu.Lock()
303+ p.mu.Lock()
304+ generation, transport := p.linkGeneration, p.transport
305+ p.mu.Unlock()
306+ if transport == nil {
307+ p.sendMu.Unlock()
308+ return ports.CommandResult{}, errProxyCommandUnavailable
309+ }
310+ p.commandNext++
311+ if p.commandNext == 0 {
312+ p.commandNext++
313+ }
314+ requestID := p.commandNext
315+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
316+ Version: ports.ProtocolVersion, RequestID: requestID, Attached: true,
317+ Slug: slug, Args: append([]string(nil), args...),
318+ })
319+ if err != nil {
320+ p.sendMu.Unlock()
321+ return ports.CommandResult{}, err
322+ }
323+ outcome := make(chan proxyCommandOutcome, 1)
324+ p.commandPending[requestID] = proxyCommandPending{generation: generation, outcome: outcome}
325+ if err := transport.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
326+ delete(p.commandPending, requestID)
327+ p.sendMu.Unlock()
328+ return ports.CommandResult{}, err
329+ }
330+ p.sendMu.Unlock()
331+
332+ timer := clock.NewTimer(proxyAttachedCommandTimeout)
333+ if timer == nil {
334+ p.removePendingCommand(requestID, generation)
335+ return ports.CommandResult{}, errProxyCommandTimeout
336+ }
337+ defer timer.Stop()
338+ select {
339+ case completed := <-outcome:
340+ return completed.result, completed.err
341+ case <-ctx.Done():
342+ p.removePendingCommand(requestID, generation)
343+ return ports.CommandResult{}, ctx.Err()
344+ case <-timer.C():
345+ p.removePendingCommand(requestID, generation)
346+ return ports.CommandResult{}, errProxyCommandTimeout
347+ }
348+}
349+
350+func (p *proxySession) removePendingCommand(requestID, generation uint64) {
351+ p.sendMu.Lock()
352+ pending, ok := p.commandPending[requestID]
353+ if ok && pending.generation == generation {
354+ delete(p.commandPending, requestID)
355+ }
356+ p.sendMu.Unlock()
357+}
358+
359+// completeCommandResult ignores malformed correlation state, unknown/late IDs,
360+// and results received by a replacement generation.
361+func (p *proxySession) completeCommandResult(generation uint64, result ports.CommandResult) {
362+ if result.RequestID == 0 {
363+ return
364+ }
365+ p.sendMu.Lock()
366+ pending, ok := p.commandPending[result.RequestID]
367+ if !ok || pending.generation != generation {
368+ p.sendMu.Unlock()
369+ return
370+ }
371+ delete(p.commandPending, result.RequestID)
372+ p.sendMu.Unlock()
373+ pending.outcome <- proxyCommandOutcome{result: result}
374+}
375+
376+// failPendingCommandsLocked requires sendMu and never blocks.
377+func (p *proxySession) failPendingCommandsLocked(err error) {
378+ for id, pending := range p.commandPending {
379+ delete(p.commandPending, id)
380+ pending.outcome <- proxyCommandOutcome{err: err}
381+ }
382+}
383+
384+func (d *Daemon) runProxyLink(ctx context.Context, p *proxySession) {
385+ defer p.finish()
386+ connectedAt := d.clock.Now()
387+ resumeAttempts := 0
388+ for {
389+ p.mu.Lock()
390+ generation := p.linkGeneration
391+ transport := p.transport
392+ p.mu.Unlock()
393+ if transport == nil {
394+ return
395+ }
396+
397+ result := d.runProxyTransport(ctx, p, generation, transport)
398+ if result == proxyLinkReplaced {
399+ // ReasonReplaced is terminal for the exact transport incarnation. Publish
400+ // expiry before retirement clears the transport identity used by the
401+ // callback fence; the switch below deliberately never redials it.
402+ d.markProxyReplaced(p, generation, transport)
403+ }
404+ p.retireTransport(generation, transport)
405+ switch result {
406+ case proxyLinkReplaced, proxyLinkStop:
407+ return
408+ case proxyLinkResume:
409+ if ctx.Err() != nil {
410+ return
411+ }
412+ if d.clock.Now().Sub(connectedAt) >= proxyResumeStableDuration {
413+ resumeAttempts = 0
414+ }
415+ d.updateProxyDisconnectedState(p, generation, ports.LinkStateOffline)
416+
417+ for {
418+ if resumeAttempts >= proxyResumeMaxAttempts {
419+ d.log.Warn("proxy resume attempts exhausted", "host", p.key.Host, "session", p.key.Name, "attempts", resumeAttempts)
420+ return
421+ }
422+ delay := proxyResumeBackoff(resumeAttempts)
423+ if !waitProxyResume(ctx, d.clock, delay) {
424+ return
425+ }
426+ resumeAttempts++
427+ if err := d.dialProxyHandshake(ctx, p, ports.IntentResume); err != nil {
428+ p.mu.Lock()
429+ failedGeneration := p.linkGeneration
430+ p.mu.Unlock()
431+ d.updateProxyDisconnectedState(p, failedGeneration, ports.LinkStateOffline)
432+ if ctx.Err() != nil {
433+ return
434+ }
435+ d.log.Warn("proxy resume failed", "host", p.key.Host, "session", p.key.Name, "attempt", resumeAttempts, "err", err)
436+ continue
437+ }
438+ connectedAt = d.clock.Now()
439+ d.repaintProxyLifecycle(p)
440+ break
441+ }
442+ }
443+ }
444+}
445+
446+func proxyResumeBackoff(attempt int) time.Duration {
447+ delay := proxyResumeInitialBackoff
448+ for range attempt {
449+ if delay >= proxyResumeMaxBackoff/2 {
450+ return proxyResumeMaxBackoff
451+ }
452+ delay *= 2
453+ }
454+ return min(delay, proxyResumeMaxBackoff)
455+}
456+
457+func waitProxyResume(ctx context.Context, clock ports.Clock, delay time.Duration) bool {
458+ if err := ctx.Err(); err != nil {
459+ return false
460+ }
461+ timer := clock.NewTimer(delay)
462+ if timer == nil {
463+ return false
464+ }
465+ defer timer.Stop()
466+ select {
467+ case <-ctx.Done():
468+ return false
469+ case <-timer.C():
470+ return true
471+ }
472+}
473+
474+func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generation uint64, transport ports.Transport) proxyLinkResult {
475+ recvCtx, cancelRecv := context.WithCancel(ctx)
476+ recv := make(chan proxyRecvResult, 1)
477+ recvDone := make(chan struct{})
478+ go func() {
479+ defer close(recvDone)
480+ for {
481+ frame, err := transport.Recv()
482+ select {
483+ case recv <- proxyRecvResult{frame: frame, err: err}:
484+ case <-recvCtx.Done():
485+ return
486+ }
487+ if err != nil {
488+ return
489+ }
490+ }
491+ }()
492+ defer func() {
493+ cancelRecv()
494+ _ = transport.Close()
495+ <-recvDone
496+ }()
497+
498+ var events <-chan ports.LinkEvent
499+ if reporter, ok := transport.(ports.LinkStateReporter); ok {
500+ events = reporter.LinkEvents()
501+ }
502+ for {
503+ select {
504+ case <-ctx.Done():
505+ return proxyLinkStop
506+ case event, ok := <-events:
507+ if !ok {
508+ events = nil
509+ continue
510+ }
511+ if !d.updateProxyLinkState(p, generation, transport, event.State) {
512+ return proxyLinkStop
513+ }
514+ if event.State == ports.LinkStateOffline || event.State == ports.LinkStateDead {
515+ _ = transport.Close()
516+ return proxyLinkResume
517+ }
518+ case received := <-recv:
519+ if received.err != nil {
520+ if ctx.Err() != nil {
521+ return proxyLinkStop
522+ }
523+ return proxyLinkResume
524+ }
525+ result, err := d.handleLinkFrame(p, generation, received.frame)
526+ if err != nil {
527+ // A failed reply says nothing about the remote's framing, so it
528+ // retires this transport instead of the whole proxy link.
529+ if errors.Is(err, errProxyLinkSend) {
530+ d.log.Warn("proxy link reply failed", "host", p.key.Host, "session", p.key.Name, "err", err)
531+ return proxyLinkResume
532+ }
533+ d.log.Warn("invalid proxy link frame", "host", p.key.Host, "session", p.key.Name, "err", err)
534+ return proxyLinkStop
535+ }
536+ if result != proxyLinkResume {
537+ return result
538+ }
539+ }
540+ }
541+}
542+
543+// handleLinkFrame returns proxyLinkResume to mean "continue this transport";
544+// the other values are terminal actions.
545+func (d *Daemon) handleLinkFrame(p *proxySession, generation uint64, frame ports.Frame) (proxyLinkResult, error) {
546+ switch frame.Type {
547+ case ports.MsgOutput:
548+ out, err := ports.UnmarshalOutput(frame.Payload)
549+ if err != nil {
550+ return proxyLinkStop, err
551+ }
552+ if err := d.handleProxyOutput(p, generation, out); err != nil {
553+ return proxyLinkStop, err
554+ }
555+ return proxyLinkResume, nil
556+ case ports.MsgSessionMeta:
557+ meta, err := ports.UnmarshalSessionMeta(frame.Payload)
558+ if err != nil {
559+ return proxyLinkStop, err
560+ }
561+ if _, err := d.applyProxySessionMeta(p, generation, meta); err != nil {
562+ return proxyLinkStop, err
563+ }
564+ return proxyLinkResume, nil
565+ case ports.MsgCommandResult:
566+ result, err := ports.UnmarshalCommandResult(frame.Payload)
567+ if err != nil {
568+ return proxyLinkStop, err
569+ }
570+ p.completeCommandResult(generation, result)
571+ return proxyLinkResume, nil
572+ case ports.MsgPong:
573+ _, err := ports.UnmarshalPong(frame.Payload)
574+ return proxyLinkResume, err
575+ case ports.MsgPing:
576+ if _, err := ports.UnmarshalPing(frame.Payload); err != nil {
577+ return proxyLinkStop, err
578+ }
579+ if err := p.sendGeneration(generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}); err != nil {
580+ return proxyLinkStop, fmt.Errorf("proxy pong reply: %w: %w", errProxyLinkSend, err)
581+ }
582+ return proxyLinkResume, nil
583+ case ports.MsgDetached:
584+ detached, err := ports.UnmarshalDetached(frame.Payload)
585+ if err != nil {
586+ return proxyLinkStop, err
587+ }
588+ if detached.Reason == ports.ReasonReplaced {
589+ return proxyLinkReplaced, nil
590+ }
591+ return proxyLinkStop, nil
592+ case ports.MsgError:
593+ remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
594+ if err != nil {
595+ return proxyLinkStop, err
596+ }
597+ return proxyLinkStop, fmt.Errorf("remote proxy error (%d): %s", remoteErr.Code, remoteErr.Text)
598+ default:
599+ return proxyLinkStop, fmt.Errorf("unexpected proxy server frame type %d", frame.Type)
600+ }
601+}
@@ -0,0 +1,601 @@
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+ // errProxyLinkSend marks a reply that could not be written to the remote.
26+ // The received frame was well formed, so the link is resumable rather than
27+ // terminally broken.
28+ errProxyLinkSend = errors.New("proxy session: link reply send failed")
29+)
30+
31+type proxyCommandOutcome struct {
32+ result ports.CommandResult
33+ err error
34+}
35+
36+type proxyCommandPending struct {
37+ generation uint64
38+ outcome chan proxyCommandOutcome
39+}
40+
41+type proxyRecvResult struct {
42+ frame ports.Frame
43+ err error
44+}
45+
46+type proxyLinkResult uint8
47+
48+const (
49+ proxyLinkStop proxyLinkResult = iota
50+ proxyLinkResume
51+ proxyLinkReplaced
52+)
53+
54+// dialProxyHandshake installs a candidate transport, sends the proxied Hello
55+// through the session's sole sender, and requires Welcome followed immediately
56+// by valid authoritative metadata. The candidate is never registry-visible.
57+func (d *Daemon) dialProxyHandshake(ctx context.Context, p *proxySession, intent uint8) error {
58+ if err := ctx.Err(); err != nil {
59+ return err
60+ }
61+ dialer, err := d.remoteDialerFactory.DialerForRemote(p.key.Host, p.key.Name, d.remoteTransportMode, d.log)
62+ if err != nil {
63+ return fmt.Errorf("proxy session: select remote dialer: %w", err)
64+ }
65+ if dialer == nil {
66+ return errors.New("proxy session: remote dialer factory returned nil dialer")
67+ }
68+ if err := ctx.Err(); err != nil {
69+ return err
70+ }
71+ transport, err := dialer.Dial(ctx)
72+ if err != nil {
73+ return fmt.Errorf("proxy session: dial: %w", err)
74+ }
75+ if transport == nil {
76+ return errors.New("proxy session: remote dialer returned nil transport")
77+ }
78+ if err := ctx.Err(); err != nil {
79+ _ = transport.Close()
80+ return err
81+ }
82+
83+ generation, previous := p.installTransport(transport)
84+ if previous != nil && previous != transport {
85+ _ = previous.Close()
86+ }
87+ failed := true
88+ defer func() {
89+ if failed {
90+ p.retireTransport(generation, transport)
91+ _ = transport.Close()
92+ }
93+ }()
94+
95+ p.mu.Lock()
96+ resumeToken := p.resumeToken
97+ clientID := p.clientID
98+ size := p.contentSize
99+ p.mu.Unlock()
100+ if intent != ports.IntentResume {
101+ resumeToken = 0
102+ }
103+ hello := ports.Hello{
104+ Version: ports.ProtocolVersion,
105+ Intent: intent,
106+ Proxied: true,
107+ ClientID: clientID,
108+ ResumeToken: resumeToken,
109+ Name: p.key.Name,
110+ Size: size,
111+ MaxOutputInFlight: proxyOutputWindow(transport),
112+ }
113+ if err := p.sendGeneration(generation, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
114+ return fmt.Errorf("proxy session: send hello: %w", err)
115+ }
116+
117+ welcome, meta, err := recvProxyHandshake(ctx, transport, p.key.Name)
118+ if err != nil {
119+ return err
120+ }
121+ if err := ctx.Err(); err != nil {
122+ return err
123+ }
124+
125+ p.mu.Lock()
126+ if p.linkGeneration != generation || p.transport != transport {
127+ p.mu.Unlock()
128+ return errors.New("proxy session: link replaced during handshake")
129+ }
130+ p.resumeToken = welcome.ResumeToken
131+ p.replaceSessionMetaLocked(meta, d.clock.Now())
132+ p.linkState = ports.LinkStateConnected
133+ p.expired = false
134+ p.mu.Unlock()
135+ failed = false
136+ return nil
137+}
138+
139+func proxyOutputWindow(transport ports.Transport) uint8 {
140+ if _, ok := transport.(ports.DatagramTransport); ok {
141+ return 1
142+ }
143+ return maxUnackedOutputStates
144+}
145+
146+func recvProxyHandshake(ctx context.Context, transport ports.Transport, sessionName string) (ports.Welcome, ports.SessionMeta, error) {
147+ frame, err := recvProxyHandshakeFrame(ctx, transport)
148+ if err != nil {
149+ return ports.Welcome{}, ports.SessionMeta{}, fmt.Errorf("proxy session: receive welcome: %w", err)
150+ }
151+ welcome, err := validateProxyWelcome(frame, sessionName)
152+ if err != nil {
153+ return ports.Welcome{}, ports.SessionMeta{}, err
154+ }
155+
156+ frame, err = recvProxyHandshakeFrame(ctx, transport)
157+ if err != nil {
158+ return ports.Welcome{}, ports.SessionMeta{}, fmt.Errorf("proxy session: receive initial metadata: %w", err)
159+ }
160+ meta, err := validateProxyInitialMeta(frame, sessionName)
161+ if err != nil {
162+ return ports.Welcome{}, ports.SessionMeta{}, err
163+ }
164+ return welcome, meta, nil
165+}
166+
167+func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, error) {
168+ if frame.Type == ports.MsgError {
169+ remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
170+ if err != nil {
171+ return ports.Welcome{}, fmt.Errorf("proxy session: malformed remote error: %w", err)
172+ }
173+ return ports.Welcome{}, fmt.Errorf("proxy session: remote rejected handshake (%d): %s", remoteErr.Code, remoteErr.Text)
174+ }
175+ if frame.Type != ports.MsgWelcome {
176+ return ports.Welcome{}, fmt.Errorf("proxy session: first server frame is %d, want Welcome", frame.Type)
177+ }
178+ welcome, err := ports.UnmarshalWelcome(frame.Payload)
179+ if err != nil {
180+ return ports.Welcome{}, fmt.Errorf("proxy session: decode welcome: %w", err)
181+ }
182+ if welcome.Capabilities&ports.CapabilityProxied == 0 {
183+ return ports.Welcome{}, errors.New("proxy session: remote did not negotiate proxied capability")
184+ }
185+ if welcome.SessionID == "" || welcome.SessionName != sessionName {
186+ return ports.Welcome{}, fmt.Errorf("proxy session: welcome identity mismatch: got %q", welcome.SessionName)
187+ }
188+ return welcome, nil
189+}
190+
191+func validateProxyInitialMeta(frame ports.Frame, sessionName string) (ports.SessionMeta, error) {
192+ if frame.Type != ports.MsgSessionMeta {
193+ return ports.SessionMeta{}, fmt.Errorf("proxy session: second server frame is %d, want SessionMeta", frame.Type)
194+ }
195+ meta, err := ports.UnmarshalSessionMeta(frame.Payload)
196+ if err != nil {
197+ return ports.SessionMeta{}, fmt.Errorf("proxy session: decode initial metadata: %w", err)
198+ }
199+ if meta.SessionName != sessionName {
200+ return ports.SessionMeta{}, fmt.Errorf("proxy session: metadata identity mismatch: got %q", meta.SessionName)
201+ }
202+ return meta, nil
203+}
204+
205+func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {
206+ result := make(chan proxyRecvResult, 1)
207+ go func() {
208+ frame, err := transport.Recv()
209+ result <- proxyRecvResult{frame: frame, err: err}
210+ }()
211+ select {
212+ case received := <-result:
213+ return received.frame, received.err
214+ case <-ctx.Done():
215+ _ = transport.Close()
216+ received := <-result // Transport.Close is required to unblock Recv.
217+ if received.err != nil && !errors.Is(received.err, io.EOF) {
218+ return ports.Frame{}, errors.Join(ctx.Err(), received.err)
219+ }
220+ return ports.Frame{}, ctx.Err()
221+ }
222+}
223+
224+func (p *proxySession) installTransport(transport ports.Transport) (uint64, ports.Transport) {
225+ // Link replacement participates in the same sender serialization as every
226+ // frame. A request can therefore only be published wholly before the
227+ // generation changes or wholly after it.
228+ p.sendMu.Lock()
229+ defer p.sendMu.Unlock()
230+ p.mu.Lock()
231+ previous := p.transport
232+ p.linkGeneration++
233+ generation := p.linkGeneration
234+ // State numbers belong to one remote output stream. A replacement link
235+ // must prove its state with a base-zero paint before an incremental frame
236+ // can advance this proxy.
237+ p.resetOutputStateLocked()
238+ p.transport = transport
239+ p.linkState = ports.LinkStateConnected
240+ p.mu.Unlock()
241+ p.failPendingCommandsLocked(errProxyCommandGeneration)
242+ return generation, previous
243+}
244+
245+func (p *proxySession) retireTransport(generation uint64, transport ports.Transport) {
246+ p.sendMu.Lock()
247+ defer p.sendMu.Unlock()
248+ p.mu.Lock()
249+ current := p.linkGeneration == generation && p.transport == transport
250+ if current {
251+ p.transport = nil
252+ }
253+ p.mu.Unlock()
254+ if current {
255+ p.failPendingCommandsLocked(errProxyCommandUnavailable)
256+ }
257+}
258+
259+// Every proxy Transport.Send path is serialized by sendMu: Hello, input,
260+// resize, ACK/reset, ping/pong, and command traffic. The leaf proxy lock is
261+// held only long enough to revalidate
262+// the immutable link snapshot; I/O happens after releasing it.
263+func (p *proxySession) sendCurrent(frame ports.Frame) error {
264+ p.mu.Lock()
265+ generation := p.linkGeneration
266+ p.mu.Unlock()
267+ return p.sendGeneration(generation, frame)
268+}
269+
270+func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error {
271+ p.sendMu.Lock()
272+ defer p.sendMu.Unlock()
273+ p.mu.Lock()
274+ if p.linkGeneration != generation || p.transport == nil {
275+ p.mu.Unlock()
276+ return errors.New("proxy session: stale or unavailable link")
277+ }
278+ transport := p.transport
279+ p.mu.Unlock()
280+ return transport.Send(frame)
281+}
282+
283+// sendCommand sends one correlated attached palette command and waits for its
284+// exact result. commandMu serializes interactive requests while sendMu remains
285+// available to all other link traffic during the wait.
286+func (p *proxySession) sendCommand(ctx context.Context, clock ports.Clock, slug string, args []string) (ports.CommandResult, error) {
287+ if p == nil || clock == nil {
288+ return ports.CommandResult{}, errProxyCommandUnavailable
289+ }
290+ if ctx == nil {
291+ ctx = context.Background()
292+ }
293+ if err := ctx.Err(); err != nil {
294+ return ports.CommandResult{}, err
295+ }
296+ p.commandMu.Lock()
297+ defer p.commandMu.Unlock()
298+ if err := ctx.Err(); err != nil {
299+ return ports.CommandResult{}, err
300+ }
301+
302+ p.sendMu.Lock()
303+ p.mu.Lock()
304+ generation, transport := p.linkGeneration, p.transport
305+ p.mu.Unlock()
306+ if transport == nil {
307+ p.sendMu.Unlock()
308+ return ports.CommandResult{}, errProxyCommandUnavailable
309+ }
310+ p.commandNext++
311+ if p.commandNext == 0 {
312+ p.commandNext++
313+ }
314+ requestID := p.commandNext
315+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
316+ Version: ports.ProtocolVersion, RequestID: requestID, Attached: true,
317+ Slug: slug, Args: append([]string(nil), args...),
318+ })
319+ if err != nil {
320+ p.sendMu.Unlock()
321+ return ports.CommandResult{}, err
322+ }
323+ outcome := make(chan proxyCommandOutcome, 1)
324+ p.commandPending[requestID] = proxyCommandPending{generation: generation, outcome: outcome}
325+ if err := transport.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
326+ delete(p.commandPending, requestID)
327+ p.sendMu.Unlock()
328+ return ports.CommandResult{}, err
329+ }
330+ p.sendMu.Unlock()
331+
332+ timer := clock.NewTimer(proxyAttachedCommandTimeout)
333+ if timer == nil {
334+ p.removePendingCommand(requestID, generation)
335+ return ports.CommandResult{}, errProxyCommandTimeout
336+ }
337+ defer timer.Stop()
338+ select {
339+ case completed := <-outcome:
340+ return completed.result, completed.err
341+ case <-ctx.Done():
342+ p.removePendingCommand(requestID, generation)
343+ return ports.CommandResult{}, ctx.Err()
344+ case <-timer.C():
345+ p.removePendingCommand(requestID, generation)
346+ return ports.CommandResult{}, errProxyCommandTimeout
347+ }
348+}
349+
350+func (p *proxySession) removePendingCommand(requestID, generation uint64) {
351+ p.sendMu.Lock()
352+ pending, ok := p.commandPending[requestID]
353+ if ok && pending.generation == generation {
354+ delete(p.commandPending, requestID)
355+ }
356+ p.sendMu.Unlock()
357+}
358+
359+// completeCommandResult ignores malformed correlation state, unknown/late IDs,
360+// and results received by a replacement generation.
361+func (p *proxySession) completeCommandResult(generation uint64, result ports.CommandResult) {
362+ if result.RequestID == 0 {
363+ return
364+ }
365+ p.sendMu.Lock()
366+ pending, ok := p.commandPending[result.RequestID]
367+ if !ok || pending.generation != generation {
368+ p.sendMu.Unlock()
369+ return
370+ }
371+ delete(p.commandPending, result.RequestID)
372+ p.sendMu.Unlock()
373+ pending.outcome <- proxyCommandOutcome{result: result}
374+}
375+
376+// failPendingCommandsLocked requires sendMu and never blocks.
377+func (p *proxySession) failPendingCommandsLocked(err error) {
378+ for id, pending := range p.commandPending {
379+ delete(p.commandPending, id)
380+ pending.outcome <- proxyCommandOutcome{err: err}
381+ }
382+}
383+
384+func (d *Daemon) runProxyLink(ctx context.Context, p *proxySession) {
385+ defer p.finish()
386+ connectedAt := d.clock.Now()
387+ resumeAttempts := 0
388+ for {
389+ p.mu.Lock()
390+ generation := p.linkGeneration
391+ transport := p.transport
392+ p.mu.Unlock()
393+ if transport == nil {
394+ return
395+ }
396+
397+ result := d.runProxyTransport(ctx, p, generation, transport)
398+ if result == proxyLinkReplaced {
399+ // ReasonReplaced is terminal for the exact transport incarnation. Publish
400+ // expiry before retirement clears the transport identity used by the
401+ // callback fence; the switch below deliberately never redials it.
402+ d.markProxyReplaced(p, generation, transport)
403+ }
404+ p.retireTransport(generation, transport)
405+ switch result {
406+ case proxyLinkReplaced, proxyLinkStop:
407+ return
408+ case proxyLinkResume:
409+ if ctx.Err() != nil {
410+ return
411+ }
412+ if d.clock.Now().Sub(connectedAt) >= proxyResumeStableDuration {
413+ resumeAttempts = 0
414+ }
415+ d.updateProxyDisconnectedState(p, generation, ports.LinkStateOffline)
416+
417+ for {
418+ if resumeAttempts >= proxyResumeMaxAttempts {
419+ d.log.Warn("proxy resume attempts exhausted", "host", p.key.Host, "session", p.key.Name, "attempts", resumeAttempts)
420+ return
421+ }
422+ delay := proxyResumeBackoff(resumeAttempts)
423+ if !waitProxyResume(ctx, d.clock, delay) {
424+ return
425+ }
426+ resumeAttempts++
427+ if err := d.dialProxyHandshake(ctx, p, ports.IntentResume); err != nil {
428+ p.mu.Lock()
429+ failedGeneration := p.linkGeneration
430+ p.mu.Unlock()
431+ d.updateProxyDisconnectedState(p, failedGeneration, ports.LinkStateOffline)
432+ if ctx.Err() != nil {
433+ return
434+ }
435+ d.log.Warn("proxy resume failed", "host", p.key.Host, "session", p.key.Name, "attempt", resumeAttempts, "err", err)
436+ continue
437+ }
438+ connectedAt = d.clock.Now()
439+ d.repaintProxyLifecycle(p)
440+ break
441+ }
442+ }
443+ }
444+}
445+
446+func proxyResumeBackoff(attempt int) time.Duration {
447+ delay := proxyResumeInitialBackoff
448+ for range attempt {
449+ if delay >= proxyResumeMaxBackoff/2 {
450+ return proxyResumeMaxBackoff
451+ }
452+ delay *= 2
453+ }
454+ return min(delay, proxyResumeMaxBackoff)
455+}
456+
457+func waitProxyResume(ctx context.Context, clock ports.Clock, delay time.Duration) bool {
458+ if err := ctx.Err(); err != nil {
459+ return false
460+ }
461+ timer := clock.NewTimer(delay)
462+ if timer == nil {
463+ return false
464+ }
465+ defer timer.Stop()
466+ select {
467+ case <-ctx.Done():
468+ return false
469+ case <-timer.C():
470+ return true
471+ }
472+}
473+
474+func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generation uint64, transport ports.Transport) proxyLinkResult {
475+ recvCtx, cancelRecv := context.WithCancel(ctx)
476+ recv := make(chan proxyRecvResult, 1)
477+ recvDone := make(chan struct{})
478+ go func() {
479+ defer close(recvDone)
480+ for {
481+ frame, err := transport.Recv()
482+ select {
483+ case recv <- proxyRecvResult{frame: frame, err: err}:
484+ case <-recvCtx.Done():
485+ return
486+ }
487+ if err != nil {
488+ return
489+ }
490+ }
491+ }()
492+ defer func() {
493+ cancelRecv()
494+ _ = transport.Close()
495+ <-recvDone
496+ }()
497+
498+ var events <-chan ports.LinkEvent
499+ if reporter, ok := transport.(ports.LinkStateReporter); ok {
500+ events = reporter.LinkEvents()
501+ }
502+ for {
503+ select {
504+ case <-ctx.Done():
505+ return proxyLinkStop
506+ case event, ok := <-events:
507+ if !ok {
508+ events = nil
509+ continue
510+ }
511+ if !d.updateProxyLinkState(p, generation, transport, event.State) {
512+ return proxyLinkStop
513+ }
514+ if event.State == ports.LinkStateOffline || event.State == ports.LinkStateDead {
515+ _ = transport.Close()
516+ return proxyLinkResume
517+ }
518+ case received := <-recv:
519+ if received.err != nil {
520+ if ctx.Err() != nil {
521+ return proxyLinkStop
522+ }
523+ return proxyLinkResume
524+ }
525+ result, err := d.handleLinkFrame(p, generation, received.frame)
526+ if err != nil {
527+ // A failed reply says nothing about the remote's framing, so it
528+ // retires this transport instead of the whole proxy link.
529+ if errors.Is(err, errProxyLinkSend) {
530+ d.log.Warn("proxy link reply failed", "host", p.key.Host, "session", p.key.Name, "err", err)
531+ return proxyLinkResume
532+ }
533+ d.log.Warn("invalid proxy link frame", "host", p.key.Host, "session", p.key.Name, "err", err)
534+ return proxyLinkStop
535+ }
536+ if result != proxyLinkResume {
537+ return result
538+ }
539+ }
540+ }
541+}
542+
543+// handleLinkFrame returns proxyLinkResume to mean "continue this transport";
544+// the other values are terminal actions.
545+func (d *Daemon) handleLinkFrame(p *proxySession, generation uint64, frame ports.Frame) (proxyLinkResult, error) {
546+ switch frame.Type {
547+ case ports.MsgOutput:
548+ out, err := ports.UnmarshalOutput(frame.Payload)
549+ if err != nil {
550+ return proxyLinkStop, err
551+ }
552+ if err := d.handleProxyOutput(p, generation, out); err != nil {
553+ return proxyLinkStop, err
554+ }
555+ return proxyLinkResume, nil
556+ case ports.MsgSessionMeta:
557+ meta, err := ports.UnmarshalSessionMeta(frame.Payload)
558+ if err != nil {
559+ return proxyLinkStop, err
560+ }
561+ if _, err := d.applyProxySessionMeta(p, generation, meta); err != nil {
562+ return proxyLinkStop, err
563+ }
564+ return proxyLinkResume, nil
565+ case ports.MsgCommandResult:
566+ result, err := ports.UnmarshalCommandResult(frame.Payload)
567+ if err != nil {
568+ return proxyLinkStop, err
569+ }
570+ p.completeCommandResult(generation, result)
571+ return proxyLinkResume, nil
572+ case ports.MsgPong:
573+ _, err := ports.UnmarshalPong(frame.Payload)
574+ return proxyLinkResume, err
575+ case ports.MsgPing:
576+ if _, err := ports.UnmarshalPing(frame.Payload); err != nil {
577+ return proxyLinkStop, err
578+ }
579+ if err := p.sendGeneration(generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}); err != nil {
580+ return proxyLinkStop, fmt.Errorf("proxy pong reply: %w: %w", errProxyLinkSend, err)
581+ }
582+ return proxyLinkResume, nil
583+ case ports.MsgDetached:
584+ detached, err := ports.UnmarshalDetached(frame.Payload)
585+ if err != nil {
586+ return proxyLinkStop, err
587+ }
588+ if detached.Reason == ports.ReasonReplaced {
589+ return proxyLinkReplaced, nil
590+ }
591+ return proxyLinkStop, nil
592+ case ports.MsgError:
593+ remoteErr, err := ports.UnmarshalErrorMsg(frame.Payload)
594+ if err != nil {
595+ return proxyLinkStop, err
596+ }
597+ return proxyLinkStop, fmt.Errorf("remote proxy error (%d): %s", remoteErr.Code, remoteErr.Text)
598+ default:
599+ return proxyLinkStop, fmt.Errorf("unexpected proxy server frame type %d", frame.Type)
600+ }
601+}
A internal/usecase/daemon/proxy_output.go +118 -0
@@ -0,0 +1,118 @@
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+ // A retransmitted full paint must not clobber a newer screen or regress
43+ // the applied state. Only a reset this proxy actually asked for may
44+ // replay a state it has already applied.
45+ if !p.awaitingReset && out.NewStateNum <= p.appliedState {
46+ return 0, false, false
47+ }
48+ p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)
49+ p.screen.Write(data)
50+ p.appliedState = out.NewStateNum
51+ p.awaitingReset = false
52+ return out.NewStateNum, false, true
53+ }
54+ // Stateful frames are consecutive by construction. A duplicate or an
55+ // otherwise non-monotonic state is invalid just like a missing dependency.
56+ if p.awaitingReset || out.BaseStateNum != p.appliedState || out.NewStateNum != out.BaseStateNum+1 {
57+ if !p.awaitingReset {
58+ p.awaitingReset = true
59+ requestReset = true
60+ }
61+ return 0, requestReset, false
62+ }
63+ p.screen.Write(data)
64+ p.appliedState = out.NewStateNum
65+ return out.NewStateNum, false, len(data) != 0
66+}
67+
68+// handleProxyOutput completes output handling after applyOutputForGeneration
69+// has released proxy.mu. In particular, transport I/O and local render
70+// invalidation must never run under the VT lock.
71+func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports.Output) error {
72+ ack, requestReset, changed := p.applyOutputForGeneration(generation, out)
73+ if ack != 0 {
74+ if err := p.sendGeneration(generation, ports.Frame{
75+ Type: ports.MsgAck,
76+ Payload: ports.MarshalAck(ports.Ack{AckedStateNum: ack}),
77+ }); err != nil {
78+ return fmt.Errorf("proxy output ACK: %w: %w", errProxyLinkSend, err)
79+ }
80+ }
81+ if requestReset {
82+ if err := p.sendGeneration(generation, ports.Frame{
83+ Type: ports.MsgOutputResetRequest,
84+ Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
85+ }); err != nil {
86+ return fmt.Errorf("proxy output reset request: %w: %w", errProxyLinkSend, err)
87+ }
88+ }
89+ if !changed || !p.currentLinkGeneration(generation) {
90+ return nil
91+ }
92+
93+ p.sessionCore.mu.Lock()
94+ ac := p.client
95+ p.sessionCore.mu.Unlock()
96+ d.invalidateRender(p, ac, false, "proxy_output.go")
97+ return nil
98+}
99+
100+// resetOutputState discards the dependency chain after a local content resize.
101+// The next incremental remote frame is rejected and triggers a request for an
102+// authoritative base-zero paint.
103+func (p *proxySession) resetOutputState() {
104+ p.mu.Lock()
105+ defer p.mu.Unlock()
106+ p.resetOutputStateLocked()
107+}
108+
109+func (p *proxySession) resetOutputStateLocked() {
110+ p.appliedState = 0
111+ p.awaitingReset = false
112+}
113+
114+func (p *proxySession) currentLinkGeneration(generation uint64) bool {
115+ p.mu.Lock()
116+ defer p.mu.Unlock()
117+ return p.linkGeneration == generation
118+}
@@ -0,0 +1,118 @@
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+ // A retransmitted full paint must not clobber a newer screen or regress
43+ // the applied state. Only a reset this proxy actually asked for may
44+ // replay a state it has already applied.
45+ if !p.awaitingReset && out.NewStateNum <= p.appliedState {
46+ return 0, false, false
47+ }
48+ p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)
49+ p.screen.Write(data)
50+ p.appliedState = out.NewStateNum
51+ p.awaitingReset = false
52+ return out.NewStateNum, false, true
53+ }
54+ // Stateful frames are consecutive by construction. A duplicate or an
55+ // otherwise non-monotonic state is invalid just like a missing dependency.
56+ if p.awaitingReset || out.BaseStateNum != p.appliedState || out.NewStateNum != out.BaseStateNum+1 {
57+ if !p.awaitingReset {
58+ p.awaitingReset = true
59+ requestReset = true
60+ }
61+ return 0, requestReset, false
62+ }
63+ p.screen.Write(data)
64+ p.appliedState = out.NewStateNum
65+ return out.NewStateNum, false, len(data) != 0
66+}
67+
68+// handleProxyOutput completes output handling after applyOutputForGeneration
69+// has released proxy.mu. In particular, transport I/O and local render
70+// invalidation must never run under the VT lock.
71+func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports.Output) error {
72+ ack, requestReset, changed := p.applyOutputForGeneration(generation, out)
73+ if ack != 0 {
74+ if err := p.sendGeneration(generation, ports.Frame{
75+ Type: ports.MsgAck,
76+ Payload: ports.MarshalAck(ports.Ack{AckedStateNum: ack}),
77+ }); err != nil {
78+ return fmt.Errorf("proxy output ACK: %w: %w", errProxyLinkSend, err)
79+ }
80+ }
81+ if requestReset {
82+ if err := p.sendGeneration(generation, ports.Frame{
83+ Type: ports.MsgOutputResetRequest,
84+ Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
85+ }); err != nil {
86+ return fmt.Errorf("proxy output reset request: %w: %w", errProxyLinkSend, err)
87+ }
88+ }
89+ if !changed || !p.currentLinkGeneration(generation) {
90+ return nil
91+ }
92+
93+ p.sessionCore.mu.Lock()
94+ ac := p.client
95+ p.sessionCore.mu.Unlock()
96+ d.invalidateRender(p, ac, false, "proxy_output.go")
97+ return nil
98+}
99+
100+// resetOutputState discards the dependency chain after a local content resize.
101+// The next incremental remote frame is rejected and triggers a request for an
102+// authoritative base-zero paint.
103+func (p *proxySession) resetOutputState() {
104+ p.mu.Lock()
105+ defer p.mu.Unlock()
106+ p.resetOutputStateLocked()
107+}
108+
109+func (p *proxySession) resetOutputStateLocked() {
110+ p.appliedState = 0
111+ p.awaitingReset = false
112+}
113+
114+func (p *proxySession) currentLinkGeneration(generation uint64) bool {
115+ p.mu.Lock()
116+ defer p.mu.Unlock()
117+ return p.linkGeneration == generation
118+}
A internal/usecase/daemon/proxy_output_test.go +749 -0
@@ -0,0 +1,749 @@
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+ // sendFails rejects every write while leaving Recv able to deliver queued
37+ // frames, so a reply failure can be observed without also ending the receive
38+ // pump.
39+ sendFails atomic.Bool
40+}
41+
42+func newProxyTestTransport() *proxyTestTransport {
43+ return newProxyTestTransportWithSendGate(nil, nil, 0)
44+}
45+
46+func newProxyTestTransportWithSendGate(gate <-chan struct{}, entered chan struct{}, ungatedSends int32) *proxyTestTransport {
47+ transport := &proxyTestTransport{
48+ recv: make(chan proxyRecv, 16),
49+ closed: make(chan struct{}),
50+ sent: make(chan ports.Frame, 32),
51+ sendGate: gate,
52+ sendEntered: entered,
53+ }
54+ transport.ungatedSends.Store(ungatedSends)
55+ return transport
56+}
57+
58+func (t *proxyTestTransport) Send(frame ports.Frame) error {
59+ if t.sending.Add(1) != 1 {
60+ t.concurrent.Store(true)
61+ }
62+ defer t.sending.Add(-1)
63+ if t.sendFails.Load() {
64+ return io.ErrClosedPipe
65+ }
66+ if t.sendGate != nil && t.ungatedSends.Add(-1) < 0 {
67+ if t.sendEntered != nil {
68+ select {
69+ case t.sendEntered <- struct{}{}:
70+ default:
71+ }
72+ }
73+ select {
74+ case <-t.sendGate:
75+ case <-t.closed:
76+ return io.ErrClosedPipe
77+ }
78+ }
79+ select {
80+ case t.sent <- frame:
81+ return nil
82+ case <-t.closed:
83+ return io.ErrClosedPipe
84+ }
85+}
86+
87+func (t *proxyTestTransport) Recv() (ports.Frame, error) {
88+ t.receiving.Add(1)
89+ defer t.receiving.Add(-1)
90+ select {
91+ case item := <-t.recv:
92+ return item.frame, item.err
93+ case <-t.closed:
94+ return ports.Frame{}, io.EOF
95+ }
96+}
97+
98+func (t *proxyTestTransport) Close() error {
99+ t.closeOnce.Do(func() { close(t.closed) })
100+ return nil
101+}
102+
103+func proxyWelcome(name string, token uint64, capabilities uint32) ports.Frame {
104+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
105+ SessionID: "remote-id",
106+ SessionName: name,
107+ ResumeToken: token,
108+ Capabilities: capabilities,
109+ })}
110+}
111+
112+func proxyMeta(name string) ports.Frame {
113+ payload, err := ports.MarshalSessionMeta(ports.SessionMeta{
114+ SessionName: name,
115+ Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}},
116+ })
117+ if err != nil {
118+ panic(err)
119+ }
120+ return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}
121+}
122+
123+// newProxyTestDaemon always installs a controllable clock so no proxy test can
124+// depend on wall-clock deadlines. Pass a signalClock to drive resume backoff.
125+func newProxyTestDaemon(t *testing.T, factory ports.RemoteDialerFactory, clk ports.Clock) *Daemon {
126+ t.Helper()
127+ if clk == nil {
128+ clk = stubClock{}
129+ }
130+ return New(nil, clk, slog.New(slog.NewTextHandler(io.Discard, nil)),
131+ WithRemoteDiscovery(nil, nil, nil, factory, ports.RemoteTransportUDP))
132+}
133+
134+// proxySessionSnapshot clones the registry under d.mu so assertions never read
135+// d.sessions while a registration writes it. Assertions run on the clone, after
136+// the lock has been released.
137+func proxySessionSnapshot(d *Daemon) map[domain.SessionID]attachmentSession {
138+ d.mu.Lock()
139+ defer d.mu.Unlock()
140+ clone := make(map[domain.SessionID]attachmentSession, len(d.sessions))
141+ for id, entry := range d.sessions {
142+ clone[id] = entry
143+ }
144+ return clone
145+}
146+
147+func proxyFactoryFor(t *testing.T, key domain.RemoteSessionKey, transports ...ports.Transport) ports.RemoteDialerFactory {
148+ t.Helper()
149+ factory := portsmocks.NewMockRemoteDialerFactory(t)
150+ for _, transport := range transports {
151+ dialer := portsmocks.NewMockDialer(t)
152+ factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
153+ dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
154+ }
155+ return factory
156+}
157+
158+func requireProxyHello(t *testing.T, tr *proxyTestTransport) ports.Hello {
159+ t.Helper()
160+ select {
161+ case frame := <-tr.sent:
162+ require.Equal(t, ports.MsgHello, frame.Type)
163+ hello, err := ports.UnmarshalHello(frame.Payload)
164+ require.NoError(t, err)
165+ return hello
166+ case <-time.After(time.Second):
167+ t.Fatal("timed out waiting for proxy Hello")
168+ return ports.Hello{}
169+ }
170+}
171+
172+func stopProxy(t *testing.T, p *proxySession) {
173+ t.Helper()
174+ p.stop()
175+ select {
176+ case <-p.done:
177+ case <-time.After(time.Second):
178+ t.Fatal("timed out waiting for proxy link to stop")
179+ }
180+}
181+
182+func TestProxyHandshakePublishesOnlyAfterWelcomeAndMetadata(t *testing.T) {
183+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
184+ tr := newProxyTestTransport()
185+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
186+
187+ result := make(chan struct {
188+ proxy *proxySession
189+ err error
190+ }, 1)
191+ go func() {
192+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
193+ result <- struct {
194+ proxy *proxySession
195+ err error
196+ }{proxy, err}
197+ }()
198+
199+ hello := requireProxyHello(t, tr)
200+ require.Equal(t, ports.ProtocolVersion, hello.Version)
201+ require.Equal(t, ports.IntentAttach, hello.Intent)
202+ require.True(t, hello.Proxied)
203+ require.Equal(t, key.Name, hello.Name)
204+ require.Equal(t, domain.Size{Cols: 80, Rows: 22}, hello.Size)
205+ require.Equal(t, uint8(maxUnackedOutputStates), hello.MaxOutputInFlight)
206+ require.Empty(t, proxySessionSnapshot(d), "Hello alone must not publish a proxy")
207+
208+ tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 44, ports.CapabilityResume|ports.CapabilityProxied)}
209+ require.Empty(t, proxySessionSnapshot(d), "Welcome alone must not publish a proxy")
210+ tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
211+
212+ created := awaitTestValue(t, result, "proxy handshake did not complete")
213+ require.NoError(t, created.err)
214+ require.Same(t, created.proxy, proxySessionSnapshot(d)[key.ID()])
215+ require.Equal(t, key, created.proxy.key)
216+ require.Equal(t, uint64(44), created.proxy.resumeToken)
217+ stopProxy(t, created.proxy)
218+}
219+
220+func TestProxyHandshakeRejectsInvalidWelcomeWithoutPublication(t *testing.T) {
221+ tests := []struct {
222+ name string
223+ first ports.Frame
224+ }{
225+ {name: "missing proxied capability", first: proxyWelcome("work", 1, ports.CapabilityResume)},
226+ {name: "malformed welcome", first: ports.Frame{Type: ports.MsgWelcome, Payload: []byte{0xff}}},
227+ {name: "wrong first type", first: proxyMeta("work")},
228+ }
229+ for _, tt := range tests {
230+ t.Run(tt.name, func(t *testing.T) {
231+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
232+ tr := newProxyTestTransport()
233+ tr.recv <- proxyRecv{frame: tt.first}
234+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
235+
236+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
237+ require.Error(t, err)
238+ require.Nil(t, proxy)
239+ require.Empty(t, proxySessionSnapshot(d))
240+ select {
241+ case <-tr.closed:
242+ default:
243+ t.Fatal("failed handshake transport was not closed")
244+ }
245+ })
246+ }
247+}
248+
249+func TestProxyRegistryDedupesByExactStructuredKey(t *testing.T) {
250+ firstKey := domain.RemoteSessionKey{Host: "build.example", Name: "work"}
251+ secondKey := domain.RemoteSessionKey{Host: "build", Name: "example-work"}
252+ firstTransport := newProxyTestTransport()
253+ firstTransport.recv <- proxyRecv{frame: proxyWelcome(firstKey.Name, 1, ports.CapabilityProxied)}
254+ firstTransport.recv <- proxyRecv{frame: proxyMeta(firstKey.Name)}
255+ secondTransport := newProxyTestTransport()
256+ secondTransport.recv <- proxyRecv{frame: proxyWelcome(secondKey.Name, 2, ports.CapabilityProxied)}
257+ secondTransport.recv <- proxyRecv{frame: proxyMeta(secondKey.Name)}
258+ factory := portsmocks.NewMockRemoteDialerFactory(t)
259+ for key, transport := range map[domain.RemoteSessionKey]ports.Transport{firstKey: firstTransport, secondKey: secondTransport} {
260+ dialer := portsmocks.NewMockDialer(t)
261+ factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
262+ dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
263+ }
264+ d := newProxyTestDaemon(t, factory, stubClock{})
265+
266+ first, err := d.openProxySession(context.Background(), firstKey, domain.Size{Cols: 80, Rows: 24})
267+ require.NoError(t, err)
268+ deduped, err := d.openProxySession(context.Background(), firstKey, domain.Size{Cols: 100, Rows: 30})
269+ require.NoError(t, err)
270+ require.Same(t, first, deduped)
271+ second, err := d.openProxySession(context.Background(), secondKey, domain.Size{Cols: 80, Rows: 24})
272+ require.NoError(t, err)
273+ require.NotSame(t, first, second)
274+ require.Len(t, proxySessionSnapshot(d), 2)
275+
276+ stopProxy(t, first)
277+ stopProxy(t, second)
278+}
279+
280+func TestProxyCancelDuringDialOrHandshakeNeverPublishes(t *testing.T) {
281+ t.Run("dial", func(t *testing.T) {
282+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
283+ factory := portsmocks.NewMockRemoteDialerFactory(t)
284+ dialer := portsmocks.NewMockDialer(t)
285+ factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
286+ dialEntered := make(chan struct{})
287+ dialer.EXPECT().Dial(mock.Anything).RunAndReturn(func(ctx context.Context) (ports.Transport, error) {
288+ close(dialEntered)
289+ <-ctx.Done()
290+ return nil, ctx.Err()
291+ }).Once()
292+ d := newProxyTestDaemon(t, factory, stubClock{})
293+ ctx, cancel := context.WithCancel(context.Background())
294+ result := make(chan struct {
295+ proxy *proxySession
296+ err error
297+ }, 1)
298+ go func() {
299+ proxy, err := d.openProxySession(ctx, key, domain.Size{Cols: 80, Rows: 24})
300+ result <- struct {
301+ proxy *proxySession
302+ err error
303+ }{proxy: proxy, err: err}
304+ }()
305+ awaitTestCompletion(t, dialEntered, "proxy dial did not start")
306+ cancel()
307+
308+ opened := awaitTestValue(t, result, "canceled proxy dial did not finish")
309+ proxy, err := opened.proxy, opened.err
310+ require.ErrorIs(t, err, context.Canceled)
311+ require.Nil(t, proxy)
312+ require.Empty(t, proxySessionSnapshot(d))
313+ })
314+
315+ t.Run("handshake", func(t *testing.T) {
316+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
317+ tr := newProxyTestTransport()
318+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
319+ ctx, cancel := context.WithCancel(context.Background())
320+ result := make(chan error, 1)
321+ go func() {
322+ _, err := d.openProxySession(ctx, key, domain.Size{Cols: 80, Rows: 24})
323+ result <- err
324+ }()
325+ requireProxyHello(t, tr)
326+ cancel()
327+ require.ErrorIs(t, awaitTestValue(t, result, "canceled proxy handshake did not finish"), context.Canceled)
328+ require.Empty(t, proxySessionSnapshot(d))
329+ select {
330+ case <-tr.closed:
331+ default:
332+ t.Fatal("cancellation did not close the handshaking transport")
333+ }
334+ })
335+}
336+
337+func TestProxyLinkResumesAndReplacementIsTerminal(t *testing.T) {
338+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
339+ first := newProxyTestTransport()
340+ first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 55, ports.CapabilityResume|ports.CapabilityProxied)}
341+ first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
342+ second := newProxyTestTransport()
343+ factory := proxyFactoryFor(t, key, first, second)
344+ // The resume backoff is a ports.Clock deadline, so it is fired rather than
345+ // waited out. The buffer also absorbs the warm timer armed by replacement.
346+ clock := &signalClock{timers: make(chan *signalTimer, 8)}
347+ d := newProxyTestDaemon(t, factory, clock)
348+
349+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
350+ require.NoError(t, err)
351+ firstHello := requireProxyHello(t, first)
352+ require.Equal(t, ports.IntentAttach, firstHello.Intent)
353+
354+ first.recv <- proxyRecv{err: io.EOF}
355+ backoff := awaitTestValue(t, clock.timers, "proxy resume backoff timer was not armed")
356+ require.Equal(t, proxyResumeInitialBackoff, backoff.duration)
357+ backoff.ch <- time.Time{}
358+ resumeHello := requireProxyHello(t, second)
359+ require.Equal(t, ports.IntentResume, resumeHello.Intent)
360+ require.Equal(t, uint64(55), resumeHello.ResumeToken)
361+ require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
362+ second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 66, ports.CapabilityResume|ports.CapabilityProxied)}
363+ second.recv <- proxyRecv{frame: proxyMeta(key.Name)}
364+ second.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced})}}
365+
366+ select {
367+ case <-proxy.done:
368+ case <-time.After(time.Second):
369+ t.Fatal("replacement did not terminate proxy link loop")
370+ }
371+ proxy.mu.Lock()
372+ require.True(t, proxy.expired)
373+ require.Equal(t, uint64(66), proxy.resumeToken)
374+ proxy.mu.Unlock()
375+ require.Same(t, proxy, proxySessionSnapshot(d)[key.ID()], "replacement leaves the expired proxy available for local presentation")
376+}
377+
378+func TestRunProxyTransportStopsReceivePumpAfterInvalidFrame(t *testing.T) {
379+ d, proxy, transport, generation := newProxyOutputSession(t)
380+ t.Cleanup(func() { _ = transport.Close() })
381+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}}
382+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}}
383+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}}
384+
385+ result := d.runProxyTransport(context.Background(), proxy, generation, transport)
386+ require.Equal(t, proxyLinkStop, result)
387+ select {
388+ case <-transport.closed:
389+ default:
390+ t.Fatal("runProxyTransport returned without closing its receive pump transport")
391+ }
392+ require.Zero(t, transport.receiving.Load(), "receive pump outlived transport consumer")
393+}
394+
395+func TestProxySenderSerializesAllTransportWrites(t *testing.T) {
396+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
397+ gate := make(chan struct{})
398+ entered := make(chan struct{}, 1)
399+ tr := newProxyTestTransportWithSendGate(gate, entered, 1)
400+ tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
401+ tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
402+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
403+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
404+ require.NoError(t, err)
405+ _ = requireProxyHello(t, tr)
406+ var wg sync.WaitGroup
407+ errs := make(chan error, 8)
408+ for range 8 {
409+ wg.Add(1)
410+ go func() {
411+ defer wg.Done()
412+ errs <- proxy.sendCurrent(ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})})
413+ }()
414+ }
415+ // Once the first sender enters Send, the others must remain behind sendMu.
416+ awaitTestCompletion(t, entered, "proxy sender did not enter transport")
417+ close(gate)
418+ wg.Wait()
419+ close(errs)
420+ for err := range errs {
421+ require.NoError(t, err)
422+ }
423+ require.False(t, tr.concurrent.Load(), "Transport.Send calls overlapped")
424+ stopProxy(t, proxy)
425+}
426+
427+func TestProxyHandshakeRejectsInvalidInitialMetadata(t *testing.T) {
428+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
429+ tr := newProxyTestTransport()
430+ tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
431+ tr.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgSessionMeta, Payload: []byte{0xff}}}
432+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
433+
434+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
435+ require.Error(t, err)
436+ require.Nil(t, proxy)
437+ require.Empty(t, proxySessionSnapshot(d))
438+}
439+
440+func TestProxyOpenValidatesDependenciesAndKey(t *testing.T) {
441+ d := newProxyTestDaemon(t, nil, stubClock{})
442+ _, err := d.openProxySession(context.Background(), domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
443+ require.Error(t, err)
444+
445+ factory := portsmocks.NewMockRemoteDialerFactory(t)
446+ d = newProxyTestDaemon(t, factory, stubClock{})
447+ _, err = d.openProxySession(context.Background(), domain.RemoteSessionKey{Host: "arch", Name: "bad:name"}, domain.Size{Cols: 80, Rows: 24})
448+ require.Error(t, err)
449+ require.Empty(t, proxySessionSnapshot(d))
450+}
451+
452+func newProxyOutputSession(t *testing.T) (*Daemon, *proxySession, *proxyTestTransport, uint64) {
453+ t.Helper()
454+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
455+ require.NoError(t, err)
456+ transport := newProxyTestTransport()
457+ generation, _ := proxy.installTransport(transport)
458+ return newProxyTestDaemon(t, nil, stubClock{}), proxy, transport, generation
459+}
460+
461+func proxyScreenText(p *proxySession) string {
462+ p.mu.Lock()
463+ defer p.mu.Unlock()
464+ return string([]rune{p.screen.Frame.At(0, 0).Rune, p.screen.Frame.At(1, 0).Rune})
465+}
466+
467+func proxyOutputState(p *proxySession) (uint64, bool) {
468+ p.mu.Lock()
469+ defer p.mu.Unlock()
470+ return p.appliedState, p.awaitingReset
471+}
472+
473+func TestProxyOutputStateMachine(t *testing.T) {
474+ tests := []struct {
475+ name string
476+ initial *ports.Output
477+ output ports.Output
478+ wantAck uint64
479+ wantReset bool
480+ wantChanged bool
481+ wantState uint64
482+ wantAwaiting bool
483+ wantScreen string
484+ }{
485+ {
486+ name: "base zero initial reset",
487+ output: ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
488+ wantAck: 1,
489+ wantChanged: true,
490+ wantState: 1,
491+ wantScreen: "A ",
492+ },
493+ {
494+ name: "matching increment",
495+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
496+ output: ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")},
497+ wantAck: 2,
498+ wantChanged: true,
499+ wantState: 2,
500+ wantScreen: "AB",
501+ },
502+ {
503+ name: "duplicate old state requests reset",
504+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 2, Data: []byte("A")},
505+ output: ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")},
506+ wantReset: true,
507+ wantState: 2,
508+ wantAwaiting: true,
509+ wantScreen: "A ",
510+ },
511+ {
512+ name: "gap requests reset",
513+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
514+ output: ports.Output{BaseStateNum: 3, NewStateNum: 4, Data: []byte("B")},
515+ wantReset: true,
516+ wantState: 1,
517+ wantAwaiting: true,
518+ wantScreen: "A ",
519+ },
520+ {
521+ name: "stateless side effect does not advance state",
522+ output: ports.Output{NewStateNum: 0, Data: []byte("!")},
523+ wantChanged: true,
524+ wantScreen: "! ",
525+ },
526+ {
527+ name: "stale base zero is ignored",
528+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("A")},
529+ output: ports.Output{BaseStateNum: 0, NewStateNum: 3, Data: []byte("B")},
530+ wantState: 5,
531+ wantScreen: "A ",
532+ },
533+ {
534+ name: "duplicate base zero is ignored",
535+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("A")},
536+ output: ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("B")},
537+ wantState: 5,
538+ wantScreen: "A ",
539+ },
540+ }
541+ for _, tt := range tests {
542+ t.Run(tt.name, func(t *testing.T) {
543+ _, proxy, _, generation := newProxyOutputSession(t)
544+ if tt.initial != nil {
545+ _, _, _ = proxy.applyOutputForGeneration(generation, *tt.initial)
546+ }
547+
548+ ack, reset, changed := proxy.applyOutput(tt.output)
549+ require.Equal(t, tt.wantAck, ack)
550+ require.Equal(t, tt.wantReset, reset)
551+ require.Equal(t, tt.wantChanged, changed)
552+ state, awaiting := proxyOutputState(proxy)
553+ require.Equal(t, tt.wantState, state)
554+ require.Equal(t, tt.wantAwaiting, awaiting)
555+ require.Equal(t, tt.wantScreen, proxyScreenText(proxy))
556+ })
557+ }
558+}
559+
560+// TestProxyOutputAwaitedResetAcceptsReplayedState fences the stale base-zero
561+// guard: a reset this proxy asked for must still be applied even when the
562+// remote numbers it at or below the state already applied.
563+func TestProxyOutputAwaitedResetAcceptsReplayedState(t *testing.T) {
564+ _, proxy, _, generation := newProxyOutputSession(t)
565+ _, _, _ = proxy.applyOutputForGeneration(generation, ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("A")})
566+ _, requestReset, _ := proxy.applyOutputForGeneration(generation, ports.Output{BaseStateNum: 9, NewStateNum: 10, Data: []byte("x")})
567+ require.True(t, requestReset)
568+
569+ ack, reset, changed := proxy.applyOutputForGeneration(generation, ports.Output{BaseStateNum: 0, NewStateNum: 3, Data: []byte("B")})
570+ require.Equal(t, uint64(3), ack)
571+ require.False(t, reset)
572+ require.True(t, changed)
573+ state, awaiting := proxyOutputState(proxy)
574+ require.Equal(t, uint64(3), state)
575+ require.False(t, awaiting)
576+ require.Equal(t, "B ", proxyScreenText(proxy))
577+}
578+
579+// TestProxyLinkReplySendFailureResumesInsteadOfStopping separates a broken
580+// write from a broken remote: the frame was well formed, so the proxy link is
581+// resumable and only this transport incarnation is retired.
582+func TestProxyLinkReplySendFailureResumesInsteadOfStopping(t *testing.T) {
583+ tests := []struct {
584+ name string
585+ frame ports.Frame
586+ }{
587+ {
588+ name: "pong reply",
589+ frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})},
590+ },
591+ {
592+ name: "output ack",
593+ frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})},
594+ },
595+ {
596+ name: "output reset request",
597+ frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 7, NewStateNum: 8, Data: []byte("A")})},
598+ },
599+ }
600+ for _, tt := range tests {
601+ t.Run(tt.name, func(t *testing.T) {
602+ d, proxy, transport, generation := newProxyOutputSession(t)
603+ t.Cleanup(func() { _ = transport.Close() })
604+ transport.sendFails.Store(true)
605+ transport.recv <- proxyRecv{frame: tt.frame}
606+
607+ result := d.runProxyTransport(context.Background(), proxy, generation, transport)
608+ require.Equal(t, proxyLinkResume, result, "a failed reply must retire the transport, not the proxy link")
609+ })
610+ }
611+}
612+
613+// TestProxyLinkProtocolErrorStillStops fences the sentinel so it cannot widen
614+// into "every error is resumable".
615+func TestProxyLinkProtocolErrorStillStops(t *testing.T) {
616+ d, proxy, transport, generation := newProxyOutputSession(t)
617+ t.Cleanup(func() { _ = transport.Close() })
618+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}}
619+
620+ require.Equal(t, proxyLinkStop, d.runProxyTransport(context.Background(), proxy, generation, transport))
621+}
622+
623+func TestProxyOutputMismatchSendsOneResetUntilBaseZero(t *testing.T) {
624+ d, proxy, transport, generation := newProxyOutputSession(t)
625+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
626+ require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "initial output was not acknowledged").Type)
627+
628+ mismatch := ports.Output{BaseStateNum: 3, NewStateNum: 4, Data: []byte("discard")}
629+ require.NoError(t, d.handleProxyOutput(proxy, generation, mismatch))
630+ reset := awaitTestValue(t, transport.sent, "output mismatch did not request reset")
631+ require.Equal(t, ports.MsgOutputResetRequest, reset.Type)
632+ require.NoError(t, d.handleProxyOutput(proxy, generation, mismatch))
633+ select {
634+ case frame := <-transport.sent:
635+ t.Fatalf("second mismatch sent %v, want no frame", frame.Type)
636+ default:
637+ }
638+
639+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("R")}))
640+ ack := awaitTestValue(t, transport.sent, "reset output was not acknowledged")
641+ require.Equal(t, ports.MsgAck, ack.Type)
642+ state, awaiting := proxyOutputState(proxy)
643+ require.Equal(t, uint64(5), state)
644+ require.False(t, awaiting)
645+ require.Equal(t, "R ", proxyScreenText(proxy))
646+}
647+
648+func TestProxyOutputMalformedOrStaleCannotAdvanceState(t *testing.T) {
649+ t.Run("malformed", func(t *testing.T) {
650+ d, proxy, _, generation := newProxyOutputSession(t)
651+ result, err := d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}})
652+ require.Equal(t, proxyLinkStop, result)
653+ require.Error(t, err)
654+ state, awaiting := proxyOutputState(proxy)
655+ require.Zero(t, state)
656+ require.False(t, awaiting)
657+ })
658+
659+ t.Run("stale generation", func(t *testing.T) {
660+ d, proxy, transport, oldGeneration := newProxyOutputSession(t)
661+ newGeneration, _ := proxy.installTransport(newProxyTestTransport())
662+ require.NotEqual(t, oldGeneration, newGeneration)
663+ require.NoError(t, d.handleProxyOutput(proxy, oldGeneration, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
664+ state, awaiting := proxyOutputState(proxy)
665+ require.Zero(t, state)
666+ require.False(t, awaiting)
667+ require.Equal(t, " ", proxyScreenText(proxy))
668+ select {
669+ case frame := <-transport.sent:
670+ t.Fatalf("stale output sent %v", frame.Type)
671+ default:
672+ }
673+ })
674+}
675+
676+func TestProxyOutputAckFailureStillDoesNotApplyStaleState(t *testing.T) {
677+ d, proxy, transport, generation := newProxyOutputSession(t)
678+ transport.sendGate = make(chan struct{})
679+ require.NoError(t, transport.Close())
680+ err := d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})
681+ require.Error(t, err)
682+ state, _ := proxyOutputState(proxy)
683+ require.Equal(t, uint64(1), state, "ACK failure follows a successfully applied state")
684+}
685+
686+func TestProxyOutputReconnectRequiresBaseZero(t *testing.T) {
687+ d, proxy, first, generation := newProxyOutputSession(t)
688+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
689+ _ = awaitTestValue(t, first.sent, "initial output was not acknowledged") // ACK
690+
691+ second := newProxyTestTransport()
692+ generation, _ = proxy.installTransport(second)
693+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")}))
694+ frame := awaitTestValue(t, second.sent, "reconnected output did not request reset")
695+ require.Equal(t, ports.MsgOutputResetRequest, frame.Type)
696+ state, awaiting := proxyOutputState(proxy)
697+ require.Zero(t, state)
698+ require.True(t, awaiting)
699+ require.Equal(t, "A ", proxyScreenText(proxy))
700+
701+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 7, Data: []byte("R")}))
702+ frame = awaitTestValue(t, second.sent, "reset reconnected output was not acknowledged")
703+ require.Equal(t, ports.MsgAck, frame.Type)
704+ state, awaiting = proxyOutputState(proxy)
705+ require.Equal(t, uint64(7), state)
706+ require.False(t, awaiting)
707+}
708+
709+func TestProxyOutputResizeRequiresBaseZero(t *testing.T) {
710+ d, proxy, transport, generation := newProxyOutputSession(t)
711+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
712+ _ = awaitTestValue(t, transport.sent, "initial output was not acknowledged") // ACK
713+
714+ proxy.resetOutputState()
715+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")}))
716+ frame := awaitTestValue(t, transport.sent, "resize mismatch did not request reset")
717+ require.Equal(t, ports.MsgOutputResetRequest, frame.Type)
718+ state, awaiting := proxyOutputState(proxy)
719+ require.Zero(t, state)
720+ require.True(t, awaiting)
721+ require.Equal(t, "A ", proxyScreenText(proxy))
722+}
723+
724+func TestProxyOutputConcurrentCancelDoesNotHoldProxyLockDuringSend(t *testing.T) {
725+ d, proxy, transport, generation := newProxyOutputSession(t)
726+ gate := make(chan struct{})
727+ entered := make(chan struct{}, 1)
728+ transport.sendGate = gate
729+ transport.sendEntered = entered
730+
731+ result := make(chan error, 1)
732+ go func() {
733+ result <- d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})
734+ }()
735+ awaitTestCompletion(t, entered, "output sender did not enter transport")
736+
737+ locked := make(chan struct{})
738+ go func() {
739+ _, _ = proxyOutputState(proxy)
740+ close(locked)
741+ }()
742+ select {
743+ case <-locked:
744+ case <-time.After(time.Second):
745+ t.Fatal("proxy.mu was held while transport Send was blocked")
746+ }
747+ require.NoError(t, transport.Close())
748+ require.Error(t, awaitTestValue(t, result, "blocked output send did not return"))
749+}
@@ -0,0 +1,749 @@
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+ // sendFails rejects every write while leaving Recv able to deliver queued
37+ // frames, so a reply failure can be observed without also ending the receive
38+ // pump.
39+ sendFails atomic.Bool
40+}
41+
42+func newProxyTestTransport() *proxyTestTransport {
43+ return newProxyTestTransportWithSendGate(nil, nil, 0)
44+}
45+
46+func newProxyTestTransportWithSendGate(gate <-chan struct{}, entered chan struct{}, ungatedSends int32) *proxyTestTransport {
47+ transport := &proxyTestTransport{
48+ recv: make(chan proxyRecv, 16),
49+ closed: make(chan struct{}),
50+ sent: make(chan ports.Frame, 32),
51+ sendGate: gate,
52+ sendEntered: entered,
53+ }
54+ transport.ungatedSends.Store(ungatedSends)
55+ return transport
56+}
57+
58+func (t *proxyTestTransport) Send(frame ports.Frame) error {
59+ if t.sending.Add(1) != 1 {
60+ t.concurrent.Store(true)
61+ }
62+ defer t.sending.Add(-1)
63+ if t.sendFails.Load() {
64+ return io.ErrClosedPipe
65+ }
66+ if t.sendGate != nil && t.ungatedSends.Add(-1) < 0 {
67+ if t.sendEntered != nil {
68+ select {
69+ case t.sendEntered <- struct{}{}:
70+ default:
71+ }
72+ }
73+ select {
74+ case <-t.sendGate:
75+ case <-t.closed:
76+ return io.ErrClosedPipe
77+ }
78+ }
79+ select {
80+ case t.sent <- frame:
81+ return nil
82+ case <-t.closed:
83+ return io.ErrClosedPipe
84+ }
85+}
86+
87+func (t *proxyTestTransport) Recv() (ports.Frame, error) {
88+ t.receiving.Add(1)
89+ defer t.receiving.Add(-1)
90+ select {
91+ case item := <-t.recv:
92+ return item.frame, item.err
93+ case <-t.closed:
94+ return ports.Frame{}, io.EOF
95+ }
96+}
97+
98+func (t *proxyTestTransport) Close() error {
99+ t.closeOnce.Do(func() { close(t.closed) })
100+ return nil
101+}
102+
103+func proxyWelcome(name string, token uint64, capabilities uint32) ports.Frame {
104+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
105+ SessionID: "remote-id",
106+ SessionName: name,
107+ ResumeToken: token,
108+ Capabilities: capabilities,
109+ })}
110+}
111+
112+func proxyMeta(name string) ports.Frame {
113+ payload, err := ports.MarshalSessionMeta(ports.SessionMeta{
114+ SessionName: name,
115+ Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}},
116+ })
117+ if err != nil {
118+ panic(err)
119+ }
120+ return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}
121+}
122+
123+// newProxyTestDaemon always installs a controllable clock so no proxy test can
124+// depend on wall-clock deadlines. Pass a signalClock to drive resume backoff.
125+func newProxyTestDaemon(t *testing.T, factory ports.RemoteDialerFactory, clk ports.Clock) *Daemon {
126+ t.Helper()
127+ if clk == nil {
128+ clk = stubClock{}
129+ }
130+ return New(nil, clk, slog.New(slog.NewTextHandler(io.Discard, nil)),
131+ WithRemoteDiscovery(nil, nil, nil, factory, ports.RemoteTransportUDP))
132+}
133+
134+// proxySessionSnapshot clones the registry under d.mu so assertions never read
135+// d.sessions while a registration writes it. Assertions run on the clone, after
136+// the lock has been released.
137+func proxySessionSnapshot(d *Daemon) map[domain.SessionID]attachmentSession {
138+ d.mu.Lock()
139+ defer d.mu.Unlock()
140+ clone := make(map[domain.SessionID]attachmentSession, len(d.sessions))
141+ for id, entry := range d.sessions {
142+ clone[id] = entry
143+ }
144+ return clone
145+}
146+
147+func proxyFactoryFor(t *testing.T, key domain.RemoteSessionKey, transports ...ports.Transport) ports.RemoteDialerFactory {
148+ t.Helper()
149+ factory := portsmocks.NewMockRemoteDialerFactory(t)
150+ for _, transport := range transports {
151+ dialer := portsmocks.NewMockDialer(t)
152+ factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
153+ dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
154+ }
155+ return factory
156+}
157+
158+func requireProxyHello(t *testing.T, tr *proxyTestTransport) ports.Hello {
159+ t.Helper()
160+ select {
161+ case frame := <-tr.sent:
162+ require.Equal(t, ports.MsgHello, frame.Type)
163+ hello, err := ports.UnmarshalHello(frame.Payload)
164+ require.NoError(t, err)
165+ return hello
166+ case <-time.After(time.Second):
167+ t.Fatal("timed out waiting for proxy Hello")
168+ return ports.Hello{}
169+ }
170+}
171+
172+func stopProxy(t *testing.T, p *proxySession) {
173+ t.Helper()
174+ p.stop()
175+ select {
176+ case <-p.done:
177+ case <-time.After(time.Second):
178+ t.Fatal("timed out waiting for proxy link to stop")
179+ }
180+}
181+
182+func TestProxyHandshakePublishesOnlyAfterWelcomeAndMetadata(t *testing.T) {
183+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
184+ tr := newProxyTestTransport()
185+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
186+
187+ result := make(chan struct {
188+ proxy *proxySession
189+ err error
190+ }, 1)
191+ go func() {
192+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
193+ result <- struct {
194+ proxy *proxySession
195+ err error
196+ }{proxy, err}
197+ }()
198+
199+ hello := requireProxyHello(t, tr)
200+ require.Equal(t, ports.ProtocolVersion, hello.Version)
201+ require.Equal(t, ports.IntentAttach, hello.Intent)
202+ require.True(t, hello.Proxied)
203+ require.Equal(t, key.Name, hello.Name)
204+ require.Equal(t, domain.Size{Cols: 80, Rows: 22}, hello.Size)
205+ require.Equal(t, uint8(maxUnackedOutputStates), hello.MaxOutputInFlight)
206+ require.Empty(t, proxySessionSnapshot(d), "Hello alone must not publish a proxy")
207+
208+ tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 44, ports.CapabilityResume|ports.CapabilityProxied)}
209+ require.Empty(t, proxySessionSnapshot(d), "Welcome alone must not publish a proxy")
210+ tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
211+
212+ created := awaitTestValue(t, result, "proxy handshake did not complete")
213+ require.NoError(t, created.err)
214+ require.Same(t, created.proxy, proxySessionSnapshot(d)[key.ID()])
215+ require.Equal(t, key, created.proxy.key)
216+ require.Equal(t, uint64(44), created.proxy.resumeToken)
217+ stopProxy(t, created.proxy)
218+}
219+
220+func TestProxyHandshakeRejectsInvalidWelcomeWithoutPublication(t *testing.T) {
221+ tests := []struct {
222+ name string
223+ first ports.Frame
224+ }{
225+ {name: "missing proxied capability", first: proxyWelcome("work", 1, ports.CapabilityResume)},
226+ {name: "malformed welcome", first: ports.Frame{Type: ports.MsgWelcome, Payload: []byte{0xff}}},
227+ {name: "wrong first type", first: proxyMeta("work")},
228+ }
229+ for _, tt := range tests {
230+ t.Run(tt.name, func(t *testing.T) {
231+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
232+ tr := newProxyTestTransport()
233+ tr.recv <- proxyRecv{frame: tt.first}
234+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
235+
236+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
237+ require.Error(t, err)
238+ require.Nil(t, proxy)
239+ require.Empty(t, proxySessionSnapshot(d))
240+ select {
241+ case <-tr.closed:
242+ default:
243+ t.Fatal("failed handshake transport was not closed")
244+ }
245+ })
246+ }
247+}
248+
249+func TestProxyRegistryDedupesByExactStructuredKey(t *testing.T) {
250+ firstKey := domain.RemoteSessionKey{Host: "build.example", Name: "work"}
251+ secondKey := domain.RemoteSessionKey{Host: "build", Name: "example-work"}
252+ firstTransport := newProxyTestTransport()
253+ firstTransport.recv <- proxyRecv{frame: proxyWelcome(firstKey.Name, 1, ports.CapabilityProxied)}
254+ firstTransport.recv <- proxyRecv{frame: proxyMeta(firstKey.Name)}
255+ secondTransport := newProxyTestTransport()
256+ secondTransport.recv <- proxyRecv{frame: proxyWelcome(secondKey.Name, 2, ports.CapabilityProxied)}
257+ secondTransport.recv <- proxyRecv{frame: proxyMeta(secondKey.Name)}
258+ factory := portsmocks.NewMockRemoteDialerFactory(t)
259+ for key, transport := range map[domain.RemoteSessionKey]ports.Transport{firstKey: firstTransport, secondKey: secondTransport} {
260+ dialer := portsmocks.NewMockDialer(t)
261+ factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
262+ dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
263+ }
264+ d := newProxyTestDaemon(t, factory, stubClock{})
265+
266+ first, err := d.openProxySession(context.Background(), firstKey, domain.Size{Cols: 80, Rows: 24})
267+ require.NoError(t, err)
268+ deduped, err := d.openProxySession(context.Background(), firstKey, domain.Size{Cols: 100, Rows: 30})
269+ require.NoError(t, err)
270+ require.Same(t, first, deduped)
271+ second, err := d.openProxySession(context.Background(), secondKey, domain.Size{Cols: 80, Rows: 24})
272+ require.NoError(t, err)
273+ require.NotSame(t, first, second)
274+ require.Len(t, proxySessionSnapshot(d), 2)
275+
276+ stopProxy(t, first)
277+ stopProxy(t, second)
278+}
279+
280+func TestProxyCancelDuringDialOrHandshakeNeverPublishes(t *testing.T) {
281+ t.Run("dial", func(t *testing.T) {
282+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
283+ factory := portsmocks.NewMockRemoteDialerFactory(t)
284+ dialer := portsmocks.NewMockDialer(t)
285+ factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
286+ dialEntered := make(chan struct{})
287+ dialer.EXPECT().Dial(mock.Anything).RunAndReturn(func(ctx context.Context) (ports.Transport, error) {
288+ close(dialEntered)
289+ <-ctx.Done()
290+ return nil, ctx.Err()
291+ }).Once()
292+ d := newProxyTestDaemon(t, factory, stubClock{})
293+ ctx, cancel := context.WithCancel(context.Background())
294+ result := make(chan struct {
295+ proxy *proxySession
296+ err error
297+ }, 1)
298+ go func() {
299+ proxy, err := d.openProxySession(ctx, key, domain.Size{Cols: 80, Rows: 24})
300+ result <- struct {
301+ proxy *proxySession
302+ err error
303+ }{proxy: proxy, err: err}
304+ }()
305+ awaitTestCompletion(t, dialEntered, "proxy dial did not start")
306+ cancel()
307+
308+ opened := awaitTestValue(t, result, "canceled proxy dial did not finish")
309+ proxy, err := opened.proxy, opened.err
310+ require.ErrorIs(t, err, context.Canceled)
311+ require.Nil(t, proxy)
312+ require.Empty(t, proxySessionSnapshot(d))
313+ })
314+
315+ t.Run("handshake", func(t *testing.T) {
316+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
317+ tr := newProxyTestTransport()
318+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
319+ ctx, cancel := context.WithCancel(context.Background())
320+ result := make(chan error, 1)
321+ go func() {
322+ _, err := d.openProxySession(ctx, key, domain.Size{Cols: 80, Rows: 24})
323+ result <- err
324+ }()
325+ requireProxyHello(t, tr)
326+ cancel()
327+ require.ErrorIs(t, awaitTestValue(t, result, "canceled proxy handshake did not finish"), context.Canceled)
328+ require.Empty(t, proxySessionSnapshot(d))
329+ select {
330+ case <-tr.closed:
331+ default:
332+ t.Fatal("cancellation did not close the handshaking transport")
333+ }
334+ })
335+}
336+
337+func TestProxyLinkResumesAndReplacementIsTerminal(t *testing.T) {
338+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
339+ first := newProxyTestTransport()
340+ first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 55, ports.CapabilityResume|ports.CapabilityProxied)}
341+ first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
342+ second := newProxyTestTransport()
343+ factory := proxyFactoryFor(t, key, first, second)
344+ // The resume backoff is a ports.Clock deadline, so it is fired rather than
345+ // waited out. The buffer also absorbs the warm timer armed by replacement.
346+ clock := &signalClock{timers: make(chan *signalTimer, 8)}
347+ d := newProxyTestDaemon(t, factory, clock)
348+
349+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
350+ require.NoError(t, err)
351+ firstHello := requireProxyHello(t, first)
352+ require.Equal(t, ports.IntentAttach, firstHello.Intent)
353+
354+ first.recv <- proxyRecv{err: io.EOF}
355+ backoff := awaitTestValue(t, clock.timers, "proxy resume backoff timer was not armed")
356+ require.Equal(t, proxyResumeInitialBackoff, backoff.duration)
357+ backoff.ch <- time.Time{}
358+ resumeHello := requireProxyHello(t, second)
359+ require.Equal(t, ports.IntentResume, resumeHello.Intent)
360+ require.Equal(t, uint64(55), resumeHello.ResumeToken)
361+ require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
362+ second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 66, ports.CapabilityResume|ports.CapabilityProxied)}
363+ second.recv <- proxyRecv{frame: proxyMeta(key.Name)}
364+ second.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced})}}
365+
366+ select {
367+ case <-proxy.done:
368+ case <-time.After(time.Second):
369+ t.Fatal("replacement did not terminate proxy link loop")
370+ }
371+ proxy.mu.Lock()
372+ require.True(t, proxy.expired)
373+ require.Equal(t, uint64(66), proxy.resumeToken)
374+ proxy.mu.Unlock()
375+ require.Same(t, proxy, proxySessionSnapshot(d)[key.ID()], "replacement leaves the expired proxy available for local presentation")
376+}
377+
378+func TestRunProxyTransportStopsReceivePumpAfterInvalidFrame(t *testing.T) {
379+ d, proxy, transport, generation := newProxyOutputSession(t)
380+ t.Cleanup(func() { _ = transport.Close() })
381+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}}
382+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}}
383+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}}
384+
385+ result := d.runProxyTransport(context.Background(), proxy, generation, transport)
386+ require.Equal(t, proxyLinkStop, result)
387+ select {
388+ case <-transport.closed:
389+ default:
390+ t.Fatal("runProxyTransport returned without closing its receive pump transport")
391+ }
392+ require.Zero(t, transport.receiving.Load(), "receive pump outlived transport consumer")
393+}
394+
395+func TestProxySenderSerializesAllTransportWrites(t *testing.T) {
396+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
397+ gate := make(chan struct{})
398+ entered := make(chan struct{}, 1)
399+ tr := newProxyTestTransportWithSendGate(gate, entered, 1)
400+ tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
401+ tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
402+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
403+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
404+ require.NoError(t, err)
405+ _ = requireProxyHello(t, tr)
406+ var wg sync.WaitGroup
407+ errs := make(chan error, 8)
408+ for range 8 {
409+ wg.Add(1)
410+ go func() {
411+ defer wg.Done()
412+ errs <- proxy.sendCurrent(ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})})
413+ }()
414+ }
415+ // Once the first sender enters Send, the others must remain behind sendMu.
416+ awaitTestCompletion(t, entered, "proxy sender did not enter transport")
417+ close(gate)
418+ wg.Wait()
419+ close(errs)
420+ for err := range errs {
421+ require.NoError(t, err)
422+ }
423+ require.False(t, tr.concurrent.Load(), "Transport.Send calls overlapped")
424+ stopProxy(t, proxy)
425+}
426+
427+func TestProxyHandshakeRejectsInvalidInitialMetadata(t *testing.T) {
428+ key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
429+ tr := newProxyTestTransport()
430+ tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
431+ tr.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgSessionMeta, Payload: []byte{0xff}}}
432+ d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
433+
434+ proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
435+ require.Error(t, err)
436+ require.Nil(t, proxy)
437+ require.Empty(t, proxySessionSnapshot(d))
438+}
439+
440+func TestProxyOpenValidatesDependenciesAndKey(t *testing.T) {
441+ d := newProxyTestDaemon(t, nil, stubClock{})
442+ _, err := d.openProxySession(context.Background(), domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
443+ require.Error(t, err)
444+
445+ factory := portsmocks.NewMockRemoteDialerFactory(t)
446+ d = newProxyTestDaemon(t, factory, stubClock{})
447+ _, err = d.openProxySession(context.Background(), domain.RemoteSessionKey{Host: "arch", Name: "bad:name"}, domain.Size{Cols: 80, Rows: 24})
448+ require.Error(t, err)
449+ require.Empty(t, proxySessionSnapshot(d))
450+}
451+
452+func newProxyOutputSession(t *testing.T) (*Daemon, *proxySession, *proxyTestTransport, uint64) {
453+ t.Helper()
454+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
455+ require.NoError(t, err)
456+ transport := newProxyTestTransport()
457+ generation, _ := proxy.installTransport(transport)
458+ return newProxyTestDaemon(t, nil, stubClock{}), proxy, transport, generation
459+}
460+
461+func proxyScreenText(p *proxySession) string {
462+ p.mu.Lock()
463+ defer p.mu.Unlock()
464+ return string([]rune{p.screen.Frame.At(0, 0).Rune, p.screen.Frame.At(1, 0).Rune})
465+}
466+
467+func proxyOutputState(p *proxySession) (uint64, bool) {
468+ p.mu.Lock()
469+ defer p.mu.Unlock()
470+ return p.appliedState, p.awaitingReset
471+}
472+
473+func TestProxyOutputStateMachine(t *testing.T) {
474+ tests := []struct {
475+ name string
476+ initial *ports.Output
477+ output ports.Output
478+ wantAck uint64
479+ wantReset bool
480+ wantChanged bool
481+ wantState uint64
482+ wantAwaiting bool
483+ wantScreen string
484+ }{
485+ {
486+ name: "base zero initial reset",
487+ output: ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
488+ wantAck: 1,
489+ wantChanged: true,
490+ wantState: 1,
491+ wantScreen: "A ",
492+ },
493+ {
494+ name: "matching increment",
495+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
496+ output: ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")},
497+ wantAck: 2,
498+ wantChanged: true,
499+ wantState: 2,
500+ wantScreen: "AB",
501+ },
502+ {
503+ name: "duplicate old state requests reset",
504+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 2, Data: []byte("A")},
505+ output: ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")},
506+ wantReset: true,
507+ wantState: 2,
508+ wantAwaiting: true,
509+ wantScreen: "A ",
510+ },
511+ {
512+ name: "gap requests reset",
513+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
514+ output: ports.Output{BaseStateNum: 3, NewStateNum: 4, Data: []byte("B")},
515+ wantReset: true,
516+ wantState: 1,
517+ wantAwaiting: true,
518+ wantScreen: "A ",
519+ },
520+ {
521+ name: "stateless side effect does not advance state",
522+ output: ports.Output{NewStateNum: 0, Data: []byte("!")},
523+ wantChanged: true,
524+ wantScreen: "! ",
525+ },
526+ {
527+ name: "stale base zero is ignored",
528+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("A")},
529+ output: ports.Output{BaseStateNum: 0, NewStateNum: 3, Data: []byte("B")},
530+ wantState: 5,
531+ wantScreen: "A ",
532+ },
533+ {
534+ name: "duplicate base zero is ignored",
535+ initial: &ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("A")},
536+ output: ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("B")},
537+ wantState: 5,
538+ wantScreen: "A ",
539+ },
540+ }
541+ for _, tt := range tests {
542+ t.Run(tt.name, func(t *testing.T) {
543+ _, proxy, _, generation := newProxyOutputSession(t)
544+ if tt.initial != nil {
545+ _, _, _ = proxy.applyOutputForGeneration(generation, *tt.initial)
546+ }
547+
548+ ack, reset, changed := proxy.applyOutput(tt.output)
549+ require.Equal(t, tt.wantAck, ack)
550+ require.Equal(t, tt.wantReset, reset)
551+ require.Equal(t, tt.wantChanged, changed)
552+ state, awaiting := proxyOutputState(proxy)
553+ require.Equal(t, tt.wantState, state)
554+ require.Equal(t, tt.wantAwaiting, awaiting)
555+ require.Equal(t, tt.wantScreen, proxyScreenText(proxy))
556+ })
557+ }
558+}
559+
560+// TestProxyOutputAwaitedResetAcceptsReplayedState fences the stale base-zero
561+// guard: a reset this proxy asked for must still be applied even when the
562+// remote numbers it at or below the state already applied.
563+func TestProxyOutputAwaitedResetAcceptsReplayedState(t *testing.T) {
564+ _, proxy, _, generation := newProxyOutputSession(t)
565+ _, _, _ = proxy.applyOutputForGeneration(generation, ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("A")})
566+ _, requestReset, _ := proxy.applyOutputForGeneration(generation, ports.Output{BaseStateNum: 9, NewStateNum: 10, Data: []byte("x")})
567+ require.True(t, requestReset)
568+
569+ ack, reset, changed := proxy.applyOutputForGeneration(generation, ports.Output{BaseStateNum: 0, NewStateNum: 3, Data: []byte("B")})
570+ require.Equal(t, uint64(3), ack)
571+ require.False(t, reset)
572+ require.True(t, changed)
573+ state, awaiting := proxyOutputState(proxy)
574+ require.Equal(t, uint64(3), state)
575+ require.False(t, awaiting)
576+ require.Equal(t, "B ", proxyScreenText(proxy))
577+}
578+
579+// TestProxyLinkReplySendFailureResumesInsteadOfStopping separates a broken
580+// write from a broken remote: the frame was well formed, so the proxy link is
581+// resumable and only this transport incarnation is retired.
582+func TestProxyLinkReplySendFailureResumesInsteadOfStopping(t *testing.T) {
583+ tests := []struct {
584+ name string
585+ frame ports.Frame
586+ }{
587+ {
588+ name: "pong reply",
589+ frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})},
590+ },
591+ {
592+ name: "output ack",
593+ frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})},
594+ },
595+ {
596+ name: "output reset request",
597+ frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 7, NewStateNum: 8, Data: []byte("A")})},
598+ },
599+ }
600+ for _, tt := range tests {
601+ t.Run(tt.name, func(t *testing.T) {
602+ d, proxy, transport, generation := newProxyOutputSession(t)
603+ t.Cleanup(func() { _ = transport.Close() })
604+ transport.sendFails.Store(true)
605+ transport.recv <- proxyRecv{frame: tt.frame}
606+
607+ result := d.runProxyTransport(context.Background(), proxy, generation, transport)
608+ require.Equal(t, proxyLinkResume, result, "a failed reply must retire the transport, not the proxy link")
609+ })
610+ }
611+}
612+
613+// TestProxyLinkProtocolErrorStillStops fences the sentinel so it cannot widen
614+// into "every error is resumable".
615+func TestProxyLinkProtocolErrorStillStops(t *testing.T) {
616+ d, proxy, transport, generation := newProxyOutputSession(t)
617+ t.Cleanup(func() { _ = transport.Close() })
618+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}}
619+
620+ require.Equal(t, proxyLinkStop, d.runProxyTransport(context.Background(), proxy, generation, transport))
621+}
622+
623+func TestProxyOutputMismatchSendsOneResetUntilBaseZero(t *testing.T) {
624+ d, proxy, transport, generation := newProxyOutputSession(t)
625+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
626+ require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "initial output was not acknowledged").Type)
627+
628+ mismatch := ports.Output{BaseStateNum: 3, NewStateNum: 4, Data: []byte("discard")}
629+ require.NoError(t, d.handleProxyOutput(proxy, generation, mismatch))
630+ reset := awaitTestValue(t, transport.sent, "output mismatch did not request reset")
631+ require.Equal(t, ports.MsgOutputResetRequest, reset.Type)
632+ require.NoError(t, d.handleProxyOutput(proxy, generation, mismatch))
633+ select {
634+ case frame := <-transport.sent:
635+ t.Fatalf("second mismatch sent %v, want no frame", frame.Type)
636+ default:
637+ }
638+
639+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("R")}))
640+ ack := awaitTestValue(t, transport.sent, "reset output was not acknowledged")
641+ require.Equal(t, ports.MsgAck, ack.Type)
642+ state, awaiting := proxyOutputState(proxy)
643+ require.Equal(t, uint64(5), state)
644+ require.False(t, awaiting)
645+ require.Equal(t, "R ", proxyScreenText(proxy))
646+}
647+
648+func TestProxyOutputMalformedOrStaleCannotAdvanceState(t *testing.T) {
649+ t.Run("malformed", func(t *testing.T) {
650+ d, proxy, _, generation := newProxyOutputSession(t)
651+ result, err := d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}})
652+ require.Equal(t, proxyLinkStop, result)
653+ require.Error(t, err)
654+ state, awaiting := proxyOutputState(proxy)
655+ require.Zero(t, state)
656+ require.False(t, awaiting)
657+ })
658+
659+ t.Run("stale generation", func(t *testing.T) {
660+ d, proxy, transport, oldGeneration := newProxyOutputSession(t)
661+ newGeneration, _ := proxy.installTransport(newProxyTestTransport())
662+ require.NotEqual(t, oldGeneration, newGeneration)
663+ require.NoError(t, d.handleProxyOutput(proxy, oldGeneration, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
664+ state, awaiting := proxyOutputState(proxy)
665+ require.Zero(t, state)
666+ require.False(t, awaiting)
667+ require.Equal(t, " ", proxyScreenText(proxy))
668+ select {
669+ case frame := <-transport.sent:
670+ t.Fatalf("stale output sent %v", frame.Type)
671+ default:
672+ }
673+ })
674+}
675+
676+func TestProxyOutputAckFailureStillDoesNotApplyStaleState(t *testing.T) {
677+ d, proxy, transport, generation := newProxyOutputSession(t)
678+ transport.sendGate = make(chan struct{})
679+ require.NoError(t, transport.Close())
680+ err := d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})
681+ require.Error(t, err)
682+ state, _ := proxyOutputState(proxy)
683+ require.Equal(t, uint64(1), state, "ACK failure follows a successfully applied state")
684+}
685+
686+func TestProxyOutputReconnectRequiresBaseZero(t *testing.T) {
687+ d, proxy, first, generation := newProxyOutputSession(t)
688+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
689+ _ = awaitTestValue(t, first.sent, "initial output was not acknowledged") // ACK
690+
691+ second := newProxyTestTransport()
692+ generation, _ = proxy.installTransport(second)
693+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")}))
694+ frame := awaitTestValue(t, second.sent, "reconnected output did not request reset")
695+ require.Equal(t, ports.MsgOutputResetRequest, frame.Type)
696+ state, awaiting := proxyOutputState(proxy)
697+ require.Zero(t, state)
698+ require.True(t, awaiting)
699+ require.Equal(t, "A ", proxyScreenText(proxy))
700+
701+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 7, Data: []byte("R")}))
702+ frame = awaitTestValue(t, second.sent, "reset reconnected output was not acknowledged")
703+ require.Equal(t, ports.MsgAck, frame.Type)
704+ state, awaiting = proxyOutputState(proxy)
705+ require.Equal(t, uint64(7), state)
706+ require.False(t, awaiting)
707+}
708+
709+func TestProxyOutputResizeRequiresBaseZero(t *testing.T) {
710+ d, proxy, transport, generation := newProxyOutputSession(t)
711+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
712+ _ = awaitTestValue(t, transport.sent, "initial output was not acknowledged") // ACK
713+
714+ proxy.resetOutputState()
715+ require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")}))
716+ frame := awaitTestValue(t, transport.sent, "resize mismatch did not request reset")
717+ require.Equal(t, ports.MsgOutputResetRequest, frame.Type)
718+ state, awaiting := proxyOutputState(proxy)
719+ require.Zero(t, state)
720+ require.True(t, awaiting)
721+ require.Equal(t, "A ", proxyScreenText(proxy))
722+}
723+
724+func TestProxyOutputConcurrentCancelDoesNotHoldProxyLockDuringSend(t *testing.T) {
725+ d, proxy, transport, generation := newProxyOutputSession(t)
726+ gate := make(chan struct{})
727+ entered := make(chan struct{}, 1)
728+ transport.sendGate = gate
729+ transport.sendEntered = entered
730+
731+ result := make(chan error, 1)
732+ go func() {
733+ result <- d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})
734+ }()
735+ awaitTestCompletion(t, entered, "output sender did not enter transport")
736+
737+ locked := make(chan struct{})
738+ go func() {
739+ _, _ = proxyOutputState(proxy)
740+ close(locked)
741+ }()
742+ select {
743+ case <-locked:
744+ case <-time.After(time.Second):
745+ t.Fatal("proxy.mu was held while transport Send was blocked")
746+ }
747+ require.NoError(t, transport.Close())
748+ require.Error(t, awaitTestValue(t, result, "blocked output send did not return"))
749+}
A internal/usecase/daemon/proxy_render.go +137 -0
@@ -0,0 +1,137 @@
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. Local chrome is repainted whenever the local content
88+// rectangle moved, including when the remote never learned about it: the local
89+// VT has already been resized either way, so skipping the repaint would leave
90+// the client showing chrome for the old geometry.
91+func (d *Daemon) resizeProxyForLease(p *proxySession, ac *attachedClient, lease *attachmentLease, size domain.Size) {
92+ if p == nil || ac == nil || !size.Valid() {
93+ return
94+ }
95+ if rc := attachmentRenderCoordinator(p); rc != nil && !rc.leaseCurrent(lease, false) {
96+ return
97+ }
98+ if changed, _ := p.resize(size); !changed {
99+ return
100+ }
101+ if rc := attachmentRenderCoordinator(p); rc != nil {
102+ rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "proxy_render.go"})
103+ return
104+ }
105+ d.invalidateRender(p, ac, true, "proxy_render.go")
106+}
107+
108+// resize updates the local VT to the local content rectangle and sends that
109+// reduced geometry once to the remote daemon. The sender performs transport
110+// I/O only after proxy.mu has been released.
111+//
112+// changed reports whether the local content rectangle actually moved, which is
113+// what local chrome repaints depend on. sent reports whether the remote was
114+// told. The two are deliberately separate: a failed send leaves the local VT
115+// already resized, so the attached client still owes a repaint.
116+func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
117+ if p == nil || !size.Valid() {
118+ return false, false
119+ }
120+ content := contentSize(size, false)
121+ p.mu.Lock()
122+ if p.screen == nil || p.contentSize == content {
123+ available := p.screen != nil
124+ p.mu.Unlock()
125+ return false, available
126+ }
127+ p.screen.Resize(content.Cols, content.Rows)
128+ p.contentSize = content
129+ p.resetOutputStateLocked()
130+ generation := p.linkGeneration
131+ p.mu.Unlock()
132+
133+ return true, p.sendGeneration(generation, ports.Frame{
134+ Type: ports.MsgResize,
135+ Payload: ports.MarshalResize(ports.Resize{Size: content}),
136+ }) == nil
137+}
@@ -0,0 +1,137 @@
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. Local chrome is repainted whenever the local content
88+// rectangle moved, including when the remote never learned about it: the local
89+// VT has already been resized either way, so skipping the repaint would leave
90+// the client showing chrome for the old geometry.
91+func (d *Daemon) resizeProxyForLease(p *proxySession, ac *attachedClient, lease *attachmentLease, size domain.Size) {
92+ if p == nil || ac == nil || !size.Valid() {
93+ return
94+ }
95+ if rc := attachmentRenderCoordinator(p); rc != nil && !rc.leaseCurrent(lease, false) {
96+ return
97+ }
98+ if changed, _ := p.resize(size); !changed {
99+ return
100+ }
101+ if rc := attachmentRenderCoordinator(p); rc != nil {
102+ rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "proxy_render.go"})
103+ return
104+ }
105+ d.invalidateRender(p, ac, true, "proxy_render.go")
106+}
107+
108+// resize updates the local VT to the local content rectangle and sends that
109+// reduced geometry once to the remote daemon. The sender performs transport
110+// I/O only after proxy.mu has been released.
111+//
112+// changed reports whether the local content rectangle actually moved, which is
113+// what local chrome repaints depend on. sent reports whether the remote was
114+// told. The two are deliberately separate: a failed send leaves the local VT
115+// already resized, so the attached client still owes a repaint.
116+func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
117+ if p == nil || !size.Valid() {
118+ return false, false
119+ }
120+ content := contentSize(size, false)
121+ p.mu.Lock()
122+ if p.screen == nil || p.contentSize == content {
123+ available := p.screen != nil
124+ p.mu.Unlock()
125+ return false, available
126+ }
127+ p.screen.Resize(content.Cols, content.Rows)
128+ p.contentSize = content
129+ p.resetOutputStateLocked()
130+ generation := p.linkGeneration
131+ p.mu.Unlock()
132+
133+ return true, p.sendGeneration(generation, ports.Frame{
134+ Type: ports.MsgResize,
135+ Payload: ports.MarshalResize(ports.Resize{Size: content}),
136+ }) == nil
137+}
A internal/usecase/daemon/proxy_render_test.go +242 -0
@@ -0,0 +1,242 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
10+ "github.com/bnema/vev/pkg/renderer"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestProxyRenderComposesRemoteVTUnderExactlyOneLocalChrome(t *testing.T) {
16+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
17+ require.NoError(t, err)
18+ proxy.mu.Lock()
19+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
20+ proxy.mu.Unlock()
21+ _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2;3Hremote")})
22+ require.True(t, changed)
23+
24+ state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{
25+ bars: barState{status: proxy.statusSegments(false)},
26+ reset: true,
27+ })
28+ require.True(t, ok)
29+ composed := composeFrame(*state, composeCacheInput{})
30+
31+ require.Equal(t, 6, composed.frame.Height)
32+ require.Contains(t, proxyRowText(composed.frame.Row(0)), "shell", "one local top bar")
33+ require.Contains(t, proxyRowText(composed.frame.Row(5)), "work@arch", "one local bottom bar")
34+ require.NotContains(t, proxyRowText(composed.frame.Row(1)), "shell")
35+ require.NotContains(t, proxyRowText(composed.frame.Row(4)), "work@arch")
36+ require.Equal(t, 'r', composed.frame.At(2, 2).Rune, "ANSI was applied only by the local VT")
37+ require.False(t, composed.cursor.hidden)
38+ require.Equal(t, 2, composed.cursor.row, "cursor includes exactly the one local top bar")
39+ require.Contains(t, composed.damage, renderer.FullRedraw())
40+}
41+
42+func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) {
43+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
44+ require.NoError(t, err)
45+ proxy.mu.Lock()
46+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
47+ proxy.mu.Unlock()
48+ _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")})
49+ require.True(t, changed)
50+
51+ d := newTestDaemon(t, nil, stubClock{})
52+ transport, sent := newCapturingTransport(t)
53+ ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}
54+ ac.setSession(proxy)
55+ proxy.sessionCore.mu.Lock()
56+ proxy.client = ac
57+ proxy.sessionCore.mu.Unlock()
58+
59+ d.paint(proxy, ac, true, nil)
60+ frame := awaitTestValue(t, sent, "handoff to remote proxy did not paint")
61+ require.Equal(t, ports.MsgOutput, frame.Type, "handoff to remote proxy gets a local first paint")
62+}
63+
64+func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
65+ d, local, ac, sent := newManualSessionWithPTYs(t, newQuietPTY())
66+ localCoordinator := d.attachCoordinator(local, nil, ac, true)
67+ localToken := attachmentToken(local, ac, ac.transport())
68+ localToken.lease = localCoordinator.attachmentLease(ac)
69+
70+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, ac.size)
71+ require.NoError(t, err)
72+ proxy.mu.Lock()
73+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
74+ proxy.mu.Unlock()
75+ _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")})
76+ require.True(t, changed)
77+ d.mu.Lock()
78+ require.True(t, d.registerSessionLocked(proxy))
79+ d.mu.Unlock()
80+
81+ toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
82+ source: local, target: proxy, next: ac,
83+ expectedRole: attachmentActive, targetRole: attachmentActive,
84+ expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
85+ action: "test transition", ready: true,
86+ })
87+ require.NoError(t, err)
88+ require.NotNil(t, toProxy.published.lease, "proxy transition installs a coordinator lease")
89+ require.True(t, d.firstPaintForTransition(toProxy.published))
90+ require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to proxy did not paint").Type)
91+
92+ toLocal, err := d.transitionAttachment(attachmentTransitionRequest{
93+ source: proxy, target: local, next: ac,
94+ expectedRole: attachmentActive, targetRole: attachmentActive,
95+ expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
96+ action: "test transition", ready: true,
97+ })
98+ require.NoError(t, err)
99+ require.True(t, d.firstPaintForTransition(toLocal.published))
100+ require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to local session did not paint").Type)
101+}
102+
103+func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) {
104+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 12, Rows: 8})
105+ require.NoError(t, err)
106+ transport := newProxyTestTransport()
107+ proxy.mu.Lock()
108+ proxy.transport = transport
109+ proxy.linkGeneration = 1
110+ proxy.mu.Unlock()
111+
112+ changed, sent := proxy.resize(domain.Size{Cols: 20, Rows: 10})
113+ require.True(t, changed)
114+ require.True(t, sent)
115+ frame := awaitTestValue(t, transport.sent, "proxy resize did not reach remote transport")
116+ require.Equal(t, ports.MsgResize, frame.Type)
117+ resize, err := ports.UnmarshalResize(frame.Payload)
118+ require.NoError(t, err)
119+ require.Equal(t, domain.Size{Cols: 20, Rows: 8}, resize.Size)
120+ select {
121+ case extra := <-transport.sent:
122+ t.Fatalf("unexpected second remote resize: %#v", extra)
123+ default:
124+ }
125+
126+ state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{reset: true})
127+ require.True(t, ok)
128+ composed := composeFrame(*state, composeCacheInput{})
129+ require.Equal(t, 10, composed.frame.Height, "local viewport remains full-sized")
130+ require.Equal(t, 8, state.layout.area.Height)
131+}
132+
133+// newAttachedProxyFixture publishes one proxy with a local thin client bound to
134+// clientTr and its remote link bound to remoteTr, using no render coordinator so
135+// invalidation paints synchronously.
136+func newAttachedProxyFixture(t *testing.T, d *Daemon, clientTr ports.Transport, remoteTr ports.Transport) (*proxySession, *attachedClient) {
137+ t.Helper()
138+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
139+ require.NoError(t, err)
140+ proxy.mu.Lock()
141+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
142+ proxy.transport = remoteTr
143+ proxy.linkGeneration = 1
144+ proxy.mu.Unlock()
145+ d.mu.Lock()
146+ require.True(t, d.registerSessionLocked(proxy))
147+ d.mu.Unlock()
148+
149+ ac := &attachedClient{tr: clientTr, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}
150+ ac.setSession(proxy)
151+ proxy.sessionCore.mu.Lock()
152+ proxy.client = ac
153+ proxy.sessionCore.mu.Unlock()
154+ return proxy, ac
155+}
156+
157+func newFailingTransport(t *testing.T) *portsmocks.MockTransport {
158+ t.Helper()
159+ tr := portsmocks.NewMockTransport(t)
160+ tr.EXPECT().Send(mock.Anything).Return(io.ErrClosedPipe).Maybe()
161+ tr.EXPECT().Close().Return(nil).Maybe()
162+ return tr
163+}
164+
165+// TestProxyResizeRepaintsLocalChromeWhenRemoteSendFails covers the split
166+// between "the local content rectangle moved" and "the remote was told". The
167+// local VT has already been resized, so the client owes a repaint either way.
168+func TestProxyResizeRepaintsLocalChromeWhenRemoteSendFails(t *testing.T) {
169+ d := newTestDaemon(t, nil, stubClock{})
170+ clientTr, sent := newCapturingTransport(t)
171+ remote := newProxyTestTransport()
172+ remote.sendFails.Store(true)
173+ proxy, ac := newAttachedProxyFixture(t, d, clientTr, remote)
174+
175+ d.resizeProxyForLease(proxy, ac, nil, domain.Size{Cols: 24, Rows: 10})
176+
177+ frame := awaitTestValue(t, sent, "failed remote resize did not repaint local chrome")
178+ require.Equal(t, ports.MsgOutput, frame.Type)
179+ proxy.mu.Lock()
180+ content := proxy.contentSize
181+ proxy.mu.Unlock()
182+ require.Equal(t, contentSize(domain.Size{Cols: 24, Rows: 10}, false), content, "local VT resized despite the failed send")
183+}
184+
185+// TestProxyResizeWithoutGeometryChangeDoesNotRepaint is the other half: an
186+// identical size is not a reason to repaint.
187+func TestProxyResizeWithoutGeometryChangeDoesNotRepaint(t *testing.T) {
188+ d := newTestDaemon(t, nil, stubClock{})
189+ clientTr, sent := newCapturingTransport(t)
190+ proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())
191+
192+ d.resizeProxyForLease(proxy, ac, nil, domain.Size{Cols: 16, Rows: 6})
193+
194+ select {
195+ case frame := <-sent:
196+ t.Fatalf("no-op resize repainted: %v", frame.Type)
197+ default:
198+ }
199+}
200+
201+// TestProxyPrepareFailureRepaintsWithoutLocalSession covers a failed render
202+// transaction on an attachment that has no local session to report through.
203+func TestProxyPrepareFailureRepaintsWithoutLocalSession(t *testing.T) {
204+ d := newTestDaemon(t, nil, stubClock{})
205+ clientTr, sent := newCapturingTransport(t)
206+ proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())
207+
208+ ac.sendMu.Lock() // emitFrame releases the transaction lock.
209+ state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{bars: barState{status: proxy.statusSegments(false)}, reset: true})
210+ require.True(t, ok)
211+ composed := composeFrame(*state, composeCacheInput{})
212+ // A malformed frame is what makes outputStateStream.prepare fail.
213+ composed.frame = renderer.Frame{Width: 1}
214+
215+ require.True(t, d.emitFrame(proxy, ac, state, composed))
216+ frame := awaitTestValue(t, sent, "prepare failure on a proxy did not schedule a repaint")
217+ require.Equal(t, ports.MsgOutput, frame.Type)
218+}
219+
220+// TestProxySendErrorDetachesProxyAttachment covers the transport-failure
221+// cleanup path for an attachment with no local session lifecycle.
222+func TestProxySendErrorDetachesProxyAttachment(t *testing.T) {
223+ d := newTestDaemon(t, nil, stubClock{})
224+ clientTr := newFailingTransport(t)
225+ proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())
226+
227+ d.paint(proxy, ac, true, nil)
228+
229+ proxy.sessionCore.mu.Lock()
230+ client := proxy.client
231+ proxy.sessionCore.mu.Unlock()
232+ require.Nil(t, client, "a failed send must release the proxy attachment")
233+ require.Nil(t, ac.currentAttachmentSession(), "the client must no longer point at the proxy")
234+}
235+
236+func proxyRowText(row []renderer.Cell) string {
237+ runes := make([]rune, len(row))
238+ for i, cell := range row {
239+ runes[i] = cell.Rune
240+ }
241+ return string(runes)
242+}
@@ -0,0 +1,242 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
10+ "github.com/bnema/vev/pkg/renderer"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestProxyRenderComposesRemoteVTUnderExactlyOneLocalChrome(t *testing.T) {
16+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
17+ require.NoError(t, err)
18+ proxy.mu.Lock()
19+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
20+ proxy.mu.Unlock()
21+ _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2;3Hremote")})
22+ require.True(t, changed)
23+
24+ state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{
25+ bars: barState{status: proxy.statusSegments(false)},
26+ reset: true,
27+ })
28+ require.True(t, ok)
29+ composed := composeFrame(*state, composeCacheInput{})
30+
31+ require.Equal(t, 6, composed.frame.Height)
32+ require.Contains(t, proxyRowText(composed.frame.Row(0)), "shell", "one local top bar")
33+ require.Contains(t, proxyRowText(composed.frame.Row(5)), "work@arch", "one local bottom bar")
34+ require.NotContains(t, proxyRowText(composed.frame.Row(1)), "shell")
35+ require.NotContains(t, proxyRowText(composed.frame.Row(4)), "work@arch")
36+ require.Equal(t, 'r', composed.frame.At(2, 2).Rune, "ANSI was applied only by the local VT")
37+ require.False(t, composed.cursor.hidden)
38+ require.Equal(t, 2, composed.cursor.row, "cursor includes exactly the one local top bar")
39+ require.Contains(t, composed.damage, renderer.FullRedraw())
40+}
41+
42+func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) {
43+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
44+ require.NoError(t, err)
45+ proxy.mu.Lock()
46+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
47+ proxy.mu.Unlock()
48+ _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")})
49+ require.True(t, changed)
50+
51+ d := newTestDaemon(t, nil, stubClock{})
52+ transport, sent := newCapturingTransport(t)
53+ ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}
54+ ac.setSession(proxy)
55+ proxy.sessionCore.mu.Lock()
56+ proxy.client = ac
57+ proxy.sessionCore.mu.Unlock()
58+
59+ d.paint(proxy, ac, true, nil)
60+ frame := awaitTestValue(t, sent, "handoff to remote proxy did not paint")
61+ require.Equal(t, ports.MsgOutput, frame.Type, "handoff to remote proxy gets a local first paint")
62+}
63+
64+func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
65+ d, local, ac, sent := newManualSessionWithPTYs(t, newQuietPTY())
66+ localCoordinator := d.attachCoordinator(local, nil, ac, true)
67+ localToken := attachmentToken(local, ac, ac.transport())
68+ localToken.lease = localCoordinator.attachmentLease(ac)
69+
70+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, ac.size)
71+ require.NoError(t, err)
72+ proxy.mu.Lock()
73+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
74+ proxy.mu.Unlock()
75+ _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")})
76+ require.True(t, changed)
77+ d.mu.Lock()
78+ require.True(t, d.registerSessionLocked(proxy))
79+ d.mu.Unlock()
80+
81+ toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
82+ source: local, target: proxy, next: ac,
83+ expectedRole: attachmentActive, targetRole: attachmentActive,
84+ expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
85+ action: "test transition", ready: true,
86+ })
87+ require.NoError(t, err)
88+ require.NotNil(t, toProxy.published.lease, "proxy transition installs a coordinator lease")
89+ require.True(t, d.firstPaintForTransition(toProxy.published))
90+ require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to proxy did not paint").Type)
91+
92+ toLocal, err := d.transitionAttachment(attachmentTransitionRequest{
93+ source: proxy, target: local, next: ac,
94+ expectedRole: attachmentActive, targetRole: attachmentActive,
95+ expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
96+ action: "test transition", ready: true,
97+ })
98+ require.NoError(t, err)
99+ require.True(t, d.firstPaintForTransition(toLocal.published))
100+ require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to local session did not paint").Type)
101+}
102+
103+func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) {
104+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 12, Rows: 8})
105+ require.NoError(t, err)
106+ transport := newProxyTestTransport()
107+ proxy.mu.Lock()
108+ proxy.transport = transport
109+ proxy.linkGeneration = 1
110+ proxy.mu.Unlock()
111+
112+ changed, sent := proxy.resize(domain.Size{Cols: 20, Rows: 10})
113+ require.True(t, changed)
114+ require.True(t, sent)
115+ frame := awaitTestValue(t, transport.sent, "proxy resize did not reach remote transport")
116+ require.Equal(t, ports.MsgResize, frame.Type)
117+ resize, err := ports.UnmarshalResize(frame.Payload)
118+ require.NoError(t, err)
119+ require.Equal(t, domain.Size{Cols: 20, Rows: 8}, resize.Size)
120+ select {
121+ case extra := <-transport.sent:
122+ t.Fatalf("unexpected second remote resize: %#v", extra)
123+ default:
124+ }
125+
126+ state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{reset: true})
127+ require.True(t, ok)
128+ composed := composeFrame(*state, composeCacheInput{})
129+ require.Equal(t, 10, composed.frame.Height, "local viewport remains full-sized")
130+ require.Equal(t, 8, state.layout.area.Height)
131+}
132+
133+// newAttachedProxyFixture publishes one proxy with a local thin client bound to
134+// clientTr and its remote link bound to remoteTr, using no render coordinator so
135+// invalidation paints synchronously.
136+func newAttachedProxyFixture(t *testing.T, d *Daemon, clientTr ports.Transport, remoteTr ports.Transport) (*proxySession, *attachedClient) {
137+ t.Helper()
138+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
139+ require.NoError(t, err)
140+ proxy.mu.Lock()
141+ proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
142+ proxy.transport = remoteTr
143+ proxy.linkGeneration = 1
144+ proxy.mu.Unlock()
145+ d.mu.Lock()
146+ require.True(t, d.registerSessionLocked(proxy))
147+ d.mu.Unlock()
148+
149+ ac := &attachedClient{tr: clientTr, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}
150+ ac.setSession(proxy)
151+ proxy.sessionCore.mu.Lock()
152+ proxy.client = ac
153+ proxy.sessionCore.mu.Unlock()
154+ return proxy, ac
155+}
156+
157+func newFailingTransport(t *testing.T) *portsmocks.MockTransport {
158+ t.Helper()
159+ tr := portsmocks.NewMockTransport(t)
160+ tr.EXPECT().Send(mock.Anything).Return(io.ErrClosedPipe).Maybe()
161+ tr.EXPECT().Close().Return(nil).Maybe()
162+ return tr
163+}
164+
165+// TestProxyResizeRepaintsLocalChromeWhenRemoteSendFails covers the split
166+// between "the local content rectangle moved" and "the remote was told". The
167+// local VT has already been resized, so the client owes a repaint either way.
168+func TestProxyResizeRepaintsLocalChromeWhenRemoteSendFails(t *testing.T) {
169+ d := newTestDaemon(t, nil, stubClock{})
170+ clientTr, sent := newCapturingTransport(t)
171+ remote := newProxyTestTransport()
172+ remote.sendFails.Store(true)
173+ proxy, ac := newAttachedProxyFixture(t, d, clientTr, remote)
174+
175+ d.resizeProxyForLease(proxy, ac, nil, domain.Size{Cols: 24, Rows: 10})
176+
177+ frame := awaitTestValue(t, sent, "failed remote resize did not repaint local chrome")
178+ require.Equal(t, ports.MsgOutput, frame.Type)
179+ proxy.mu.Lock()
180+ content := proxy.contentSize
181+ proxy.mu.Unlock()
182+ require.Equal(t, contentSize(domain.Size{Cols: 24, Rows: 10}, false), content, "local VT resized despite the failed send")
183+}
184+
185+// TestProxyResizeWithoutGeometryChangeDoesNotRepaint is the other half: an
186+// identical size is not a reason to repaint.
187+func TestProxyResizeWithoutGeometryChangeDoesNotRepaint(t *testing.T) {
188+ d := newTestDaemon(t, nil, stubClock{})
189+ clientTr, sent := newCapturingTransport(t)
190+ proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())
191+
192+ d.resizeProxyForLease(proxy, ac, nil, domain.Size{Cols: 16, Rows: 6})
193+
194+ select {
195+ case frame := <-sent:
196+ t.Fatalf("no-op resize repainted: %v", frame.Type)
197+ default:
198+ }
199+}
200+
201+// TestProxyPrepareFailureRepaintsWithoutLocalSession covers a failed render
202+// transaction on an attachment that has no local session to report through.
203+func TestProxyPrepareFailureRepaintsWithoutLocalSession(t *testing.T) {
204+ d := newTestDaemon(t, nil, stubClock{})
205+ clientTr, sent := newCapturingTransport(t)
206+ proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())
207+
208+ ac.sendMu.Lock() // emitFrame releases the transaction lock.
209+ state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{bars: barState{status: proxy.statusSegments(false)}, reset: true})
210+ require.True(t, ok)
211+ composed := composeFrame(*state, composeCacheInput{})
212+ // A malformed frame is what makes outputStateStream.prepare fail.
213+ composed.frame = renderer.Frame{Width: 1}
214+
215+ require.True(t, d.emitFrame(proxy, ac, state, composed))
216+ frame := awaitTestValue(t, sent, "prepare failure on a proxy did not schedule a repaint")
217+ require.Equal(t, ports.MsgOutput, frame.Type)
218+}
219+
220+// TestProxySendErrorDetachesProxyAttachment covers the transport-failure
221+// cleanup path for an attachment with no local session lifecycle.
222+func TestProxySendErrorDetachesProxyAttachment(t *testing.T) {
223+ d := newTestDaemon(t, nil, stubClock{})
224+ clientTr := newFailingTransport(t)
225+ proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())
226+
227+ d.paint(proxy, ac, true, nil)
228+
229+ proxy.sessionCore.mu.Lock()
230+ client := proxy.client
231+ proxy.sessionCore.mu.Unlock()
232+ require.Nil(t, client, "a failed send must release the proxy attachment")
233+ require.Nil(t, ac.currentAttachmentSession(), "the client must no longer point at the proxy")
234+}
235+
236+func proxyRowText(row []renderer.Cell) string {
237+ runes := make([]rune, len(row))
238+ for i, cell := range row {
239+ runes[i] = cell.Rune
240+ }
241+ return string(runes)
242+}
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 +38 -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,25 @@ 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
479489 }
480- d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))
481- d.invalidateRender(sess, ac, true, "render_pipeline.go:prepare-failed")
490+ // Notices are session-scoped, but the repaint is not: a proxy attachment
491+ // has no local session to report through and still needs its chrome
492+ // redrawn after a failed transaction.
493+ d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
482494 return true
483495 }
484496 data := append([]byte(nil), prepared.data...)
@@ -491,7 +503,9 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
491503 sendTransport := ac.transportSnapshot()
492504 sendTr = sendTransport.transport
493505 if ac.proxied {
494- sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)
506+ if sess, ok := localSession(entry); ok {
507+ sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)
508+ }
495509 }
496510 if len(data) > 0 {
497511 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
@@ -538,7 +552,11 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
538552 // admission first. Detachment freezes the gate and therefore cannot mutate
539553 // ownership until any enclosing admitted operation has also ended.
540554 if marks.roleEffect == nil {
541- d.detachOnSendError(sess, ac, sendTr)
555+ if sess, ok := localSession(entry); ok {
556+ d.detachOnSendError(sess, ac, sendTr)
557+ } else if proxy, ok := entry.(*proxySession); ok {
558+ d.detachProxyOnSendError(proxy, ac, sendTr)
559+ }
542560 } else {
543561 // Capture the exact admitted capability, including its coordinator
544562 // 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,25 @@ 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)
478- return true
479 }489 }
480- d.reportError(sess, domain.UserErr(domain.NoticeInternal, "display update failed", err))490+ // Notices are session-scoped, but the repaint is not: a proxy attachment
481- d.invalidateRender(sess, ac, true, "render_pipeline.go:prepare-failed")491+ // has no local session to report through and still needs its chrome
492+ // redrawn after a failed transaction.
493+ d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
482 return true494 return true
483 }495 }
484 data := append([]byte(nil), prepared.data...)496 data := append([]byte(nil), prepared.data...)
@@ -491,7 +503,9 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
491 sendTransport := ac.transportSnapshot()503 sendTransport := ac.transportSnapshot()
492 sendTr = sendTransport.transport504 sendTr = sendTransport.transport
493 if ac.proxied {505 if ac.proxied {
494- sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)506+ if sess, ok := localSession(entry); ok {
507+ sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)
508+ }
495 }509 }
496 if len(data) > 0 {510 if len(data) > 0 {
497 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))511 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
@@ -538,7 +552,11 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen
538 // admission first. Detachment freezes the gate and therefore cannot mutate552 // admission first. Detachment freezes the gate and therefore cannot mutate
539 // ownership until any enclosing admitted operation has also ended.553 // ownership until any enclosing admitted operation has also ended.
540 if marks.roleEffect == nil {554 if marks.roleEffect == nil {
541- d.detachOnSendError(sess, ac, sendTr)555+ if sess, ok := localSession(entry); ok {
556+ d.detachOnSendError(sess, ac, sendTr)
557+ } else if proxy, ok := entry.(*proxySession); ok {
558+ d.detachProxyOnSendError(proxy, ac, sendTr)
559+ }
542 } else {560 } else {
543 // Capture the exact admitted capability, including its coordinator561 // Capture the exact admitted capability, including its coordinator
544 // lease, before End permits a replacement publication. Reserve cleanup562 // lease, before End permits a replacement publication. Reserve cleanup
M internal/usecase/daemon/session.go +30 -0
@@ -751,6 +751,36 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
751751 return current
752752 }
753753
754+// detachProxyIfCurrentTransport is detachIfCurrentTransport for a proxy
755+// attachment. It fences on the same published identity — exact session pointer,
756+// exact attachment, exact transport incarnation — but reads the attachment
757+// through the shared sessionCore rather than a local session.
758+func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClient, expected transportSnapshot) bool {
759+ if p == nil || ac == nil {
760+ return false
761+ }
762+ frozen := freezeRoleEffectGates(ac)
763+ defer frozen.unfreeze()
764+ if d.afterDetachRoleEffectsFrozen != nil {
765+ d.afterDetachRoleEffectsFrozen()
766+ }
767+ d.notices.routingMu.Lock()
768+ p.sessionCore.mu.Lock()
769+ current := p.client == ac && ac.currentAttachmentSession() == attachmentSession(p)
770+ if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
771+ current = false
772+ }
773+ if current {
774+ p.client = nil
775+ ac.roleGeneration.Add(1)
776+ ac.setSession(nil)
777+ ac.invalidateFrozenRoleCapability()
778+ }
779+ p.sessionCore.mu.Unlock()
780+ d.notices.routingMu.Unlock()
781+ return current
782+}
783+
754784 // detachIfRoleCurrent invalidates only the exact role generation and transport
755785 // whose admitted send failed. A queued stale error cannot detach a later role
756786 // publication or a rebound transport on the same attachment object.
@@ -751,6 +751,36 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
751 return current751 return current
752 }752 }
753 753
754+// detachProxyIfCurrentTransport is detachIfCurrentTransport for a proxy
755+// attachment. It fences on the same published identity — exact session pointer,
756+// exact attachment, exact transport incarnation — but reads the attachment
757+// through the shared sessionCore rather than a local session.
758+func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClient, expected transportSnapshot) bool {
759+ if p == nil || ac == nil {
760+ return false
761+ }
762+ frozen := freezeRoleEffectGates(ac)
763+ defer frozen.unfreeze()
764+ if d.afterDetachRoleEffectsFrozen != nil {
765+ d.afterDetachRoleEffectsFrozen()
766+ }
767+ d.notices.routingMu.Lock()
768+ p.sessionCore.mu.Lock()
769+ current := p.client == ac && ac.currentAttachmentSession() == attachmentSession(p)
770+ if current && expected.transport != nil && !ac.transportSnapshotCurrent(expected) {
771+ current = false
772+ }
773+ if current {
774+ p.client = nil
775+ ac.roleGeneration.Add(1)
776+ ac.setSession(nil)
777+ ac.invalidateFrozenRoleCapability()
778+ }
779+ p.sessionCore.mu.Unlock()
780+ d.notices.routingMu.Unlock()
781+ return current
782+}
783+
754 // detachIfRoleCurrent invalidates only the exact role generation and transport784 // detachIfRoleCurrent invalidates only the exact role generation and transport
755 // whose admitted send failed. A queued stale error cannot detach a later role785 // whose admitted send failed. A queued stale error cannot detach a later role
756 // publication or a rebound transport on the same attachment object.786 // publication or a rebound transport on the same attachment object.
M internal/usecase/daemon/session_view.go +8 -3
@@ -1,6 +1,8 @@
11 package daemon
22
33 import (
4+ "time"
5+
46 "github.com/bnema/vev/internal/domain"
57 "github.com/bnema/vev/internal/usecase/picker"
68 )
@@ -18,6 +20,7 @@ type tabView struct {
1820 name string
1921 focusedTitle string
2022 attention bool
23+ attentionAt time.Time
2124 }
2225
2326 // sessionView is an immutable, value-only description of a session. It
@@ -38,6 +41,7 @@ type sessionView struct {
3841 hasAttention bool
3942 tabs []tabView
4043 cannotAcceptMoves bool
44+ expired bool
4145 }
4246
4347 // snapshotView reads session fields under s.mu and samples the independently
@@ -76,9 +80,10 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
7680 continue
7781 }
7882 entry := tabView{
79- id: domain.TabStableID(tb.stableID),
80- name: tabDisplayName(tb, i),
81- attention: tb.attention,
83+ id: domain.TabStableID(tb.stableID),
84+ name: tabDisplayName(tb, i),
85+ attention: tb.attention,
86+ attentionAt: tb.attentionAt,
8287 }
8388 if opts.focusedTitles {
8489 entry.focusedTitle = tb.focusedPaneTitle(opts.terminalTitle)
@@ -1,6 +1,8 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "time"
5+
4 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/usecase/picker"7 "github.com/bnema/vev/internal/usecase/picker"
6 )8 )
@@ -18,6 +20,7 @@ type tabView struct {
18 name string20 name string
19 focusedTitle string21 focusedTitle string
20 attention bool22 attention bool
23+ attentionAt time.Time
21 }24 }
22 25
23 // sessionView is an immutable, value-only description of a session. It26 // sessionView is an immutable, value-only description of a session. It
@@ -38,6 +41,7 @@ type sessionView struct {
38 hasAttention bool41 hasAttention bool
39 tabs []tabView42 tabs []tabView
40 cannotAcceptMoves bool43 cannotAcceptMoves bool
44+ expired bool
41 }45 }
42 46
43 // snapshotView reads session fields under s.mu and samples the independently47 // snapshotView reads session fields under s.mu and samples the independently
@@ -76,9 +80,10 @@ func (s *session) snapshotView(opts viewOptions) sessionView {
76 continue80 continue
77 }81 }
78 entry := tabView{82 entry := tabView{
79- id: domain.TabStableID(tb.stableID),83+ id: domain.TabStableID(tb.stableID),
80- name: tabDisplayName(tb, i),84+ name: tabDisplayName(tb, i),
81- attention: tb.attention,85+ attention: tb.attention,
86+ attentionAt: tb.attentionAt,
82 }87 }
83 if opts.focusedTitles {88 if opts.focusedTitles {
84 entry.focusedTitle = tb.focusedPaneTitle(opts.terminalTitle)89 entry.focusedTitle = tb.focusedPaneTitle(opts.terminalTitle)
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