feat(daemon): route remote attachment frames locally
- commit
- ac571a722cb7752ec1f85cc25c6c0e34b96d45c7
- author
- brice · · 2026-08-06 17:55 UTC
- parent
- 2a4b043
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 875 | 875 | return false | |
| 876 | 876 | } | |
| 877 | 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 | 886 | token.ac.setClientTheme(clientTheme) | |
| 879 | 887 | sess := token.localSession() | |
| 880 | 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 | 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 | 940 | if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() { | |
| 926 | 941 | // The picker preview geometry is attachment-local. A resize must | |
| 927 | 942 | // invalidate any in-flight remote request and fetch the selected row | |
| @@ -989,7 +1004,16 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken | |||
| 989 | 1004 | } | |
| 990 | 1005 | sess := token.localSession() | |
| 991 | 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 | 1013 | d.notices.routingMu.Unlock() | |
| 1014 | + | if repaint && token.attachmentEffectCurrent() { | |
| 1015 | + | d.paintRemoteView(view, token.ac, false, token) | |
| 1016 | + | } | |
| 993 | 1017 | return | |
| 994 | 1018 | } | |
| 995 | 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 | 1084 | // recordClientNotice mutates notice history and the selected attachment while | |
| 1037 | 1085 | // routingMu keeps another connection from changing that selection. Rendering is left | |
| 1038 | 1086 | // to the caller after it releases routingMu. | |
| @@ -875,6 +875,14 @@ func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg po | |||||
| 875 | return false | 875 | 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 must | 941 | // The picker preview geometry is attachment-local. A resize must | ||
| 927 | // invalidate any in-flight remote request and fetch the selected row | 942 | // 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 | return | 1017 | 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 while | 1084 | // recordClientNotice mutates notice history and the selected attachment while | ||
| 1037 | // routingMu keeps another connection from changing that selection. Rendering is left | 1085 | // 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 | |||
| 175 | 175 | result.Text = "attached commands cannot target themselves" | |
| 176 | 176 | 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 | 183 | if request.TargetSession != "" || request.TargetTab != "" || request.TargetPane != "" { | |
| 179 | 184 | result.Code = ports.ErrInvalidCommandArgs | |
| 180 | 185 | result.Text = "attached commands cannot override their active session target" | |
| @@ -238,7 +243,9 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool { | |||
| 238 | 243 | ac.pipelineCache = composeCacheInput{} | |
| 239 | 244 | ac.pipelineScratch = composeCacheInput{} | |
| 240 | 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 | 249 | go d.paint(sess, ac, true, token.lease) | |
| 243 | 250 | } | |
| 244 | 251 | return true | |
| @@ -250,12 +257,25 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64) | |||
| 250 | 257 | return false | |
| 251 | 258 | } | |
| 252 | 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 | 265 | ac.sendMu.Unlock() | |
| 266 | + | if !advanced { | |
| 267 | + | return true | |
| 268 | + | } | |
| 255 | 269 | if sess := token.localSession(); sess != nil { | |
| 256 | 270 | if rc := sess.core().coordinator.Load(); rc != nil { | |
| 257 | 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 | 280 | return true | |
| 261 | 281 | } | |
| @@ -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 result | 176 | 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.ErrInvalidCommandArgs | 184 | 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 true | 251 | return true | ||
| @@ -250,12 +257,25 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64) | |||||
| 250 | return false | 257 | 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 true | 280 | return true | ||
| 261 | } | 281 | } | ||
M internal/usecase/daemon/clipboard.go +5 -2
| @@ -27,14 +27,14 @@ var ( | |||
| 27 | 27 | ) | |
| 28 | 28 | ||
| 29 | 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 | 31 | return | |
| 32 | 32 | } | |
| 33 | 33 | d.handleImagePushForAttachment(token, ip) | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 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 | 38 | return | |
| 39 | 39 | } | |
| 40 | 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 | 87 | func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) { | |
| 88 | + | if token.localSession() == nil || !token.attachmentEffectCurrent() { | |
| 89 | + | return "", errAttachmentTransition | |
| 90 | + | } | |
| 88 | 91 | path, err := d.createClipboardImage(ip) | |
| 89 | 92 | if err != nil { | |
| 90 | 93 | 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 | return | 31 | 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 | return | 38 | 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 "", err | 93 | return "", err | ||
M internal/usecase/daemon/notify.go +12 -0
| @@ -491,6 +491,18 @@ func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) { | |||
| 491 | 491 | // repaintForNotice asks for an urgent redraw. It must be called with noticeMu | |
| 492 | 492 | // released: invalidateRender can paint inline, which takes sendMu. | |
| 493 | 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 | 506 | sess := ac.currentSession() | |
| 495 | 507 | if sess == nil { | |
| 496 | 508 | // 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 noticeMu | 491 | // 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, and | 508 | // 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 | |||
| 250 | 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 | 254 | if s == nil { | |
| 255 | - | return | |
| 255 | + | return false | |
| 256 | 256 | } | |
| 257 | 257 | s.lockView() | |
| 258 | 258 | defer s.unlockView() | |
| 259 | 259 | if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked { | |
| 260 | - | return | |
| 260 | + | return false | |
| 261 | 261 | } | |
| 262 | 262 | s.acked = state | |
| 263 | 263 | s.publishOutstanding() | |
| 264 | + | return true | |
| 264 | 265 | } | |
| 265 | 266 | ||
| 266 | 267 | 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 | - | return | 255 | + | 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 | - | return | 260 | + | return false |
| 261 | } | 261 | } | ||
| 262 | s.acked = state | 262 | 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 { | |||
| 19 | 19 | 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 | 43 | // renderSnapshot is the remote-content ownership boundary. It copies every | |
| 23 | 44 | // mutable VT and metadata value while view.mu is held, then releases that lock | |
| 24 | 45 | // before any attachment, overlay, renderer, or transport work begins. | |
| @@ -19,6 +19,27 @@ type remoteViewRenderSnapshot struct { | |||||
| 19 | origin string | 19 | 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 every | 43 | // renderSnapshot is the remote-content ownership boundary. It copies every | ||
| 23 | // mutable VT and metadata value while view.mu is held, then releases that lock | 44 | // 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. | ||