detached ac571a7

feat(daemon): route remote attachment frames locally

commit
ac571a722cb7752ec1f85cc25c6c0e34b96d45c7
author
brice · · 2026-08-06 17:55 UTC
parent
2a4b043
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +308 -7
M internal/usecase/daemon/client.go +48 -0
@@ -875,6 +875,14 @@ func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg po
875875 return false
876876 }
877877 clientTheme := themeFromMessage(msg)
878+ if view, remote := token.owner.(*remoteView); remote {
879+ token.ac.setClientTheme(clientTheme)
880+ token.ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme))
881+ if !token.attachmentEffectCurrent() {
882+ return false
883+ }
884+ return d.paintRemoteView(view, token.ac, true, token) == paintEmitted
885+ }
878886 token.ac.setClientTheme(clientTheme)
879887 sess := token.localSession()
880888 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
@@ -922,6 +930,13 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
922930 }
923931 ac.sendMu.Unlock()
924932
933+ if view, remote := token.owner.(*remoteView); remote {
934+ if sameSize || !token.attachmentEffectCurrent() || !view.resizeScreen(ac.sizeSnapshot()) {
935+ return false
936+ }
937+ return d.paintRemoteView(view, ac, true, token) == paintEmitted
938+ }
939+
925940 if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() {
926941 // The picker preview geometry is attachment-local. A resize must
927942 // invalidate any in-flight remote request and fetch the selected row
@@ -989,7 +1004,16 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken
9891004 }
9901005 sess := token.localSession()
9911006 if sess == nil {
1007+ view, remote := token.owner.(*remoteView)
1008+ if !remote {
1009+ d.notices.routingMu.Unlock()
1010+ return
1011+ }
1012+ repaint := d.mutateRemoteClientNotice(token.ac, notice)
9921013 d.notices.routingMu.Unlock()
1014+ if repaint && token.attachmentEffectCurrent() {
1015+ d.paintRemoteView(view, token.ac, false, token)
1016+ }
9931017 return
9941018 }
9951019 repaint := d.mutateClientNotice(sess, token.ac, notice)
@@ -1033,6 +1057,30 @@ func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice po
10331057 }
10341058 }
10351059
1060+// mutateRemoteClientNotice keeps client-originated feedback local to the
1061+// attachment. A remoteView has no local session scope or notice history to
1062+// mutate; only its visible toast stack is part of the local composition.
1063+func (d *Daemon) mutateRemoteClientNotice(ac *attachedClient, notice ports.ClientNotice) bool {
1064+ if ac == nil {
1065+ return false
1066+ }
1067+ var n domain.Notification
1068+ switch notice.Action {
1069+ case ports.ClientNoticeClipboardFallback:
1070+ n = domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeError, Message: "image paste failed; sent Ctrl+V", Time: d.clock.Now()}
1071+ case ports.ClientNoticeClipboardTooLarge:
1072+ n = domain.Notification{Code: domain.NoticeClipboardTooLarge, Severity: domain.NoticeWarn, Message: "image too large to paste", Time: d.clock.Now()}
1073+ case ports.ClientNoticeLinkDegraded:
1074+ n = domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "connection degraded", Time: d.clock.Now()}
1075+ case ports.ClientNoticeLinkConnected:
1076+ return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, "")
1077+ default:
1078+ return false
1079+ }
1080+ n.Count = 1
1081+ return d.publishToast(ac, n)
1082+}
1083+
10361084 // recordClientNotice mutates notice history and the selected attachment while
10371085 // routingMu keeps another connection from changing that selection. Rendering is left
10381086 // to the caller after it releases routingMu.
@@ -875,6 +875,14 @@ func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg po
875 return false875 return false
876 }876 }
877 clientTheme := themeFromMessage(msg)877 clientTheme := themeFromMessage(msg)
878+ if view, remote := token.owner.(*remoteView); remote {
879+ token.ac.setClientTheme(clientTheme)
880+ token.ac.setAppliedTheme(d.resolveAppliedTheme(clientTheme))
881+ if !token.attachmentEffectCurrent() {
882+ return false
883+ }
884+ return d.paintRemoteView(view, token.ac, true, token) == paintEmitted
885+ }
878 token.ac.setClientTheme(clientTheme)886 token.ac.setClientTheme(clientTheme)
879 sess := token.localSession()887 sess := token.localSession()
880 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {888 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
@@ -922,6 +930,13 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
922 }930 }
923 ac.sendMu.Unlock()931 ac.sendMu.Unlock()
924 932
933+ if view, remote := token.owner.(*remoteView); remote {
934+ if sameSize || !token.attachmentEffectCurrent() || !view.resizeScreen(ac.sizeSnapshot()) {
935+ return false
936+ }
937+ return d.paintRemoteView(view, ac, true, token) == paintEmitted
938+ }
939+
925 if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() {940 if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() {
926 // The picker preview geometry is attachment-local. A resize must941 // The picker preview geometry is attachment-local. A resize must
927 // invalidate any in-flight remote request and fetch the selected row942 // invalidate any in-flight remote request and fetch the selected row
@@ -989,7 +1004,16 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken
989 }1004 }
990 sess := token.localSession()1005 sess := token.localSession()
991 if sess == nil {1006 if sess == nil {
1007+ view, remote := token.owner.(*remoteView)
1008+ if !remote {
1009+ d.notices.routingMu.Unlock()
1010+ return
1011+ }
1012+ repaint := d.mutateRemoteClientNotice(token.ac, notice)
992 d.notices.routingMu.Unlock()1013 d.notices.routingMu.Unlock()
1014+ if repaint && token.attachmentEffectCurrent() {
1015+ d.paintRemoteView(view, token.ac, false, token)
1016+ }
993 return1017 return
994 }1018 }
995 repaint := d.mutateClientNotice(sess, token.ac, notice)1019 repaint := d.mutateClientNotice(sess, token.ac, notice)
@@ -1033,6 +1057,30 @@ func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice po
1033 }1057 }
1034 }1058 }
1035 1059
1060+// mutateRemoteClientNotice keeps client-originated feedback local to the
1061+// attachment. A remoteView has no local session scope or notice history to
1062+// mutate; only its visible toast stack is part of the local composition.
1063+func (d *Daemon) mutateRemoteClientNotice(ac *attachedClient, notice ports.ClientNotice) bool {
1064+ if ac == nil {
1065+ return false
1066+ }
1067+ var n domain.Notification
1068+ switch notice.Action {
1069+ case ports.ClientNoticeClipboardFallback:
1070+ n = domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeError, Message: "image paste failed; sent Ctrl+V", Time: d.clock.Now()}
1071+ case ports.ClientNoticeClipboardTooLarge:
1072+ n = domain.Notification{Code: domain.NoticeClipboardTooLarge, Severity: domain.NoticeWarn, Message: "image too large to paste", Time: d.clock.Now()}
1073+ case ports.ClientNoticeLinkDegraded:
1074+ n = domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "connection degraded", Time: d.clock.Now()}
1075+ case ports.ClientNoticeLinkConnected:
1076+ return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, "")
1077+ default:
1078+ return false
1079+ }
1080+ n.Count = 1
1081+ return d.publishToast(ac, n)
1082+}
1083+
1036 // recordClientNotice mutates notice history and the selected attachment while1084 // recordClientNotice mutates notice history and the selected attachment while
1037 // routingMu keeps another connection from changing that selection. Rendering is left1085 // routingMu keeps another connection from changing that selection. Rendering is left
1038 // to the caller after it releases routingMu.1086 // to the caller after it releases routingMu.
M internal/usecase/daemon/client_frame_routing.go +22 -2
@@ -175,6 +175,11 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
175175 result.Text = "attached commands cannot target themselves"
176176 return result
177177 }
178+ if _, remote := token.owner.(*remoteView); remote {
179+ result.Code = ports.ErrNoSuchTarget
180+ result.Text = "attached session is no longer active"
181+ return result
182+ }
178183 if request.TargetSession != "" || request.TargetTab != "" || request.TargetPane != "" {
179184 result.Code = ports.ErrInvalidCommandArgs
180185 result.Text = "attached commands cannot override their active session target"
@@ -238,7 +243,9 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
238243 ac.pipelineCache = composeCacheInput{}
239244 ac.pipelineScratch = composeCacheInput{}
240245 ac.sendMu.Unlock()
241- if sess := token.localSession(); sess != nil {
246+ if view, remote := token.owner.(*remoteView); remote {
247+ go d.paintRemoteView(view, ac, true, token)
248+ } else if sess := token.localSession(); sess != nil {
242249 go d.paint(sess, ac, true, token.lease)
243250 }
244251 return true
@@ -250,12 +257,25 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64)
250257 return false
251258 }
252259 ac.sendMu.Lock()
253- ac.output.ack(epoch, state)
260+ if ac.output == nil {
261+ ac.sendMu.Unlock()
262+ return false
263+ }
264+ advanced := ac.output.ack(epoch, state)
254265 ac.sendMu.Unlock()
266+ if !advanced {
267+ return true
268+ }
255269 if sess := token.localSession(); sess != nil {
256270 if rc := sess.core().coordinator.Load(); rc != nil {
257271 rc.notifyAckForLease(token.lease)
258272 }
273+ } else if view, remote := token.owner.(*remoteView); remote {
274+ // Remote views do not have a session render coordinator to consume the
275+ // newly available output-window capacity. Recompose from the retained
276+ // private VT so an update previously blocked by the local client window
277+ // is not stranded until another remote frame arrives.
278+ go d.paintRemoteView(view, ac, false, token)
259279 }
260280 return true
261281 }
@@ -175,6 +175,11 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
175 result.Text = "attached commands cannot target themselves"175 result.Text = "attached commands cannot target themselves"
176 return result176 return result
177 }177 }
178+ if _, remote := token.owner.(*remoteView); remote {
179+ result.Code = ports.ErrNoSuchTarget
180+ result.Text = "attached session is no longer active"
181+ return result
182+ }
178 if request.TargetSession != "" || request.TargetTab != "" || request.TargetPane != "" {183 if request.TargetSession != "" || request.TargetTab != "" || request.TargetPane != "" {
179 result.Code = ports.ErrInvalidCommandArgs184 result.Code = ports.ErrInvalidCommandArgs
180 result.Text = "attached commands cannot override their active session target"185 result.Text = "attached commands cannot override their active session target"
@@ -238,7 +243,9 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
238 ac.pipelineCache = composeCacheInput{}243 ac.pipelineCache = composeCacheInput{}
239 ac.pipelineScratch = composeCacheInput{}244 ac.pipelineScratch = composeCacheInput{}
240 ac.sendMu.Unlock()245 ac.sendMu.Unlock()
241- if sess := token.localSession(); sess != nil {246+ if view, remote := token.owner.(*remoteView); remote {
247+ go d.paintRemoteView(view, ac, true, token)
248+ } else if sess := token.localSession(); sess != nil {
242 go d.paint(sess, ac, true, token.lease)249 go d.paint(sess, ac, true, token.lease)
243 }250 }
244 return true251 return true
@@ -250,12 +257,25 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64)
250 return false257 return false
251 }258 }
252 ac.sendMu.Lock()259 ac.sendMu.Lock()
253- ac.output.ack(epoch, state)260+ if ac.output == nil {
261+ ac.sendMu.Unlock()
262+ return false
263+ }
264+ advanced := ac.output.ack(epoch, state)
254 ac.sendMu.Unlock()265 ac.sendMu.Unlock()
266+ if !advanced {
267+ return true
268+ }
255 if sess := token.localSession(); sess != nil {269 if sess := token.localSession(); sess != nil {
256 if rc := sess.core().coordinator.Load(); rc != nil {270 if rc := sess.core().coordinator.Load(); rc != nil {
257 rc.notifyAckForLease(token.lease)271 rc.notifyAckForLease(token.lease)
258 }272 }
273+ } else if view, remote := token.owner.(*remoteView); remote {
274+ // Remote views do not have a session render coordinator to consume the
275+ // newly available output-window capacity. Recompose from the retained
276+ // private VT so an update previously blocked by the local client window
277+ // is not stranded until another remote frame arrives.
278+ go d.paintRemoteView(view, ac, false, token)
259 }279 }
260 return true280 return true
261 }281 }
M internal/usecase/daemon/clipboard.go +5 -2
@@ -27,14 +27,14 @@ var (
2727 )
2828
2929 func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {
30- if !token.attachmentEffectCurrent() || token.ac.renderMode == ports.RenderModeProxiedContent {
30+ if token.localSession() == nil || !token.attachmentEffectCurrent() || token.ac.renderMode == ports.RenderModeProxiedContent {
3131 return
3232 }
3333 d.handleImagePushForAttachment(token, ip)
3434 }
3535
3636 func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {
37- if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {
37+ if token.localSession() == nil || len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {
3838 return
3939 }
4040 path, err := d.writeClipboardImageForAttachment(token, ip)
@@ -85,6 +85,9 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
8585 }
8686
8787 func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {
88+ if token.localSession() == nil || !token.attachmentEffectCurrent() {
89+ return "", errAttachmentTransition
90+ }
8891 path, err := d.createClipboardImage(ip)
8992 if err != nil {
9093 return "", err
@@ -27,14 +27,14 @@ var (
27 )27 )
28 28
29 func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {29 func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {
30- if !token.attachmentEffectCurrent() || token.ac.renderMode == ports.RenderModeProxiedContent {30+ if token.localSession() == nil || !token.attachmentEffectCurrent() || token.ac.renderMode == ports.RenderModeProxiedContent {
31 return31 return
32 }32 }
33 d.handleImagePushForAttachment(token, ip)33 d.handleImagePushForAttachment(token, ip)
34 }34 }
35 35
36 func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {36 func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {
37- if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {37+ if token.localSession() == nil || len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {
38 return38 return
39 }39 }
40 path, err := d.writeClipboardImageForAttachment(token, ip)40 path, err := d.writeClipboardImageForAttachment(token, ip)
@@ -85,6 +85,9 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
85 }85 }
86 86
87 func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {87 func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {
88+ if token.localSession() == nil || !token.attachmentEffectCurrent() {
89+ return "", errAttachmentTransition
90+ }
88 path, err := d.createClipboardImage(ip)91 path, err := d.createClipboardImage(ip)
89 if err != nil {92 if err != nil {
90 return "", err93 return "", err
M internal/usecase/daemon/notify.go +12 -0
@@ -491,6 +491,18 @@ func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) {
491491 // repaintForNotice asks for an urgent redraw. It must be called with noticeMu
492492 // released: invalidateRender can paint inline, which takes sendMu.
493493 func (d *Daemon) repaintForNotice(ac *attachedClient) {
494+ if ac == nil {
495+ return
496+ }
497+ if view, remote := ac.currentAttachmentOwner().(*remoteView); remote {
498+ tr := ac.transport()
499+ token := attachmentOwnerToken(view, ac, tr)
500+ if token.ac == nil {
501+ return
502+ }
503+ d.paintRemoteView(view, ac, false, token)
504+ return
505+ }
494506 sess := ac.currentSession()
495507 if sess == nil {
496508 // Mid-handoff or torn down: whoever attaches next repaints anyway, and
@@ -491,6 +491,18 @@ func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) {
491 // repaintForNotice asks for an urgent redraw. It must be called with noticeMu491 // repaintForNotice asks for an urgent redraw. It must be called with noticeMu
492 // released: invalidateRender can paint inline, which takes sendMu.492 // released: invalidateRender can paint inline, which takes sendMu.
493 func (d *Daemon) repaintForNotice(ac *attachedClient) {493 func (d *Daemon) repaintForNotice(ac *attachedClient) {
494+ if ac == nil {
495+ return
496+ }
497+ if view, remote := ac.currentAttachmentOwner().(*remoteView); remote {
498+ tr := ac.transport()
499+ token := attachmentOwnerToken(view, ac, tr)
500+ if token.ac == nil {
501+ return
502+ }
503+ d.paintRemoteView(view, ac, false, token)
504+ return
505+ }
494 sess := ac.currentSession()506 sess := ac.currentSession()
495 if sess == nil {507 if sess == nil {
496 // Mid-handoff or torn down: whoever attaches next repaints anyway, and508 // Mid-handoff or torn down: whoever attaches next repaints anyway, and
M internal/usecase/daemon/output_state_stream.go +4 -3
@@ -250,17 +250,18 @@ func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports
250250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
251251 }
252252
253-func (s *outputStateStream) ack(epoch, state uint64) {
253+func (s *outputStateStream) ack(epoch, state uint64) bool {
254254 if s == nil {
255- return
255+ return false
256256 }
257257 s.lockView()
258258 defer s.unlockView()
259259 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {
260- return
260+ return false
261261 }
262262 s.acked = state
263263 s.publishOutstanding()
264+ return true
264265 }
265266
266267 func (s *outputStateStream) rebase() {
@@ -250,17 +250,18 @@ func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports
250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
251 }251 }
252 252
253-func (s *outputStateStream) ack(epoch, state uint64) {253+func (s *outputStateStream) ack(epoch, state uint64) bool {
254 if s == nil {254 if s == nil {
255- return255+ return false
256 }256 }
257 s.lockView()257 s.lockView()
258 defer s.unlockView()258 defer s.unlockView()
259 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {259 if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {
260- return260+ return false
261 }261 }
262 s.acked = state262 s.acked = state
263 s.publishOutstanding()263 s.publishOutstanding()
264+ return true
264 }265 }
265 266
266 func (s *outputStateStream) rebase() {267 func (s *outputStateStream) rebase() {
A internal/usecase/daemon/remote_frame_routing_test.go +196 -0
@@ -0,0 +1,196 @@
1+package daemon
2+
3+import (
4+ "os"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/pkg/renderer"
10+ "github.com/bnema/vev/pkg/vt"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func newRemoteFrameRoutingFixture(t *testing.T) (*Daemon, *remoteView, *attachedClient, attachmentConnectionToken, chan ports.Frame) {
15+ t.Helper()
16+ d := newTestDaemon(t, nil, stubClock{})
17+ tr, sends := newCapturingTransport(t)
18+ view := &remoteView{
19+ key: remoteViewKey{
20+ endpoint: "host",
21+ lifecycleID: domain.SessionLifecycleID{1},
22+ sessionName: "remote",
23+ },
24+ screen: vt.NewScreen(80, 22),
25+ }
26+ view.screen.Write([]byte("remote content"))
27+ d.mu.Lock()
28+ require.NoError(t, d.registerRemoteViewLocked(view))
29+ d.mu.Unlock()
30+
31+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
32+ ac.initOverlays()
33+ ac.setAttachmentOwner(view)
34+ view.mu.Lock()
35+ require.True(t, view.registerAttachmentLocked(ac))
36+ view.mu.Unlock()
37+ token := attachmentOwnerToken(view, ac, tr)
38+ require.True(t, token.current())
39+ return d, view, ac, token, sends
40+}
41+
42+func TestRemoteFrameRoutingResetRepaintsComposedFrameAndAckAdvancesOutput(t *testing.T) {
43+ d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)
44+
45+ beforeEpoch := ac.output.currentEpoch()
46+ d.handleAttachmentClientFrame(token, ports.Frame{
47+ Type: ports.MsgOutputResetRequest,
48+ Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
49+ })
50+ frame := awaitFrame(t, sends, ports.MsgOutput)
51+ output, err := ports.UnmarshalOutput(frame.Payload)
52+ require.NoError(t, err)
53+ require.True(t, output.Full)
54+ require.Greater(t, output.Epoch, beforeEpoch)
55+ require.Nil(t, ac.currentAttachmentSession())
56+ require.Empty(t, d.sessions)
57+
58+ d.handleAttachmentClientFrame(token, ports.Frame{
59+ Type: ports.MsgAck,
60+ Payload: mustMarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New}),
61+ })
62+ ac.sendMu.Lock()
63+ require.Equal(t, output.New, ac.output.acked)
64+ ac.sendMu.Unlock()
65+}
66+
67+func TestRemoteFrameRoutingAckRepaintsPrivateVTAfterOutputWindowBackpressure(t *testing.T) {
68+ d, view, ac, token, sends := newRemoteFrameRoutingFixture(t)
69+ ac.sendMu.Lock()
70+ ac.output = newOutputStateStream(1)
71+ ac.sendMu.Unlock()
72+
73+ require.Equal(t, paintEmitted, d.paintRemoteView(view, ac, true, token))
74+ first := awaitFrame(t, sends, ports.MsgOutput)
75+ firstOutput, err := ports.UnmarshalOutput(first.Payload)
76+ require.NoError(t, err)
77+
78+ view.mu.Lock()
79+ view.screen.Write([]byte(" updated"))
80+ view.mu.Unlock()
81+ require.Equal(t, paintBlockedCapacity, d.paintRemoteView(view, ac, false, token))
82+
83+ d.handleAttachmentClientFrame(token, ports.Frame{
84+ Type: ports.MsgAck,
85+ Payload: mustMarshalAck(ports.Ack{Epoch: firstOutput.Epoch, State: firstOutput.New}),
86+ })
87+ second := awaitFrame(t, sends, ports.MsgOutput)
88+ secondOutput, err := ports.UnmarshalOutput(second.Payload)
89+ require.NoError(t, err)
90+ require.Equal(t, firstOutput.Epoch, secondOutput.Epoch)
91+ require.Equal(t, firstOutput.New, secondOutput.Base)
92+ require.Greater(t, secondOutput.New, firstOutput.New)
93+}
94+
95+func TestRemoteFrameRoutingThemeUpdatesOnlyAttachmentChromeAndRepaints(t *testing.T) {
96+ d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)
97+ theme := ports.Theme{
98+ HasForeground: true,
99+ Foreground: renderer.RGB{R: 1, G: 2, B: 3},
100+ HasBackground: true,
101+ Background: renderer.RGB{R: 4, G: 5, B: 6},
102+ SchemeKnown: true,
103+ Light: true,
104+ }
105+
106+ d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(theme)})
107+ frame := awaitFrame(t, sends, ports.MsgOutput)
108+ output, err := ports.UnmarshalOutput(frame.Payload)
109+ require.NoError(t, err)
110+ require.True(t, output.Full)
111+
112+ want := d.resolveAppliedTheme(themeFromMessage(theme))
113+ got := ac.getAppliedTheme()
114+ require.Equal(t, want.Raw, got.Raw)
115+ require.Equal(t, want.Resolved, got.Resolved)
116+ require.Greater(t, got.Generation, uint64(0))
117+ require.Nil(t, ac.currentAttachmentSession())
118+ require.Empty(t, d.sessions)
119+ require.Empty(t, d.notices.history())
120+}
121+
122+func TestRemoteFrameRoutingResizeRebasesOuterAndPrivateScreenWithoutLocalPTY(t *testing.T) {
123+ d, view, ac, token, sends := newRemoteFrameRoutingFixture(t)
124+ beforeEpoch := ac.output.currentEpoch()
125+
126+ d.handleAttachmentClientFrame(token, ports.Frame{
127+ Type: ports.MsgResize,
128+ Payload: mustMarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}}),
129+ })
130+ frame := awaitFrame(t, sends, ports.MsgOutput)
131+ output, err := ports.UnmarshalOutput(frame.Payload)
132+ require.NoError(t, err)
133+ require.True(t, output.Full)
134+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, output.Size)
135+ require.Greater(t, ac.output.currentEpoch(), beforeEpoch)
136+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, ac.sizeSnapshot())
137+
138+ view.mu.Lock()
139+ require.Equal(t, 100, view.screen.Frame.Width)
140+ require.Equal(t, 28, view.screen.Frame.Height)
141+ view.mu.Unlock()
142+ require.Nil(t, ac.currentAttachmentSession())
143+ require.Empty(t, d.sessions)
144+}
145+
146+func TestRemoteFrameRoutingClientNoticeUsesAttachmentToastOnly(t *testing.T) {
147+ d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)
148+
149+ d.handleAttachmentClientFrame(token, ports.Frame{
150+ Type: ports.MsgClientNotice,
151+ Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded}),
152+ })
153+ awaitFrame(t, sends, ports.MsgOutput)
154+ toasts, _ := visibleToasts(ac)
155+ require.Len(t, toasts, 1)
156+ require.Equal(t, domain.NoticeConnection, toasts[0].Code)
157+ require.Empty(t, toasts[0].SessionID)
158+ require.Empty(t, d.notices.history(), "remote client notices must not create local session history")
159+
160+ d.handleAttachmentClientFrame(token, ports.Frame{
161+ Type: ports.MsgClientNotice,
162+ Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkConnected}),
163+ })
164+ awaitFrame(t, sends, ports.MsgOutput)
165+ toasts, _ = visibleToasts(ac)
166+ require.Empty(t, toasts)
167+}
168+
169+func TestRemoteFrameRoutingImageAndCommandFailClosed(t *testing.T) {
170+ d, _, _, token, sends := newRemoteFrameRoutingFixture(t)
171+ d.tempDir = t.TempDir()
172+
173+ d.handleAttachmentClientFrame(token, ports.Frame{
174+ Type: ports.MsgImagePush,
175+ Payload: ports.MarshalImagePush(ports.ImagePush{Mime: "image/png", Data: []byte("not written")}),
176+ })
177+ entries, err := os.ReadDir(d.tempDir)
178+ require.NoError(t, err)
179+ require.Empty(t, entries)
180+
181+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
182+ Version: ports.ProtocolVersion,
183+ RequestID: 7,
184+ Attached: true,
185+ Slug: "new-session",
186+ })
187+ require.NoError(t, err)
188+ d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})
189+ resultFrame := awaitFrame(t, sends, ports.MsgCommandResult)
190+ result, err := ports.UnmarshalCommandResult(resultFrame.Payload)
191+ require.NoError(t, err)
192+ require.Equal(t, uint64(7), result.RequestID)
193+ require.False(t, result.OK)
194+ require.Equal(t, ports.ErrNoSuchTarget, result.Code)
195+ require.Empty(t, d.sessions)
196+}
@@ -0,0 +1,196 @@
1+package daemon
2+
3+import (
4+ "os"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/pkg/renderer"
10+ "github.com/bnema/vev/pkg/vt"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func newRemoteFrameRoutingFixture(t *testing.T) (*Daemon, *remoteView, *attachedClient, attachmentConnectionToken, chan ports.Frame) {
15+ t.Helper()
16+ d := newTestDaemon(t, nil, stubClock{})
17+ tr, sends := newCapturingTransport(t)
18+ view := &remoteView{
19+ key: remoteViewKey{
20+ endpoint: "host",
21+ lifecycleID: domain.SessionLifecycleID{1},
22+ sessionName: "remote",
23+ },
24+ screen: vt.NewScreen(80, 22),
25+ }
26+ view.screen.Write([]byte("remote content"))
27+ d.mu.Lock()
28+ require.NoError(t, d.registerRemoteViewLocked(view))
29+ d.mu.Unlock()
30+
31+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
32+ ac.initOverlays()
33+ ac.setAttachmentOwner(view)
34+ view.mu.Lock()
35+ require.True(t, view.registerAttachmentLocked(ac))
36+ view.mu.Unlock()
37+ token := attachmentOwnerToken(view, ac, tr)
38+ require.True(t, token.current())
39+ return d, view, ac, token, sends
40+}
41+
42+func TestRemoteFrameRoutingResetRepaintsComposedFrameAndAckAdvancesOutput(t *testing.T) {
43+ d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)
44+
45+ beforeEpoch := ac.output.currentEpoch()
46+ d.handleAttachmentClientFrame(token, ports.Frame{
47+ Type: ports.MsgOutputResetRequest,
48+ Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
49+ })
50+ frame := awaitFrame(t, sends, ports.MsgOutput)
51+ output, err := ports.UnmarshalOutput(frame.Payload)
52+ require.NoError(t, err)
53+ require.True(t, output.Full)
54+ require.Greater(t, output.Epoch, beforeEpoch)
55+ require.Nil(t, ac.currentAttachmentSession())
56+ require.Empty(t, d.sessions)
57+
58+ d.handleAttachmentClientFrame(token, ports.Frame{
59+ Type: ports.MsgAck,
60+ Payload: mustMarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New}),
61+ })
62+ ac.sendMu.Lock()
63+ require.Equal(t, output.New, ac.output.acked)
64+ ac.sendMu.Unlock()
65+}
66+
67+func TestRemoteFrameRoutingAckRepaintsPrivateVTAfterOutputWindowBackpressure(t *testing.T) {
68+ d, view, ac, token, sends := newRemoteFrameRoutingFixture(t)
69+ ac.sendMu.Lock()
70+ ac.output = newOutputStateStream(1)
71+ ac.sendMu.Unlock()
72+
73+ require.Equal(t, paintEmitted, d.paintRemoteView(view, ac, true, token))
74+ first := awaitFrame(t, sends, ports.MsgOutput)
75+ firstOutput, err := ports.UnmarshalOutput(first.Payload)
76+ require.NoError(t, err)
77+
78+ view.mu.Lock()
79+ view.screen.Write([]byte(" updated"))
80+ view.mu.Unlock()
81+ require.Equal(t, paintBlockedCapacity, d.paintRemoteView(view, ac, false, token))
82+
83+ d.handleAttachmentClientFrame(token, ports.Frame{
84+ Type: ports.MsgAck,
85+ Payload: mustMarshalAck(ports.Ack{Epoch: firstOutput.Epoch, State: firstOutput.New}),
86+ })
87+ second := awaitFrame(t, sends, ports.MsgOutput)
88+ secondOutput, err := ports.UnmarshalOutput(second.Payload)
89+ require.NoError(t, err)
90+ require.Equal(t, firstOutput.Epoch, secondOutput.Epoch)
91+ require.Equal(t, firstOutput.New, secondOutput.Base)
92+ require.Greater(t, secondOutput.New, firstOutput.New)
93+}
94+
95+func TestRemoteFrameRoutingThemeUpdatesOnlyAttachmentChromeAndRepaints(t *testing.T) {
96+ d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)
97+ theme := ports.Theme{
98+ HasForeground: true,
99+ Foreground: renderer.RGB{R: 1, G: 2, B: 3},
100+ HasBackground: true,
101+ Background: renderer.RGB{R: 4, G: 5, B: 6},
102+ SchemeKnown: true,
103+ Light: true,
104+ }
105+
106+ d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(theme)})
107+ frame := awaitFrame(t, sends, ports.MsgOutput)
108+ output, err := ports.UnmarshalOutput(frame.Payload)
109+ require.NoError(t, err)
110+ require.True(t, output.Full)
111+
112+ want := d.resolveAppliedTheme(themeFromMessage(theme))
113+ got := ac.getAppliedTheme()
114+ require.Equal(t, want.Raw, got.Raw)
115+ require.Equal(t, want.Resolved, got.Resolved)
116+ require.Greater(t, got.Generation, uint64(0))
117+ require.Nil(t, ac.currentAttachmentSession())
118+ require.Empty(t, d.sessions)
119+ require.Empty(t, d.notices.history())
120+}
121+
122+func TestRemoteFrameRoutingResizeRebasesOuterAndPrivateScreenWithoutLocalPTY(t *testing.T) {
123+ d, view, ac, token, sends := newRemoteFrameRoutingFixture(t)
124+ beforeEpoch := ac.output.currentEpoch()
125+
126+ d.handleAttachmentClientFrame(token, ports.Frame{
127+ Type: ports.MsgResize,
128+ Payload: mustMarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}}),
129+ })
130+ frame := awaitFrame(t, sends, ports.MsgOutput)
131+ output, err := ports.UnmarshalOutput(frame.Payload)
132+ require.NoError(t, err)
133+ require.True(t, output.Full)
134+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, output.Size)
135+ require.Greater(t, ac.output.currentEpoch(), beforeEpoch)
136+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, ac.sizeSnapshot())
137+
138+ view.mu.Lock()
139+ require.Equal(t, 100, view.screen.Frame.Width)
140+ require.Equal(t, 28, view.screen.Frame.Height)
141+ view.mu.Unlock()
142+ require.Nil(t, ac.currentAttachmentSession())
143+ require.Empty(t, d.sessions)
144+}
145+
146+func TestRemoteFrameRoutingClientNoticeUsesAttachmentToastOnly(t *testing.T) {
147+ d, _, ac, token, sends := newRemoteFrameRoutingFixture(t)
148+
149+ d.handleAttachmentClientFrame(token, ports.Frame{
150+ Type: ports.MsgClientNotice,
151+ Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded}),
152+ })
153+ awaitFrame(t, sends, ports.MsgOutput)
154+ toasts, _ := visibleToasts(ac)
155+ require.Len(t, toasts, 1)
156+ require.Equal(t, domain.NoticeConnection, toasts[0].Code)
157+ require.Empty(t, toasts[0].SessionID)
158+ require.Empty(t, d.notices.history(), "remote client notices must not create local session history")
159+
160+ d.handleAttachmentClientFrame(token, ports.Frame{
161+ Type: ports.MsgClientNotice,
162+ Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkConnected}),
163+ })
164+ awaitFrame(t, sends, ports.MsgOutput)
165+ toasts, _ = visibleToasts(ac)
166+ require.Empty(t, toasts)
167+}
168+
169+func TestRemoteFrameRoutingImageAndCommandFailClosed(t *testing.T) {
170+ d, _, _, token, sends := newRemoteFrameRoutingFixture(t)
171+ d.tempDir = t.TempDir()
172+
173+ d.handleAttachmentClientFrame(token, ports.Frame{
174+ Type: ports.MsgImagePush,
175+ Payload: ports.MarshalImagePush(ports.ImagePush{Mime: "image/png", Data: []byte("not written")}),
176+ })
177+ entries, err := os.ReadDir(d.tempDir)
178+ require.NoError(t, err)
179+ require.Empty(t, entries)
180+
181+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
182+ Version: ports.ProtocolVersion,
183+ RequestID: 7,
184+ Attached: true,
185+ Slug: "new-session",
186+ })
187+ require.NoError(t, err)
188+ d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})
189+ resultFrame := awaitFrame(t, sends, ports.MsgCommandResult)
190+ result, err := ports.UnmarshalCommandResult(resultFrame.Payload)
191+ require.NoError(t, err)
192+ require.Equal(t, uint64(7), result.RequestID)
193+ require.False(t, result.OK)
194+ require.Equal(t, ports.ErrNoSuchTarget, result.Code)
195+ require.Empty(t, d.sessions)
196+}
M internal/usecase/daemon/remote_render.go +21 -0
@@ -19,6 +19,27 @@ type remoteViewRenderSnapshot struct {
1919 origin string
2020 }
2121
22+// resizeScreen updates only the private remote VT surface. The attachment
23+// owns the outer window and callers must not route this through local session
24+// geometry or PTY resize machinery.
25+func (v *remoteView) resizeScreen(size domain.Size) bool {
26+ if v == nil || !size.Valid() {
27+ return false
28+ }
29+ content := contentSize(size)
30+ v.mu.Lock()
31+ defer v.mu.Unlock()
32+ if v.closed {
33+ return false
34+ }
35+ if v.screen == nil {
36+ v.screen = vt.NewScreen(content.Cols, content.Rows)
37+ } else if v.screen.Frame.Width != content.Cols || v.screen.Frame.Height != content.Rows {
38+ v.screen.Resize(content.Cols, content.Rows)
39+ }
40+ return true
41+}
42+
2243 // renderSnapshot is the remote-content ownership boundary. It copies every
2344 // mutable VT and metadata value while view.mu is held, then releases that lock
2445 // before any attachment, overlay, renderer, or transport work begins.
@@ -19,6 +19,27 @@ type remoteViewRenderSnapshot struct {
19 origin string19 origin string
20 }20 }
21 21
22+// resizeScreen updates only the private remote VT surface. The attachment
23+// owns the outer window and callers must not route this through local session
24+// geometry or PTY resize machinery.
25+func (v *remoteView) resizeScreen(size domain.Size) bool {
26+ if v == nil || !size.Valid() {
27+ return false
28+ }
29+ content := contentSize(size)
30+ v.mu.Lock()
31+ defer v.mu.Unlock()
32+ if v.closed {
33+ return false
34+ }
35+ if v.screen == nil {
36+ v.screen = vt.NewScreen(content.Cols, content.Rows)
37+ } else if v.screen.Frame.Width != content.Cols || v.screen.Frame.Height != content.Rows {
38+ v.screen.Resize(content.Cols, content.Rows)
39+ }
40+ return true
41+}
42+
22 // renderSnapshot is the remote-content ownership boundary. It copies every43 // renderSnapshot is the remote-content ownership boundary. It copies every
23 // mutable VT and metadata value while view.mu is held, then releases that lock44 // mutable VT and metadata value while view.mu is held, then releases that lock
24 // before any attachment, overlay, renderer, or transport work begins.45 // before any attachment, overlay, renderer, or transport work begins.

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