feat(protocol): add proxied content and metadata contract
- commit
- efdd7d6554abd7b256623f70a9b1f5aa7cf6a78c
- author
- brice · · 2026-07-30 15:40 UTC
- committer
- brice · · 2026-07-31 09:02 UTC
- parent
- b6a8ce8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
20 files · +1121 -82
M AGENTS.md +1 -1
| @@ -64,7 +64,7 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o | |||
| 64 | 64 | Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`. | |
| 65 | 65 | ||
| 66 | 66 | - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload. | |
| 67 | - | - Client message types currently occupy `1–12` (`MsgCommand` is `12`); server types currently occupy `16–22` (`MsgCommandResult` is `22`). The reserved bands are `1–15` and `16+` respectively. | |
| 67 | + | - Client message types currently occupy `1–13` (`MsgOutputResetRequest` is `13`); server types currently occupy `16–23` (`MsgSessionMeta` is `23`). The reserved bands are `1–15` and `16+` respectively. | |
| 68 | 68 | - Version negotiation requires strict equality. | |
| 69 | 69 | - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work. | |
| 70 | 70 | - Bump `ProtocolVersion` for any message layout change. | |
| @@ -64,7 +64,7 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o | |||||
| 64 | Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`. | 64 | Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`. | ||
| 65 | 65 | ||||
| 66 | - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload. | 66 | - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload. | ||
| 67 | - | - Client message types currently occupy `1–12` (`MsgCommand` is `12`); server types currently occupy `16–22` (`MsgCommandResult` is `22`). The reserved bands are `1–15` and `16+` respectively. | 67 | + | - Client message types currently occupy `1–13` (`MsgOutputResetRequest` is `13`); server types currently occupy `16–23` (`MsgSessionMeta` is `23`). The reserved bands are `1–15` and `16+` respectively. |
| 68 | - Version negotiation requires strict equality. | 68 | - Version negotiation requires strict equality. | ||
| 69 | - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work. | 69 | - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work. | ||
| 70 | - Bump `ProtocolVersion` for any message layout change. | 70 | - Bump `ProtocolVersion` for any message layout change. | ||
M internal/ports/frame.go +15 -13
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 19 | |
| 4 | + | const ProtocolVersion uint16 = 20 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -10,18 +10,19 @@ type MsgType uint8 | |||
| 10 | 10 | // through 15, server-originated messages from 16, leaving room for growth in | |
| 11 | 11 | // each band. | |
| 12 | 12 | const ( | |
| 13 | - | MsgHello MsgType = 1 | |
| 14 | - | MsgInput MsgType = 2 | |
| 15 | - | MsgResize MsgType = 3 | |
| 16 | - | MsgDetach MsgType = 4 | |
| 17 | - | MsgPing MsgType = 5 | |
| 18 | - | MsgList MsgType = 6 | |
| 19 | - | MsgKill MsgType = 7 | |
| 20 | - | MsgTheme MsgType = 8 | |
| 21 | - | MsgAck MsgType = 9 | |
| 22 | - | MsgImagePush MsgType = 10 | |
| 23 | - | MsgClientNotice MsgType = 11 | |
| 24 | - | MsgCommand MsgType = 12 | |
| 13 | + | MsgHello MsgType = 1 | |
| 14 | + | MsgInput MsgType = 2 | |
| 15 | + | MsgResize MsgType = 3 | |
| 16 | + | MsgDetach MsgType = 4 | |
| 17 | + | MsgPing MsgType = 5 | |
| 18 | + | MsgList MsgType = 6 | |
| 19 | + | MsgKill MsgType = 7 | |
| 20 | + | MsgTheme MsgType = 8 | |
| 21 | + | MsgAck MsgType = 9 | |
| 22 | + | MsgImagePush MsgType = 10 | |
| 23 | + | MsgClientNotice MsgType = 11 | |
| 24 | + | MsgCommand MsgType = 12 | |
| 25 | + | MsgOutputResetRequest MsgType = 13 | |
| 25 | 26 | ||
| 26 | 27 | MsgWelcome MsgType = 16 | |
| 27 | 28 | MsgError MsgType = 17 | |
| @@ -30,6 +31,7 @@ const ( | |||
| 30 | 31 | MsgPong MsgType = 20 | |
| 31 | 32 | MsgSessions MsgType = 21 | |
| 32 | 33 | MsgCommandResult MsgType = 22 | |
| 34 | + | MsgSessionMeta MsgType = 23 | |
| 33 | 35 | ) | |
| 34 | 36 | ||
| 35 | 37 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | // ProtocolVersion is the current vev IPC wire protocol version. | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 4 | - | const ProtocolVersion uint16 = 19 | 4 | + | const ProtocolVersion uint16 = 20 |
| 5 | 5 | ||||
| 6 | // MsgType identifies the kind of payload carried by a Frame. | 6 | // MsgType identifies the kind of payload carried by a Frame. | ||
| 7 | type MsgType uint8 | 7 | type MsgType uint8 | ||
| @@ -10,18 +10,19 @@ type MsgType uint8 | |||||
| 10 | // through 15, server-originated messages from 16, leaving room for growth in | 10 | // through 15, server-originated messages from 16, leaving room for growth in | ||
| 11 | // each band. | 11 | // each band. | ||
| 12 | const ( | 12 | const ( | ||
| 13 | - | MsgHello MsgType = 1 | 13 | + | MsgHello MsgType = 1 |
| 14 | - | MsgInput MsgType = 2 | 14 | + | MsgInput MsgType = 2 |
| 15 | - | MsgResize MsgType = 3 | 15 | + | MsgResize MsgType = 3 |
| 16 | - | MsgDetach MsgType = 4 | 16 | + | MsgDetach MsgType = 4 |
| 17 | - | MsgPing MsgType = 5 | 17 | + | MsgPing MsgType = 5 |
| 18 | - | MsgList MsgType = 6 | 18 | + | MsgList MsgType = 6 |
| 19 | - | MsgKill MsgType = 7 | 19 | + | MsgKill MsgType = 7 |
| 20 | - | MsgTheme MsgType = 8 | 20 | + | MsgTheme MsgType = 8 |
| 21 | - | MsgAck MsgType = 9 | 21 | + | MsgAck MsgType = 9 |
| 22 | - | MsgImagePush MsgType = 10 | 22 | + | MsgImagePush MsgType = 10 |
| 23 | - | MsgClientNotice MsgType = 11 | 23 | + | MsgClientNotice MsgType = 11 |
| 24 | - | MsgCommand MsgType = 12 | 24 | + | MsgCommand MsgType = 12 |
| 25 | + | MsgOutputResetRequest MsgType = 13 | |||
| 25 | 26 | ||||
| 26 | MsgWelcome MsgType = 16 | 27 | MsgWelcome MsgType = 16 | ||
| 27 | MsgError MsgType = 17 | 28 | MsgError MsgType = 17 | ||
| @@ -30,6 +31,7 @@ const ( | |||||
| 30 | MsgPong MsgType = 20 | 31 | MsgPong MsgType = 20 | ||
| 31 | MsgSessions MsgType = 21 | 32 | MsgSessions MsgType = 21 | ||
| 32 | MsgCommandResult MsgType = 22 | 33 | MsgCommandResult MsgType = 22 | ||
| 34 | + | MsgSessionMeta MsgType = 23 | |||
| 33 | ) | 35 | ) | ||
| 34 | 36 | ||||
| 35 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | 37 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | ||
M internal/ports/frame_test.go +13 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 19 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 19", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 20 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 20", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -15,6 +15,15 @@ func TestControlMsgTypes(t *testing.T) { | |||
| 15 | 15 | if MsgCommandResult != 22 { | |
| 16 | 16 | t.Fatalf("MsgCommandResult = %d, want 22", MsgCommandResult) | |
| 17 | 17 | } | |
| 18 | + | if MsgOutputResetRequest != 13 { | |
| 19 | + | t.Fatalf("MsgOutputResetRequest = %d, want 13", MsgOutputResetRequest) | |
| 20 | + | } | |
| 21 | + | if MsgSessionMeta != 23 { | |
| 22 | + | t.Fatalf("MsgSessionMeta = %d, want 23", MsgSessionMeta) | |
| 23 | + | } | |
| 24 | + | if CapabilityProxied != 1<<3 { | |
| 25 | + | t.Fatalf("CapabilityProxied = %d, want 8", CapabilityProxied) | |
| 26 | + | } | |
| 18 | 27 | } | |
| 19 | 28 | ||
| 20 | 29 | func TestMsgTypeUnique(t *testing.T) { | |
| @@ -34,6 +43,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 34 | 43 | {"MsgImagePush", MsgImagePush}, | |
| 35 | 44 | {"MsgClientNotice", MsgClientNotice}, | |
| 36 | 45 | {"MsgCommand", MsgCommand}, | |
| 46 | + | {"MsgOutputResetRequest", MsgOutputResetRequest}, | |
| 37 | 47 | {"MsgWelcome", MsgWelcome}, | |
| 38 | 48 | {"MsgError", MsgError}, | |
| 39 | 49 | {"MsgOutput", MsgOutput}, | |
| @@ -41,6 +51,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 41 | 51 | {"MsgPong", MsgPong}, | |
| 42 | 52 | {"MsgSessions", MsgSessions}, | |
| 43 | 53 | {"MsgCommandResult", MsgCommandResult}, | |
| 54 | + | {"MsgSessionMeta", MsgSessionMeta}, | |
| 44 | 55 | } | |
| 45 | 56 | ||
| 46 | 57 | seen := make(map[MsgType]string, len(tests)) | |
| @@ -3,8 +3,8 @@ package ports | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestProtocolVersion(t *testing.T) { | 5 | func TestProtocolVersion(t *testing.T) { | ||
| 6 | - | if ProtocolVersion != 19 { | 6 | + | if ProtocolVersion != 20 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 19", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 20", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| @@ -15,6 +15,15 @@ func TestControlMsgTypes(t *testing.T) { | |||||
| 15 | if MsgCommandResult != 22 { | 15 | if MsgCommandResult != 22 { | ||
| 16 | t.Fatalf("MsgCommandResult = %d, want 22", MsgCommandResult) | 16 | t.Fatalf("MsgCommandResult = %d, want 22", MsgCommandResult) | ||
| 17 | } | 17 | } | ||
| 18 | + | if MsgOutputResetRequest != 13 { | |||
| 19 | + | t.Fatalf("MsgOutputResetRequest = %d, want 13", MsgOutputResetRequest) | |||
| 20 | + | } | |||
| 21 | + | if MsgSessionMeta != 23 { | |||
| 22 | + | t.Fatalf("MsgSessionMeta = %d, want 23", MsgSessionMeta) | |||
| 23 | + | } | |||
| 24 | + | if CapabilityProxied != 1<<3 { | |||
| 25 | + | t.Fatalf("CapabilityProxied = %d, want 8", CapabilityProxied) | |||
| 26 | + | } | |||
| 18 | } | 27 | } | ||
| 19 | 28 | ||||
| 20 | func TestMsgTypeUnique(t *testing.T) { | 29 | func TestMsgTypeUnique(t *testing.T) { | ||
| @@ -34,6 +43,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 34 | {"MsgImagePush", MsgImagePush}, | 43 | {"MsgImagePush", MsgImagePush}, | ||
| 35 | {"MsgClientNotice", MsgClientNotice}, | 44 | {"MsgClientNotice", MsgClientNotice}, | ||
| 36 | {"MsgCommand", MsgCommand}, | 45 | {"MsgCommand", MsgCommand}, | ||
| 46 | + | {"MsgOutputResetRequest", MsgOutputResetRequest}, | |||
| 37 | {"MsgWelcome", MsgWelcome}, | 47 | {"MsgWelcome", MsgWelcome}, | ||
| 38 | {"MsgError", MsgError}, | 48 | {"MsgError", MsgError}, | ||
| 39 | {"MsgOutput", MsgOutput}, | 49 | {"MsgOutput", MsgOutput}, | ||
| @@ -41,6 +51,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 41 | {"MsgPong", MsgPong}, | 51 | {"MsgPong", MsgPong}, | ||
| 42 | {"MsgSessions", MsgSessions}, | 52 | {"MsgSessions", MsgSessions}, | ||
| 43 | {"MsgCommandResult", MsgCommandResult}, | 53 | {"MsgCommandResult", MsgCommandResult}, | ||
| 54 | + | {"MsgSessionMeta", MsgSessionMeta}, | |||
| 44 | } | 55 | } | ||
| 45 | 56 | ||||
| 46 | seen := make(map[MsgType]string, len(tests)) | 57 | seen := make(map[MsgType]string, len(tests)) | ||
M internal/ports/wire.go +144 -0
| @@ -9,6 +9,7 @@ package ports | |||
| 9 | 9 | import ( | |
| 10 | 10 | "encoding/binary" | |
| 11 | 11 | "errors" | |
| 12 | + | "math" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/vev/internal/domain" | |
| 14 | 15 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -23,6 +24,16 @@ var errShortPayload = errors.New("ports: payload too short") | |||
| 23 | 24 | // catches version drift early instead of silently ignoring extra data. | |
| 24 | 25 | var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload") | |
| 25 | 26 | ||
| 27 | + | var errInvalidBoolean = errors.New("ports: invalid boolean flag") | |
| 28 | + | ||
| 29 | + | // ErrInvalidSessionMeta reports metadata that cannot be represented by the | |
| 30 | + | // protocol's ordered uint16-indexed tab layout. | |
| 31 | + | var ErrInvalidSessionMeta = errors.New("ports: invalid session metadata") | |
| 32 | + | ||
| 33 | + | // ErrSessionMetaStringTooLong reports metadata containing a string too large | |
| 34 | + | // for the protocol's uint16 string encoding. | |
| 35 | + | var ErrSessionMetaStringTooLong = errors.New("ports: session metadata string too long") | |
| 36 | + | ||
| 26 | 37 | // Intent values carried by Hello, describing what the client wants to do. | |
| 27 | 38 | const ( | |
| 28 | 39 | IntentEphemeral uint8 = 0 | |
| @@ -36,6 +47,7 @@ const ( | |||
| 36 | 47 | CapabilityResume uint32 = 1 << 0 | |
| 37 | 48 | CapabilityUDP uint32 = 1 << 1 | |
| 38 | 49 | CapabilityPredict uint32 = 1 << 2 | |
| 50 | + | CapabilityProxied uint32 = 1 << 3 | |
| 39 | 51 | ) | |
| 40 | 52 | ||
| 41 | 53 | // ErrorMsg codes. | |
| @@ -65,6 +77,7 @@ const ( | |||
| 65 | 77 | type Hello struct { | |
| 66 | 78 | Version uint16 | |
| 67 | 79 | Intent uint8 | |
| 80 | + | Proxied bool | |
| 68 | 81 | ClientID [16]byte | |
| 69 | 82 | ResumeToken uint64 | |
| 70 | 83 | Name string | |
| @@ -214,6 +227,24 @@ type Sessions struct { | |||
| 214 | 227 | Sessions []SessionInfo | |
| 215 | 228 | } | |
| 216 | 229 | ||
| 230 | + | // OutputResetRequest asks a proxied daemon to rebase its output stream. | |
| 231 | + | type OutputResetRequest struct{} | |
| 232 | + | ||
| 233 | + | // SessionTabMeta describes one tab in a proxied session's authoritative | |
| 234 | + | // metadata snapshot. | |
| 235 | + | type SessionTabMeta struct { | |
| 236 | + | Index uint16 | |
| 237 | + | Name string | |
| 238 | + | Attention bool | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // SessionMeta is the authoritative tab metadata sent to a proxied client. | |
| 242 | + | type SessionMeta struct { | |
| 243 | + | SessionName string | |
| 244 | + | Active uint16 | |
| 245 | + | Tabs []SessionTabMeta | |
| 246 | + | } | |
| 247 | + | ||
| 217 | 248 | // payloadWriter builds a message payload by appending fields in wire order. | |
| 218 | 249 | type payloadWriter struct { | |
| 219 | 250 | b []byte | |
| @@ -270,6 +301,17 @@ func (r *payloadReader) getUint8() (uint8, error) { | |||
| 270 | 301 | return v, nil | |
| 271 | 302 | } | |
| 272 | 303 | ||
| 304 | + | func (r *payloadReader) getBool() (bool, error) { | |
| 305 | + | v, err := r.getUint8() | |
| 306 | + | if err != nil { | |
| 307 | + | return false, err | |
| 308 | + | } | |
| 309 | + | if v > 1 { | |
| 310 | + | return false, errInvalidBoolean | |
| 311 | + | } | |
| 312 | + | return v == 1, nil | |
| 313 | + | } | |
| 314 | + | ||
| 273 | 315 | func (r *payloadReader) getUint16() (uint16, error) { | |
| 274 | 316 | if len(r.b) < 2 { | |
| 275 | 317 | return 0, errShortPayload | |
| @@ -361,6 +403,11 @@ func MarshalHello(h Hello) []byte { | |||
| 361 | 403 | w := payloadWriter{} | |
| 362 | 404 | w.putUint16(h.Version) | |
| 363 | 405 | w.putUint8(h.Intent) | |
| 406 | + | if h.Proxied { | |
| 407 | + | w.putUint8(1) | |
| 408 | + | } else { | |
| 409 | + | w.putUint8(0) | |
| 410 | + | } | |
| 364 | 411 | w.putBytes(h.ClientID[:]) | |
| 365 | 412 | w.putUint64(h.ResumeToken) | |
| 366 | 413 | w.putString(h.Name) | |
| @@ -393,6 +440,9 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 393 | 440 | if h.Intent, err = r.getUint8(); err != nil { | |
| 394 | 441 | return Hello{}, err | |
| 395 | 442 | } | |
| 443 | + | if h.Proxied, err = r.getBool(); err != nil { | |
| 444 | + | return Hello{}, err | |
| 445 | + | } | |
| 396 | 446 | clientID, err := r.getBytes(len(h.ClientID)) | |
| 397 | 447 | if err != nil { | |
| 398 | 448 | return Hello{}, err | |
| @@ -835,6 +885,100 @@ func UnmarshalList(b []byte) (List, error) { | |||
| 835 | 885 | return List{}, nil | |
| 836 | 886 | } | |
| 837 | 887 | ||
| 888 | + | // MarshalOutputResetRequest encodes an OutputResetRequest payload (always | |
| 889 | + | // empty). | |
| 890 | + | func MarshalOutputResetRequest(OutputResetRequest) []byte { | |
| 891 | + | return nil | |
| 892 | + | } | |
| 893 | + | ||
| 894 | + | // UnmarshalOutputResetRequest decodes a strict empty OutputResetRequest | |
| 895 | + | // payload. | |
| 896 | + | func UnmarshalOutputResetRequest(b []byte) (OutputResetRequest, error) { | |
| 897 | + | r := payloadReader{b: b} | |
| 898 | + | if err := r.done(); err != nil { | |
| 899 | + | return OutputResetRequest{}, err | |
| 900 | + | } | |
| 901 | + | return OutputResetRequest{}, nil | |
| 902 | + | } | |
| 903 | + | ||
| 904 | + | // MarshalSessionMeta encodes m when it satisfies the protocol's authoritative | |
| 905 | + | // ordered-tab constraints. | |
| 906 | + | func MarshalSessionMeta(m SessionMeta) ([]byte, error) { | |
| 907 | + | if len(m.Tabs) == 0 || len(m.Tabs) > math.MaxUint16 || int(m.Active) >= len(m.Tabs) { | |
| 908 | + | return nil, ErrInvalidSessionMeta | |
| 909 | + | } | |
| 910 | + | if len(m.SessionName) > math.MaxUint16 { | |
| 911 | + | return nil, ErrSessionMetaStringTooLong | |
| 912 | + | } | |
| 913 | + | for i, tab := range m.Tabs { | |
| 914 | + | if tab.Index != uint16(i) { | |
| 915 | + | return nil, ErrInvalidSessionMeta | |
| 916 | + | } | |
| 917 | + | if len(tab.Name) > math.MaxUint16 { | |
| 918 | + | return nil, ErrSessionMetaStringTooLong | |
| 919 | + | } | |
| 920 | + | } | |
| 921 | + | ||
| 922 | + | w := payloadWriter{} | |
| 923 | + | w.putString(m.SessionName) | |
| 924 | + | w.putUint16(m.Active) | |
| 925 | + | w.putUint16(uint16(len(m.Tabs))) | |
| 926 | + | for _, tab := range m.Tabs { | |
| 927 | + | w.putUint16(tab.Index) | |
| 928 | + | w.putString(tab.Name) | |
| 929 | + | if tab.Attention { | |
| 930 | + | w.putUint8(1) | |
| 931 | + | } else { | |
| 932 | + | w.putUint8(0) | |
| 933 | + | } | |
| 934 | + | } | |
| 935 | + | return w.b, nil | |
| 936 | + | } | |
| 937 | + | ||
| 938 | + | // UnmarshalSessionMeta decodes a strict authoritative metadata snapshot. | |
| 939 | + | func UnmarshalSessionMeta(b []byte) (SessionMeta, error) { | |
| 940 | + | r := payloadReader{b: b} | |
| 941 | + | var m SessionMeta | |
| 942 | + | var err error | |
| 943 | + | if m.SessionName, err = r.getString(); err != nil { | |
| 944 | + | return SessionMeta{}, err | |
| 945 | + | } | |
| 946 | + | if m.Active, err = r.getUint16(); err != nil { | |
| 947 | + | return SessionMeta{}, err | |
| 948 | + | } | |
| 949 | + | count, err := r.getUint16() | |
| 950 | + | if err != nil { | |
| 951 | + | return SessionMeta{}, err | |
| 952 | + | } | |
| 953 | + | if count == 0 || int(m.Active) >= int(count) { | |
| 954 | + | return SessionMeta{}, ErrInvalidSessionMeta | |
| 955 | + | } | |
| 956 | + | if uint64(count) > uint64(len(r.b)/5) { | |
| 957 | + | return SessionMeta{}, errShortPayload | |
| 958 | + | } | |
| 959 | + | m.Tabs = make([]SessionTabMeta, 0, int(count)) | |
| 960 | + | for i := range int(count) { | |
| 961 | + | var tab SessionTabMeta | |
| 962 | + | if tab.Index, err = r.getUint16(); err != nil { | |
| 963 | + | return SessionMeta{}, err | |
| 964 | + | } | |
| 965 | + | if tab.Index != uint16(i) { | |
| 966 | + | return SessionMeta{}, ErrInvalidSessionMeta | |
| 967 | + | } | |
| 968 | + | if tab.Name, err = r.getString(); err != nil { | |
| 969 | + | return SessionMeta{}, err | |
| 970 | + | } | |
| 971 | + | if tab.Attention, err = r.getBool(); err != nil { | |
| 972 | + | return SessionMeta{}, err | |
| 973 | + | } | |
| 974 | + | m.Tabs = append(m.Tabs, tab) | |
| 975 | + | } | |
| 976 | + | if err := r.done(); err != nil { | |
| 977 | + | return SessionMeta{}, err | |
| 978 | + | } | |
| 979 | + | return m, nil | |
| 980 | + | } | |
| 981 | + | ||
| 838 | 982 | // MarshalKill encodes m into a Kill message payload. | |
| 839 | 983 | func MarshalKill(m Kill) []byte { | |
| 840 | 984 | w := payloadWriter{} | |
| @@ -9,6 +9,7 @@ package ports | |||||
| 9 | import ( | 9 | import ( | ||
| 10 | "encoding/binary" | 10 | "encoding/binary" | ||
| 11 | "errors" | 11 | "errors" | ||
| 12 | + | "math" | |||
| 12 | 13 | ||||
| 13 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 14 | "github.com/bnema/vev/pkg/renderer" | 15 | "github.com/bnema/vev/pkg/renderer" | ||
| @@ -23,6 +24,16 @@ var errShortPayload = errors.New("ports: payload too short") | |||||
| 23 | // catches version drift early instead of silently ignoring extra data. | 24 | // catches version drift early instead of silently ignoring extra data. | ||
| 24 | var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload") | 25 | var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload") | ||
| 25 | 26 | ||||
| 27 | + | var errInvalidBoolean = errors.New("ports: invalid boolean flag") | |||
| 28 | + | ||||
| 29 | + | // ErrInvalidSessionMeta reports metadata that cannot be represented by the | |||
| 30 | + | // protocol's ordered uint16-indexed tab layout. | |||
| 31 | + | var ErrInvalidSessionMeta = errors.New("ports: invalid session metadata") | |||
| 32 | + | ||||
| 33 | + | // ErrSessionMetaStringTooLong reports metadata containing a string too large | |||
| 34 | + | // for the protocol's uint16 string encoding. | |||
| 35 | + | var ErrSessionMetaStringTooLong = errors.New("ports: session metadata string too long") | |||
| 36 | + | ||||
| 26 | // Intent values carried by Hello, describing what the client wants to do. | 37 | // Intent values carried by Hello, describing what the client wants to do. | ||
| 27 | const ( | 38 | const ( | ||
| 28 | IntentEphemeral uint8 = 0 | 39 | IntentEphemeral uint8 = 0 | ||
| @@ -36,6 +47,7 @@ const ( | |||||
| 36 | CapabilityResume uint32 = 1 << 0 | 47 | CapabilityResume uint32 = 1 << 0 | ||
| 37 | CapabilityUDP uint32 = 1 << 1 | 48 | CapabilityUDP uint32 = 1 << 1 | ||
| 38 | CapabilityPredict uint32 = 1 << 2 | 49 | CapabilityPredict uint32 = 1 << 2 | ||
| 50 | + | CapabilityProxied uint32 = 1 << 3 | |||
| 39 | ) | 51 | ) | ||
| 40 | 52 | ||||
| 41 | // ErrorMsg codes. | 53 | // ErrorMsg codes. | ||
| @@ -65,6 +77,7 @@ const ( | |||||
| 65 | type Hello struct { | 77 | type Hello struct { | ||
| 66 | Version uint16 | 78 | Version uint16 | ||
| 67 | Intent uint8 | 79 | Intent uint8 | ||
| 80 | + | Proxied bool | |||
| 68 | ClientID [16]byte | 81 | ClientID [16]byte | ||
| 69 | ResumeToken uint64 | 82 | ResumeToken uint64 | ||
| 70 | Name string | 83 | Name string | ||
| @@ -214,6 +227,24 @@ type Sessions struct { | |||||
| 214 | Sessions []SessionInfo | 227 | Sessions []SessionInfo | ||
| 215 | } | 228 | } | ||
| 216 | 229 | ||||
| 230 | + | // OutputResetRequest asks a proxied daemon to rebase its output stream. | |||
| 231 | + | type OutputResetRequest struct{} | |||
| 232 | + | ||||
| 233 | + | // SessionTabMeta describes one tab in a proxied session's authoritative | |||
| 234 | + | // metadata snapshot. | |||
| 235 | + | type SessionTabMeta struct { | |||
| 236 | + | Index uint16 | |||
| 237 | + | Name string | |||
| 238 | + | Attention bool | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // SessionMeta is the authoritative tab metadata sent to a proxied client. | |||
| 242 | + | type SessionMeta struct { | |||
| 243 | + | SessionName string | |||
| 244 | + | Active uint16 | |||
| 245 | + | Tabs []SessionTabMeta | |||
| 246 | + | } | |||
| 247 | + | ||||
| 217 | // payloadWriter builds a message payload by appending fields in wire order. | 248 | // payloadWriter builds a message payload by appending fields in wire order. | ||
| 218 | type payloadWriter struct { | 249 | type payloadWriter struct { | ||
| 219 | b []byte | 250 | b []byte | ||
| @@ -270,6 +301,17 @@ func (r *payloadReader) getUint8() (uint8, error) { | |||||
| 270 | return v, nil | 301 | return v, nil | ||
| 271 | } | 302 | } | ||
| 272 | 303 | ||||
| 304 | + | func (r *payloadReader) getBool() (bool, error) { | |||
| 305 | + | v, err := r.getUint8() | |||
| 306 | + | if err != nil { | |||
| 307 | + | return false, err | |||
| 308 | + | } | |||
| 309 | + | if v > 1 { | |||
| 310 | + | return false, errInvalidBoolean | |||
| 311 | + | } | |||
| 312 | + | return v == 1, nil | |||
| 313 | + | } | |||
| 314 | + | ||||
| 273 | func (r *payloadReader) getUint16() (uint16, error) { | 315 | func (r *payloadReader) getUint16() (uint16, error) { | ||
| 274 | if len(r.b) < 2 { | 316 | if len(r.b) < 2 { | ||
| 275 | return 0, errShortPayload | 317 | return 0, errShortPayload | ||
| @@ -361,6 +403,11 @@ func MarshalHello(h Hello) []byte { | |||||
| 361 | w := payloadWriter{} | 403 | w := payloadWriter{} | ||
| 362 | w.putUint16(h.Version) | 404 | w.putUint16(h.Version) | ||
| 363 | w.putUint8(h.Intent) | 405 | w.putUint8(h.Intent) | ||
| 406 | + | if h.Proxied { | |||
| 407 | + | w.putUint8(1) | |||
| 408 | + | } else { | |||
| 409 | + | w.putUint8(0) | |||
| 410 | + | } | |||
| 364 | w.putBytes(h.ClientID[:]) | 411 | w.putBytes(h.ClientID[:]) | ||
| 365 | w.putUint64(h.ResumeToken) | 412 | w.putUint64(h.ResumeToken) | ||
| 366 | w.putString(h.Name) | 413 | w.putString(h.Name) | ||
| @@ -393,6 +440,9 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 393 | if h.Intent, err = r.getUint8(); err != nil { | 440 | if h.Intent, err = r.getUint8(); err != nil { | ||
| 394 | return Hello{}, err | 441 | return Hello{}, err | ||
| 395 | } | 442 | } | ||
| 443 | + | if h.Proxied, err = r.getBool(); err != nil { | |||
| 444 | + | return Hello{}, err | |||
| 445 | + | } | |||
| 396 | clientID, err := r.getBytes(len(h.ClientID)) | 446 | clientID, err := r.getBytes(len(h.ClientID)) | ||
| 397 | if err != nil { | 447 | if err != nil { | ||
| 398 | return Hello{}, err | 448 | return Hello{}, err | ||
| @@ -835,6 +885,100 @@ func UnmarshalList(b []byte) (List, error) { | |||||
| 835 | return List{}, nil | 885 | return List{}, nil | ||
| 836 | } | 886 | } | ||
| 837 | 887 | ||||
| 888 | + | // MarshalOutputResetRequest encodes an OutputResetRequest payload (always | |||
| 889 | + | // empty). | |||
| 890 | + | func MarshalOutputResetRequest(OutputResetRequest) []byte { | |||
| 891 | + | return nil | |||
| 892 | + | } | |||
| 893 | + | ||||
| 894 | + | // UnmarshalOutputResetRequest decodes a strict empty OutputResetRequest | |||
| 895 | + | // payload. | |||
| 896 | + | func UnmarshalOutputResetRequest(b []byte) (OutputResetRequest, error) { | |||
| 897 | + | r := payloadReader{b: b} | |||
| 898 | + | if err := r.done(); err != nil { | |||
| 899 | + | return OutputResetRequest{}, err | |||
| 900 | + | } | |||
| 901 | + | return OutputResetRequest{}, nil | |||
| 902 | + | } | |||
| 903 | + | ||||
| 904 | + | // MarshalSessionMeta encodes m when it satisfies the protocol's authoritative | |||
| 905 | + | // ordered-tab constraints. | |||
| 906 | + | func MarshalSessionMeta(m SessionMeta) ([]byte, error) { | |||
| 907 | + | if len(m.Tabs) == 0 || len(m.Tabs) > math.MaxUint16 || int(m.Active) >= len(m.Tabs) { | |||
| 908 | + | return nil, ErrInvalidSessionMeta | |||
| 909 | + | } | |||
| 910 | + | if len(m.SessionName) > math.MaxUint16 { | |||
| 911 | + | return nil, ErrSessionMetaStringTooLong | |||
| 912 | + | } | |||
| 913 | + | for i, tab := range m.Tabs { | |||
| 914 | + | if tab.Index != uint16(i) { | |||
| 915 | + | return nil, ErrInvalidSessionMeta | |||
| 916 | + | } | |||
| 917 | + | if len(tab.Name) > math.MaxUint16 { | |||
| 918 | + | return nil, ErrSessionMetaStringTooLong | |||
| 919 | + | } | |||
| 920 | + | } | |||
| 921 | + | ||||
| 922 | + | w := payloadWriter{} | |||
| 923 | + | w.putString(m.SessionName) | |||
| 924 | + | w.putUint16(m.Active) | |||
| 925 | + | w.putUint16(uint16(len(m.Tabs))) | |||
| 926 | + | for _, tab := range m.Tabs { | |||
| 927 | + | w.putUint16(tab.Index) | |||
| 928 | + | w.putString(tab.Name) | |||
| 929 | + | if tab.Attention { | |||
| 930 | + | w.putUint8(1) | |||
| 931 | + | } else { | |||
| 932 | + | w.putUint8(0) | |||
| 933 | + | } | |||
| 934 | + | } | |||
| 935 | + | return w.b, nil | |||
| 936 | + | } | |||
| 937 | + | ||||
| 938 | + | // UnmarshalSessionMeta decodes a strict authoritative metadata snapshot. | |||
| 939 | + | func UnmarshalSessionMeta(b []byte) (SessionMeta, error) { | |||
| 940 | + | r := payloadReader{b: b} | |||
| 941 | + | var m SessionMeta | |||
| 942 | + | var err error | |||
| 943 | + | if m.SessionName, err = r.getString(); err != nil { | |||
| 944 | + | return SessionMeta{}, err | |||
| 945 | + | } | |||
| 946 | + | if m.Active, err = r.getUint16(); err != nil { | |||
| 947 | + | return SessionMeta{}, err | |||
| 948 | + | } | |||
| 949 | + | count, err := r.getUint16() | |||
| 950 | + | if err != nil { | |||
| 951 | + | return SessionMeta{}, err | |||
| 952 | + | } | |||
| 953 | + | if count == 0 || int(m.Active) >= int(count) { | |||
| 954 | + | return SessionMeta{}, ErrInvalidSessionMeta | |||
| 955 | + | } | |||
| 956 | + | if uint64(count) > uint64(len(r.b)/5) { | |||
| 957 | + | return SessionMeta{}, errShortPayload | |||
| 958 | + | } | |||
| 959 | + | m.Tabs = make([]SessionTabMeta, 0, int(count)) | |||
| 960 | + | for i := range int(count) { | |||
| 961 | + | var tab SessionTabMeta | |||
| 962 | + | if tab.Index, err = r.getUint16(); err != nil { | |||
| 963 | + | return SessionMeta{}, err | |||
| 964 | + | } | |||
| 965 | + | if tab.Index != uint16(i) { | |||
| 966 | + | return SessionMeta{}, ErrInvalidSessionMeta | |||
| 967 | + | } | |||
| 968 | + | if tab.Name, err = r.getString(); err != nil { | |||
| 969 | + | return SessionMeta{}, err | |||
| 970 | + | } | |||
| 971 | + | if tab.Attention, err = r.getBool(); err != nil { | |||
| 972 | + | return SessionMeta{}, err | |||
| 973 | + | } | |||
| 974 | + | m.Tabs = append(m.Tabs, tab) | |||
| 975 | + | } | |||
| 976 | + | if err := r.done(); err != nil { | |||
| 977 | + | return SessionMeta{}, err | |||
| 978 | + | } | |||
| 979 | + | return m, nil | |||
| 980 | + | } | |||
| 981 | + | ||||
| 838 | // MarshalKill encodes m into a Kill message payload. | 982 | // MarshalKill encodes m into a Kill message payload. | ||
| 839 | func MarshalKill(m Kill) []byte { | 983 | func MarshalKill(m Kill) []byte { | ||
| 840 | w := payloadWriter{} | 984 | w := payloadWriter{} | ||
M internal/ports/wire_command.go +26 -13
| @@ -13,6 +13,8 @@ var ErrTooManyCommandArgs = errors.New("ports: too many command arguments") | |||
| 13 | 13 | // stay first so a future payload layout can still be rejected cleanly. | |
| 14 | 14 | type CommandRequest struct { | |
| 15 | 15 | Version uint16 | |
| 16 | + | RequestID uint64 | |
| 17 | + | Attached bool | |
| 16 | 18 | Self bool | |
| 17 | 19 | Slug string | |
| 18 | 20 | Args []string | |
| @@ -24,10 +26,11 @@ type CommandRequest struct { | |||
| 24 | 26 | ||
| 25 | 27 | // CommandResult reports a control command's outcome. | |
| 26 | 28 | type CommandResult struct { | |
| 27 | - | OK bool | |
| 28 | - | Code uint16 | |
| 29 | - | Text string | |
| 30 | - | Output string | |
| 29 | + | RequestID uint64 | |
| 30 | + | OK bool | |
| 31 | + | Code uint16 | |
| 32 | + | Text string | |
| 33 | + | Output string | |
| 31 | 34 | } | |
| 32 | 35 | ||
| 33 | 36 | // PeekCommandVersion returns the leading protocol version from a | |
| @@ -47,6 +50,12 @@ func MarshalCommandRequest(m CommandRequest) ([]byte, error) { | |||
| 47 | 50 | ||
| 48 | 51 | w := payloadWriter{} | |
| 49 | 52 | w.putUint16(m.Version) | |
| 53 | + | w.putUint64(m.RequestID) | |
| 54 | + | if m.Attached { | |
| 55 | + | w.putUint8(1) | |
| 56 | + | } else { | |
| 57 | + | w.putUint8(0) | |
| 58 | + | } | |
| 50 | 59 | if m.Self { | |
| 51 | 60 | w.putUint8(1) | |
| 52 | 61 | } else { | |
| @@ -77,11 +86,15 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) { | |||
| 77 | 86 | if m.Version, err = r.getUint16(); err != nil { | |
| 78 | 87 | return CommandRequest{}, err | |
| 79 | 88 | } | |
| 80 | - | selfFlag, err := r.getUint8() | |
| 81 | - | if err != nil { | |
| 89 | + | if m.RequestID, err = r.getUint64(); err != nil { | |
| 90 | + | return CommandRequest{}, err | |
| 91 | + | } | |
| 92 | + | if m.Attached, err = r.getBool(); err != nil { | |
| 93 | + | return CommandRequest{}, err | |
| 94 | + | } | |
| 95 | + | if m.Self, err = r.getBool(); err != nil { | |
| 82 | 96 | return CommandRequest{}, err | |
| 83 | 97 | } | |
| 84 | - | m.Self = selfFlag != 0 | |
| 85 | 98 | if m.Slug, err = r.getString(); err != nil { | |
| 86 | 99 | return CommandRequest{}, err | |
| 87 | 100 | } | |
| @@ -113,11 +126,9 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) { | |||
| 113 | 126 | if m.TargetPane, err = r.getString(); err != nil { | |
| 114 | 127 | return CommandRequest{}, err | |
| 115 | 128 | } | |
| 116 | - | jsonFlag, err := r.getUint8() | |
| 117 | - | if err != nil { | |
| 129 | + | if m.JSON, err = r.getBool(); err != nil { | |
| 118 | 130 | return CommandRequest{}, err | |
| 119 | 131 | } | |
| 120 | - | m.JSON = jsonFlag != 0 | |
| 121 | 132 | if err := r.done(); err != nil { | |
| 122 | 133 | return CommandRequest{}, err | |
| 123 | 134 | } | |
| @@ -127,6 +138,7 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) { | |||
| 127 | 138 | // MarshalCommandResult encodes m into a CommandResult payload. | |
| 128 | 139 | func MarshalCommandResult(m CommandResult) []byte { | |
| 129 | 140 | w := payloadWriter{} | |
| 141 | + | w.putUint64(m.RequestID) | |
| 130 | 142 | if m.OK { | |
| 131 | 143 | w.putUint8(1) | |
| 132 | 144 | } else { | |
| @@ -144,11 +156,12 @@ func UnmarshalCommandResult(b []byte) (CommandResult, error) { | |||
| 144 | 156 | var m CommandResult | |
| 145 | 157 | var err error | |
| 146 | 158 | ||
| 147 | - | okFlag, err := r.getUint8() | |
| 148 | - | if err != nil { | |
| 159 | + | if m.RequestID, err = r.getUint64(); err != nil { | |
| 160 | + | return CommandResult{}, err | |
| 161 | + | } | |
| 162 | + | if m.OK, err = r.getBool(); err != nil { | |
| 149 | 163 | return CommandResult{}, err | |
| 150 | 164 | } | |
| 151 | - | m.OK = okFlag != 0 | |
| 152 | 165 | if m.Code, err = r.getUint16(); err != nil { | |
| 153 | 166 | return CommandResult{}, err | |
| 154 | 167 | } | |
| @@ -13,6 +13,8 @@ var ErrTooManyCommandArgs = errors.New("ports: too many command arguments") | |||||
| 13 | // stay first so a future payload layout can still be rejected cleanly. | 13 | // stay first so a future payload layout can still be rejected cleanly. | ||
| 14 | type CommandRequest struct { | 14 | type CommandRequest struct { | ||
| 15 | Version uint16 | 15 | Version uint16 | ||
| 16 | + | RequestID uint64 | |||
| 17 | + | Attached bool | |||
| 16 | Self bool | 18 | Self bool | ||
| 17 | Slug string | 19 | Slug string | ||
| 18 | Args []string | 20 | Args []string | ||
| @@ -24,10 +26,11 @@ type CommandRequest struct { | |||||
| 24 | 26 | ||||
| 25 | // CommandResult reports a control command's outcome. | 27 | // CommandResult reports a control command's outcome. | ||
| 26 | type CommandResult struct { | 28 | type CommandResult struct { | ||
| 27 | - | OK bool | 29 | + | RequestID uint64 |
| 28 | - | Code uint16 | 30 | + | OK bool |
| 29 | - | Text string | 31 | + | Code uint16 |
| 30 | - | Output string | 32 | + | Text string |
| 33 | + | Output string | |||
| 31 | } | 34 | } | ||
| 32 | 35 | ||||
| 33 | // PeekCommandVersion returns the leading protocol version from a | 36 | // PeekCommandVersion returns the leading protocol version from a | ||
| @@ -47,6 +50,12 @@ func MarshalCommandRequest(m CommandRequest) ([]byte, error) { | |||||
| 47 | 50 | ||||
| 48 | w := payloadWriter{} | 51 | w := payloadWriter{} | ||
| 49 | w.putUint16(m.Version) | 52 | w.putUint16(m.Version) | ||
| 53 | + | w.putUint64(m.RequestID) | |||
| 54 | + | if m.Attached { | |||
| 55 | + | w.putUint8(1) | |||
| 56 | + | } else { | |||
| 57 | + | w.putUint8(0) | |||
| 58 | + | } | |||
| 50 | if m.Self { | 59 | if m.Self { | ||
| 51 | w.putUint8(1) | 60 | w.putUint8(1) | ||
| 52 | } else { | 61 | } else { | ||
| @@ -77,11 +86,15 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) { | |||||
| 77 | if m.Version, err = r.getUint16(); err != nil { | 86 | if m.Version, err = r.getUint16(); err != nil { | ||
| 78 | return CommandRequest{}, err | 87 | return CommandRequest{}, err | ||
| 79 | } | 88 | } | ||
| 80 | - | selfFlag, err := r.getUint8() | 89 | + | if m.RequestID, err = r.getUint64(); err != nil { |
| 81 | - | if err != nil { | 90 | + | return CommandRequest{}, err |
| 91 | + | } | |||
| 92 | + | if m.Attached, err = r.getBool(); err != nil { | |||
| 93 | + | return CommandRequest{}, err | |||
| 94 | + | } | |||
| 95 | + | if m.Self, err = r.getBool(); err != nil { | |||
| 82 | return CommandRequest{}, err | 96 | return CommandRequest{}, err | ||
| 83 | } | 97 | } | ||
| 84 | - | m.Self = selfFlag != 0 | |||
| 85 | if m.Slug, err = r.getString(); err != nil { | 98 | if m.Slug, err = r.getString(); err != nil { | ||
| 86 | return CommandRequest{}, err | 99 | return CommandRequest{}, err | ||
| 87 | } | 100 | } | ||
| @@ -113,11 +126,9 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) { | |||||
| 113 | if m.TargetPane, err = r.getString(); err != nil { | 126 | if m.TargetPane, err = r.getString(); err != nil { | ||
| 114 | return CommandRequest{}, err | 127 | return CommandRequest{}, err | ||
| 115 | } | 128 | } | ||
| 116 | - | jsonFlag, err := r.getUint8() | 129 | + | if m.JSON, err = r.getBool(); err != nil { |
| 117 | - | if err != nil { | |||
| 118 | return CommandRequest{}, err | 130 | return CommandRequest{}, err | ||
| 119 | } | 131 | } | ||
| 120 | - | m.JSON = jsonFlag != 0 | |||
| 121 | if err := r.done(); err != nil { | 132 | if err := r.done(); err != nil { | ||
| 122 | return CommandRequest{}, err | 133 | return CommandRequest{}, err | ||
| 123 | } | 134 | } | ||
| @@ -127,6 +138,7 @@ func UnmarshalCommandRequest(b []byte) (CommandRequest, error) { | |||||
| 127 | // MarshalCommandResult encodes m into a CommandResult payload. | 138 | // MarshalCommandResult encodes m into a CommandResult payload. | ||
| 128 | func MarshalCommandResult(m CommandResult) []byte { | 139 | func MarshalCommandResult(m CommandResult) []byte { | ||
| 129 | w := payloadWriter{} | 140 | w := payloadWriter{} | ||
| 141 | + | w.putUint64(m.RequestID) | |||
| 130 | if m.OK { | 142 | if m.OK { | ||
| 131 | w.putUint8(1) | 143 | w.putUint8(1) | ||
| 132 | } else { | 144 | } else { | ||
| @@ -144,11 +156,12 @@ func UnmarshalCommandResult(b []byte) (CommandResult, error) { | |||||
| 144 | var m CommandResult | 156 | var m CommandResult | ||
| 145 | var err error | 157 | var err error | ||
| 146 | 158 | ||||
| 147 | - | okFlag, err := r.getUint8() | 159 | + | if m.RequestID, err = r.getUint64(); err != nil { |
| 148 | - | if err != nil { | 160 | + | return CommandResult{}, err |
| 161 | + | } | |||
| 162 | + | if m.OK, err = r.getBool(); err != nil { | |||
| 149 | return CommandResult{}, err | 163 | return CommandResult{}, err | ||
| 150 | } | 164 | } | ||
| 151 | - | m.OK = okFlag != 0 | |||
| 152 | if m.Code, err = r.getUint16(); err != nil { | 165 | if m.Code, err = r.getUint16(); err != nil { | ||
| 153 | return CommandResult{}, err | 166 | return CommandResult{}, err | ||
| 154 | } | 167 | } | ||
M internal/ports/wire_test.go +208 -11
| @@ -73,7 +73,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||
| 73 | 73 | TrueColor: true, | |
| 74 | 74 | MaxOutputInFlight: 8, | |
| 75 | 75 | }, | |
| 76 | - | want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00}, | |
| 76 | + | want: []byte{0x00, 0x01, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00}, | |
| 77 | 77 | }, | |
| 78 | 78 | { | |
| 79 | 79 | name: "empty strings", | |
| @@ -86,7 +86,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||
| 86 | 86 | Cwd: "", | |
| 87 | 87 | TrueColor: false, | |
| 88 | 88 | }, | |
| 89 | - | want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |
| 89 | + | want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |
| 90 | 90 | }, | |
| 91 | 91 | } | |
| 92 | 92 | ||
| @@ -122,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 122 | 122 | Env: []string{"A=B", "XY=123"}, | |
| 123 | 123 | }) | |
| 124 | 124 | want := []byte{ | |
| 125 | - | 0x00, 0x13, 0x00, // version, intent | |
| 125 | + | 0x00, 0x14, 0x00, 0x00, // version, intent, proxied | |
| 126 | 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 127 | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 128 | 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -336,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 336 | 336 | } | |
| 337 | 337 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 338 | 338 | require.Equal(t, want, MarshalTheme(cleared)) | |
| 339 | - | require.Equal(t, uint16(19), ProtocolVersion) | |
| 339 | + | require.Equal(t, uint16(20), ProtocolVersion) | |
| 340 | 340 | } | |
| 341 | 341 | ||
| 342 | 342 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -461,8 +461,10 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 461 | 461 | name: "minimal", | |
| 462 | 462 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 463 | 463 | want: []byte{ | |
| 464 | - | 0x00, 0x13, | |
| 465 | - | 0x00, | |
| 464 | + | 0x00, 0x14, | |
| 465 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 466 | + | 0x00, // attached | |
| 467 | + | 0x00, // self | |
| 466 | 468 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 467 | 469 | 0x00, 0x00, | |
| 468 | 470 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -482,8 +484,10 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 482 | 484 | JSON: true, | |
| 483 | 485 | }, | |
| 484 | 486 | want: []byte{ | |
| 485 | - | 0x00, 0x13, | |
| 486 | - | 0x01, | |
| 487 | + | 0x00, 0x14, | |
| 488 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 489 | + | 0x00, // attached | |
| 490 | + | 0x01, // self | |
| 487 | 491 | 0x00, 0x05, 't', 'o', 'a', 's', 't', | |
| 488 | 492 | 0x00, 0x03, | |
| 489 | 493 | 0x00, 0x00, 0x00, 0x02, '-', 'l', | |
| @@ -532,7 +536,7 @@ func TestMarshalCommandRequestRejectsTooManyArguments(t *testing.T) { | |||
| 532 | 536 | func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) { | |
| 533 | 537 | payload, err := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"}) | |
| 534 | 538 | require.NoError(t, err) | |
| 535 | - | argCountOffset := 2 + 1 + 2 + len("toast") | |
| 539 | + | argCountOffset := 2 + 8 + 1 + 1 + 2 + len("toast") | |
| 536 | 540 | payload[argCountOffset] = 0xff | |
| 537 | 541 | payload[argCountOffset+1] = 0xff | |
| 538 | 542 | ||
| @@ -551,6 +555,7 @@ func TestCommandResultGoldenAndRoundTrip(t *testing.T) { | |||
| 551 | 555 | name: "error", | |
| 552 | 556 | msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"}, | |
| 553 | 557 | want: []byte{ | |
| 558 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 554 | 559 | 0x00, | |
| 555 | 560 | 0x00, 0x09, | |
| 556 | 561 | 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n', | |
| @@ -561,6 +566,7 @@ func TestCommandResultGoldenAndRoundTrip(t *testing.T) { | |||
| 561 | 566 | name: "success with output", | |
| 562 | 567 | msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"}, | |
| 563 | 568 | want: []byte{ | |
| 569 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 564 | 570 | 0x01, | |
| 565 | 571 | 0x00, 0x00, | |
| 566 | 572 | 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd', | |
| @@ -868,9 +874,9 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) { | |||
| 868 | 874 | for _, window := range []uint8{0, 1, 8} { | |
| 869 | 875 | t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) { | |
| 870 | 876 | hello := Hello{Version: 14, MaxOutputInFlight: window} | |
| 871 | - | // The empty Hello has 38 zero bytes before the negotiated output window, | |
| 877 | + | // The empty Hello has 39 zero bytes before the negotiated output window, | |
| 872 | 878 | // followed by a uint32 zero environment-entry count. | |
| 873 | - | want := append(make([]byte, 38), window, 0, 0, 0, 0) | |
| 879 | + | want := append(make([]byte, 39), window, 0, 0, 0, 0) | |
| 874 | 880 | want[1] = 14 | |
| 875 | 881 | got := MarshalHello(hello) | |
| 876 | 882 | requireBytesEqual(t, want, got) | |
| @@ -889,3 +895,194 @@ func requireBytesEqual(t *testing.T, want, got []byte) { | |||
| 889 | 895 | t.Fatalf("bytes = %#v, want %#v", got, want) | |
| 890 | 896 | } | |
| 891 | 897 | } | |
| 898 | + | func TestProxiedHelloGoldenAndStrict(t *testing.T) { | |
| 899 | + | msg := Hello{ | |
| 900 | + | Version: ProtocolVersion, | |
| 901 | + | Intent: IntentAttach, | |
| 902 | + | Proxied: true, | |
| 903 | + | ClientID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, | |
| 904 | + | ResumeToken: 0x0102030405060708, | |
| 905 | + | Name: "remote", | |
| 906 | + | Size: domain.Size{Cols: 80, Rows: 22}, | |
| 907 | + | TermEnv: "xterm", | |
| 908 | + | Cwd: "/tmp", | |
| 909 | + | TrueColor: true, | |
| 910 | + | MaxOutputInFlight: 8, | |
| 911 | + | } | |
| 912 | + | want := []byte{ | |
| 913 | + | 0x00, 0x14, 0x02, 0x01, | |
| 914 | + | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | |
| 915 | + | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, | |
| 916 | + | 0x00, 0x06, 'r', 'e', 'm', 'o', 't', 'e', | |
| 917 | + | 0x00, 0x50, 0x00, 0x16, | |
| 918 | + | 0x00, 0x05, 'x', 't', 'e', 'r', 'm', | |
| 919 | + | 0x00, 0x04, '/', 't', 'm', 'p', | |
| 920 | + | 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, | |
| 921 | + | } | |
| 922 | + | got := MarshalHello(msg) | |
| 923 | + | require.Equal(t, want, got) | |
| 924 | + | back, err := UnmarshalHello(got) | |
| 925 | + | require.NoError(t, err) | |
| 926 | + | require.Equal(t, msg, back) | |
| 927 | + | assertAllPrefixesFail(t, got, UnmarshalHello) | |
| 928 | + | assertTrailingGarbageFails(t, got, UnmarshalHello) | |
| 929 | + | ||
| 930 | + | malformed := append([]byte(nil), got...) | |
| 931 | + | malformed[3] = 2 | |
| 932 | + | _, err = UnmarshalHello(malformed) | |
| 933 | + | require.Error(t, err) | |
| 934 | + | } | |
| 935 | + | ||
| 936 | + | func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |
| 937 | + | tests := []struct { | |
| 938 | + | name string | |
| 939 | + | msg CommandRequest | |
| 940 | + | want []byte | |
| 941 | + | }{ | |
| 942 | + | { | |
| 943 | + | name: "attached", | |
| 944 | + | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 945 | + | want: []byte{ | |
| 946 | + | 0x00, 0x14, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 947 | + | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 948 | + | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 949 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 950 | + | }, | |
| 951 | + | }, | |
| 952 | + | { | |
| 953 | + | name: "control", | |
| 954 | + | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | |
| 955 | + | want: []byte{ | |
| 956 | + | 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 957 | + | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 958 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 959 | + | }, | |
| 960 | + | }, | |
| 961 | + | } | |
| 962 | + | for _, tt := range tests { | |
| 963 | + | t.Run(tt.name, func(t *testing.T) { | |
| 964 | + | got, err := MarshalCommandRequest(tt.msg) | |
| 965 | + | require.NoError(t, err) | |
| 966 | + | require.Equal(t, tt.want, got) | |
| 967 | + | back, err := UnmarshalCommandRequest(got) | |
| 968 | + | require.NoError(t, err) | |
| 969 | + | require.Equal(t, tt.msg, back) | |
| 970 | + | assertAllPrefixesFail(t, got, UnmarshalCommandRequest) | |
| 971 | + | assertTrailingGarbageFails(t, got, UnmarshalCommandRequest) | |
| 972 | + | }) | |
| 973 | + | } | |
| 974 | + | ||
| 975 | + | payload, err := MarshalCommandRequest(tests[0].msg) | |
| 976 | + | require.NoError(t, err) | |
| 977 | + | for _, offset := range []int{10, 11, len(payload) - 1} { | |
| 978 | + | malformed := append([]byte(nil), payload...) | |
| 979 | + | malformed[offset] = 2 | |
| 980 | + | _, err := UnmarshalCommandRequest(malformed) | |
| 981 | + | require.Error(t, err) | |
| 982 | + | } | |
| 983 | + | } | |
| 984 | + | ||
| 985 | + | func TestCommandResultCorrelationGoldenAndStrict(t *testing.T) { | |
| 986 | + | msg := CommandResult{RequestID: 0x0102030405060708, OK: true, Code: 7, Text: "ok", Output: "result"} | |
| 987 | + | want := []byte{ | |
| 988 | + | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, | |
| 989 | + | 0x01, 0x00, 0x07, 0x00, 0x02, 'o', 'k', 0x00, 0x00, 0x00, 0x06, 'r', 'e', 's', 'u', 'l', 't', | |
| 990 | + | } | |
| 991 | + | got := MarshalCommandResult(msg) | |
| 992 | + | require.Equal(t, want, got) | |
| 993 | + | back, err := UnmarshalCommandResult(got) | |
| 994 | + | require.NoError(t, err) | |
| 995 | + | require.Equal(t, msg, back) | |
| 996 | + | assertAllPrefixesFail(t, got, UnmarshalCommandResult) | |
| 997 | + | assertTrailingGarbageFails(t, got, UnmarshalCommandResult) | |
| 998 | + | ||
| 999 | + | malformed := append([]byte(nil), got...) | |
| 1000 | + | malformed[8] = 2 | |
| 1001 | + | _, err = UnmarshalCommandResult(malformed) | |
| 1002 | + | require.Error(t, err) | |
| 1003 | + | } | |
| 1004 | + | ||
| 1005 | + | func TestOutputResetRequestStrict(t *testing.T) { | |
| 1006 | + | got := MarshalOutputResetRequest(OutputResetRequest{}) | |
| 1007 | + | require.Empty(t, got) | |
| 1008 | + | back, err := UnmarshalOutputResetRequest(got) | |
| 1009 | + | require.NoError(t, err) | |
| 1010 | + | require.Equal(t, OutputResetRequest{}, back) | |
| 1011 | + | assertTrailingGarbageFails(t, got, UnmarshalOutputResetRequest) | |
| 1012 | + | } | |
| 1013 | + | ||
| 1014 | + | func TestSessionMetaGoldenAndStrict(t *testing.T) { | |
| 1015 | + | tests := []struct { | |
| 1016 | + | name string | |
| 1017 | + | msg SessionMeta | |
| 1018 | + | want []byte | |
| 1019 | + | }{ | |
| 1020 | + | { | |
| 1021 | + | name: "one unicode tab", | |
| 1022 | + | msg: SessionMeta{SessionName: "démo", Active: 0, Tabs: []SessionTabMeta{{Index: 0, Name: "é", Attention: true}}}, | |
| 1023 | + | want: []byte{0x00, 0x05, 'd', 0xc3, 0xa9, 'm', 'o', 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0xc3, 0xa9, 0x01}, | |
| 1024 | + | }, | |
| 1025 | + | { | |
| 1026 | + | name: "multiple tabs", | |
| 1027 | + | msg: SessionMeta{SessionName: "work", Active: 1, Tabs: []SessionTabMeta{{Index: 0, Name: "shell"}, {Index: 1, Name: "build", Attention: true}}}, | |
| 1028 | + | want: []byte{0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x05, 's', 'h', 'e', 'l', 'l', 0x00, 0x00, 0x01, 0x00, 0x05, 'b', 'u', 'i', 'l', 'd', 0x01}, | |
| 1029 | + | }, | |
| 1030 | + | } | |
| 1031 | + | for _, tt := range tests { | |
| 1032 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1033 | + | got, err := MarshalSessionMeta(tt.msg) | |
| 1034 | + | require.NoError(t, err) | |
| 1035 | + | require.Equal(t, tt.want, got) | |
| 1036 | + | back, err := UnmarshalSessionMeta(got) | |
| 1037 | + | require.NoError(t, err) | |
| 1038 | + | require.Equal(t, tt.msg, back) | |
| 1039 | + | assertAllPrefixesFail(t, got, UnmarshalSessionMeta) | |
| 1040 | + | assertTrailingGarbageFails(t, got, UnmarshalSessionMeta) | |
| 1041 | + | }) | |
| 1042 | + | } | |
| 1043 | + | ||
| 1044 | + | maxString := string(bytes.Repeat([]byte{'x'}, math.MaxUint16)) | |
| 1045 | + | payload, err := MarshalSessionMeta(SessionMeta{SessionName: maxString, Tabs: []SessionTabMeta{{Name: maxString}}}) | |
| 1046 | + | require.NoError(t, err) | |
| 1047 | + | back, err := UnmarshalSessionMeta(payload) | |
| 1048 | + | require.NoError(t, err) | |
| 1049 | + | require.Equal(t, maxString, back.SessionName) | |
| 1050 | + | require.Equal(t, maxString, back.Tabs[0].Name) | |
| 1051 | + | ||
| 1052 | + | invalid := []SessionMeta{ | |
| 1053 | + | {}, | |
| 1054 | + | {Tabs: []SessionTabMeta{{Name: "one"}}, Active: 1}, | |
| 1055 | + | {Tabs: []SessionTabMeta{{Index: 1, Name: "one"}}}, | |
| 1056 | + | {Tabs: []SessionTabMeta{{Name: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1))}}, Active: 0}, | |
| 1057 | + | {SessionName: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1)), Tabs: []SessionTabMeta{{Name: "one"}}}, | |
| 1058 | + | } | |
| 1059 | + | for _, msg := range invalid { | |
| 1060 | + | _, err := MarshalSessionMeta(msg) | |
| 1061 | + | require.Error(t, err) | |
| 1062 | + | } | |
| 1063 | + | ||
| 1064 | + | valid, err := MarshalSessionMeta(tests[1].msg) | |
| 1065 | + | require.NoError(t, err) | |
| 1066 | + | for _, tt := range []struct { | |
| 1067 | + | name string | |
| 1068 | + | mutate func([]byte) | |
| 1069 | + | wantError error | |
| 1070 | + | }{ | |
| 1071 | + | {name: "active outside tab count", mutate: func(b []byte) { b[6] = 0; b[7] = 2 }, wantError: ErrInvalidSessionMeta}, | |
| 1072 | + | // The first tab index is the uint16 at bytes 10–11, after the session | |
| 1073 | + | // name, active index, and tab count. | |
| 1074 | + | {name: "first tab index out of order", mutate: func(b []byte) { b[10] = 0; b[11] = 1 }, wantError: ErrInvalidSessionMeta}, | |
| 1075 | + | {name: "attention flag malformed", mutate: func(b []byte) { b[len(b)-1] = 2 }}, | |
| 1076 | + | } { | |
| 1077 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1078 | + | malformed := append([]byte(nil), valid...) | |
| 1079 | + | tt.mutate(malformed) | |
| 1080 | + | _, err := UnmarshalSessionMeta(malformed) | |
| 1081 | + | if tt.wantError != nil { | |
| 1082 | + | require.ErrorIs(t, err, tt.wantError) | |
| 1083 | + | return | |
| 1084 | + | } | |
| 1085 | + | require.Error(t, err) | |
| 1086 | + | }) | |
| 1087 | + | } | |
| 1088 | + | } | |
| @@ -73,7 +73,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||||
| 73 | TrueColor: true, | 73 | TrueColor: true, | ||
| 74 | MaxOutputInFlight: 8, | 74 | MaxOutputInFlight: 8, | ||
| 75 | }, | 75 | }, | ||
| 76 | - | want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00}, | 76 | + | want: []byte{0x00, 0x01, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00}, |
| 77 | }, | 77 | }, | ||
| 78 | { | 78 | { | ||
| 79 | name: "empty strings", | 79 | name: "empty strings", | ||
| @@ -86,7 +86,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||||
| 86 | Cwd: "", | 86 | Cwd: "", | ||
| 87 | TrueColor: false, | 87 | TrueColor: false, | ||
| 88 | }, | 88 | }, | ||
| 89 | - | want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | 89 | + | want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, |
| 90 | }, | 90 | }, | ||
| 91 | } | 91 | } | ||
| 92 | 92 | ||||
| @@ -122,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 122 | Env: []string{"A=B", "XY=123"}, | 122 | Env: []string{"A=B", "XY=123"}, | ||
| 123 | }) | 123 | }) | ||
| 124 | want := []byte{ | 124 | want := []byte{ | ||
| 125 | - | 0x00, 0x13, 0x00, // version, intent | 125 | + | 0x00, 0x14, 0x00, 0x00, // version, intent, proxied |
| 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -336,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 336 | } | 336 | } | ||
| 337 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 337 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 338 | require.Equal(t, want, MarshalTheme(cleared)) | 338 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 339 | - | require.Equal(t, uint16(19), ProtocolVersion) | 339 | + | require.Equal(t, uint16(20), ProtocolVersion) |
| 340 | } | 340 | } | ||
| 341 | 341 | ||||
| 342 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 342 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -461,8 +461,10 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 461 | name: "minimal", | 461 | name: "minimal", | ||
| 462 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | 462 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | ||
| 463 | want: []byte{ | 463 | want: []byte{ | ||
| 464 | - | 0x00, 0x13, | 464 | + | 0x00, 0x14, |
| 465 | - | 0x00, | 465 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID |
| 466 | + | 0x00, // attached | |||
| 467 | + | 0x00, // self | |||
| 466 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | 468 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | ||
| 467 | 0x00, 0x00, | 469 | 0x00, 0x00, | ||
| 468 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 470 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| @@ -482,8 +484,10 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 482 | JSON: true, | 484 | JSON: true, | ||
| 483 | }, | 485 | }, | ||
| 484 | want: []byte{ | 486 | want: []byte{ | ||
| 485 | - | 0x00, 0x13, | 487 | + | 0x00, 0x14, |
| 486 | - | 0x01, | 488 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID |
| 489 | + | 0x00, // attached | |||
| 490 | + | 0x01, // self | |||
| 487 | 0x00, 0x05, 't', 'o', 'a', 's', 't', | 491 | 0x00, 0x05, 't', 'o', 'a', 's', 't', | ||
| 488 | 0x00, 0x03, | 492 | 0x00, 0x03, | ||
| 489 | 0x00, 0x00, 0x00, 0x02, '-', 'l', | 493 | 0x00, 0x00, 0x00, 0x02, '-', 'l', | ||
| @@ -532,7 +536,7 @@ func TestMarshalCommandRequestRejectsTooManyArguments(t *testing.T) { | |||||
| 532 | func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) { | 536 | func TestCommandRequestRejectsImpossibleArgumentCount(t *testing.T) { | ||
| 533 | payload, err := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"}) | 537 | payload, err := MarshalCommandRequest(CommandRequest{Version: ProtocolVersion, Slug: "toast"}) | ||
| 534 | require.NoError(t, err) | 538 | require.NoError(t, err) | ||
| 535 | - | argCountOffset := 2 + 1 + 2 + len("toast") | 539 | + | argCountOffset := 2 + 8 + 1 + 1 + 2 + len("toast") |
| 536 | payload[argCountOffset] = 0xff | 540 | payload[argCountOffset] = 0xff | ||
| 537 | payload[argCountOffset+1] = 0xff | 541 | payload[argCountOffset+1] = 0xff | ||
| 538 | 542 | ||||
| @@ -551,6 +555,7 @@ func TestCommandResultGoldenAndRoundTrip(t *testing.T) { | |||||
| 551 | name: "error", | 555 | name: "error", | ||
| 552 | msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"}, | 556 | msg: CommandResult{Code: ErrNoSuchTarget, Text: "no such session"}, | ||
| 553 | want: []byte{ | 557 | want: []byte{ | ||
| 558 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |||
| 554 | 0x00, | 559 | 0x00, | ||
| 555 | 0x00, 0x09, | 560 | 0x00, 0x09, | ||
| 556 | 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n', | 561 | 0x00, 0x0f, 'n', 'o', ' ', 's', 'u', 'c', 'h', ' ', 's', 'e', 's', 's', 'i', 'o', 'n', | ||
| @@ -561,6 +566,7 @@ func TestCommandResultGoldenAndRoundTrip(t *testing.T) { | |||||
| 561 | name: "success with output", | 566 | name: "success with output", | ||
| 562 | msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"}, | 567 | msg: CommandResult{OK: true, Text: "listed", Output: "ID\tFOCUSED\n"}, | ||
| 563 | want: []byte{ | 568 | want: []byte{ | ||
| 569 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |||
| 564 | 0x01, | 570 | 0x01, | ||
| 565 | 0x00, 0x00, | 571 | 0x00, 0x00, | ||
| 566 | 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd', | 572 | 0x00, 0x06, 'l', 'i', 's', 't', 'e', 'd', | ||
| @@ -868,9 +874,9 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) { | |||||
| 868 | for _, window := range []uint8{0, 1, 8} { | 874 | for _, window := range []uint8{0, 1, 8} { | ||
| 869 | t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) { | 875 | t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) { | ||
| 870 | hello := Hello{Version: 14, MaxOutputInFlight: window} | 876 | hello := Hello{Version: 14, MaxOutputInFlight: window} | ||
| 871 | - | // The empty Hello has 38 zero bytes before the negotiated output window, | 877 | + | // The empty Hello has 39 zero bytes before the negotiated output window, |
| 872 | // followed by a uint32 zero environment-entry count. | 878 | // followed by a uint32 zero environment-entry count. | ||
| 873 | - | want := append(make([]byte, 38), window, 0, 0, 0, 0) | 879 | + | want := append(make([]byte, 39), window, 0, 0, 0, 0) |
| 874 | want[1] = 14 | 880 | want[1] = 14 | ||
| 875 | got := MarshalHello(hello) | 881 | got := MarshalHello(hello) | ||
| 876 | requireBytesEqual(t, want, got) | 882 | requireBytesEqual(t, want, got) | ||
| @@ -889,3 +895,194 @@ func requireBytesEqual(t *testing.T, want, got []byte) { | |||||
| 889 | t.Fatalf("bytes = %#v, want %#v", got, want) | 895 | t.Fatalf("bytes = %#v, want %#v", got, want) | ||
| 890 | } | 896 | } | ||
| 891 | } | 897 | } | ||
| 898 | + | func TestProxiedHelloGoldenAndStrict(t *testing.T) { | |||
| 899 | + | msg := Hello{ | |||
| 900 | + | Version: ProtocolVersion, | |||
| 901 | + | Intent: IntentAttach, | |||
| 902 | + | Proxied: true, | |||
| 903 | + | ClientID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, | |||
| 904 | + | ResumeToken: 0x0102030405060708, | |||
| 905 | + | Name: "remote", | |||
| 906 | + | Size: domain.Size{Cols: 80, Rows: 22}, | |||
| 907 | + | TermEnv: "xterm", | |||
| 908 | + | Cwd: "/tmp", | |||
| 909 | + | TrueColor: true, | |||
| 910 | + | MaxOutputInFlight: 8, | |||
| 911 | + | } | |||
| 912 | + | want := []byte{ | |||
| 913 | + | 0x00, 0x14, 0x02, 0x01, | |||
| 914 | + | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | |||
| 915 | + | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, | |||
| 916 | + | 0x00, 0x06, 'r', 'e', 'm', 'o', 't', 'e', | |||
| 917 | + | 0x00, 0x50, 0x00, 0x16, | |||
| 918 | + | 0x00, 0x05, 'x', 't', 'e', 'r', 'm', | |||
| 919 | + | 0x00, 0x04, '/', 't', 'm', 'p', | |||
| 920 | + | 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, | |||
| 921 | + | } | |||
| 922 | + | got := MarshalHello(msg) | |||
| 923 | + | require.Equal(t, want, got) | |||
| 924 | + | back, err := UnmarshalHello(got) | |||
| 925 | + | require.NoError(t, err) | |||
| 926 | + | require.Equal(t, msg, back) | |||
| 927 | + | assertAllPrefixesFail(t, got, UnmarshalHello) | |||
| 928 | + | assertTrailingGarbageFails(t, got, UnmarshalHello) | |||
| 929 | + | ||||
| 930 | + | malformed := append([]byte(nil), got...) | |||
| 931 | + | malformed[3] = 2 | |||
| 932 | + | _, err = UnmarshalHello(malformed) | |||
| 933 | + | require.Error(t, err) | |||
| 934 | + | } | |||
| 935 | + | ||||
| 936 | + | func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 937 | + | tests := []struct { | |||
| 938 | + | name string | |||
| 939 | + | msg CommandRequest | |||
| 940 | + | want []byte | |||
| 941 | + | }{ | |||
| 942 | + | { | |||
| 943 | + | name: "attached", | |||
| 944 | + | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |||
| 945 | + | want: []byte{ | |||
| 946 | + | 0x00, 0x14, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |||
| 947 | + | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |||
| 948 | + | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |||
| 949 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |||
| 950 | + | }, | |||
| 951 | + | }, | |||
| 952 | + | { | |||
| 953 | + | name: "control", | |||
| 954 | + | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | |||
| 955 | + | want: []byte{ | |||
| 956 | + | 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |||
| 957 | + | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |||
| 958 | + | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |||
| 959 | + | }, | |||
| 960 | + | }, | |||
| 961 | + | } | |||
| 962 | + | for _, tt := range tests { | |||
| 963 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 964 | + | got, err := MarshalCommandRequest(tt.msg) | |||
| 965 | + | require.NoError(t, err) | |||
| 966 | + | require.Equal(t, tt.want, got) | |||
| 967 | + | back, err := UnmarshalCommandRequest(got) | |||
| 968 | + | require.NoError(t, err) | |||
| 969 | + | require.Equal(t, tt.msg, back) | |||
| 970 | + | assertAllPrefixesFail(t, got, UnmarshalCommandRequest) | |||
| 971 | + | assertTrailingGarbageFails(t, got, UnmarshalCommandRequest) | |||
| 972 | + | }) | |||
| 973 | + | } | |||
| 974 | + | ||||
| 975 | + | payload, err := MarshalCommandRequest(tests[0].msg) | |||
| 976 | + | require.NoError(t, err) | |||
| 977 | + | for _, offset := range []int{10, 11, len(payload) - 1} { | |||
| 978 | + | malformed := append([]byte(nil), payload...) | |||
| 979 | + | malformed[offset] = 2 | |||
| 980 | + | _, err := UnmarshalCommandRequest(malformed) | |||
| 981 | + | require.Error(t, err) | |||
| 982 | + | } | |||
| 983 | + | } | |||
| 984 | + | ||||
| 985 | + | func TestCommandResultCorrelationGoldenAndStrict(t *testing.T) { | |||
| 986 | + | msg := CommandResult{RequestID: 0x0102030405060708, OK: true, Code: 7, Text: "ok", Output: "result"} | |||
| 987 | + | want := []byte{ | |||
| 988 | + | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, | |||
| 989 | + | 0x01, 0x00, 0x07, 0x00, 0x02, 'o', 'k', 0x00, 0x00, 0x00, 0x06, 'r', 'e', 's', 'u', 'l', 't', | |||
| 990 | + | } | |||
| 991 | + | got := MarshalCommandResult(msg) | |||
| 992 | + | require.Equal(t, want, got) | |||
| 993 | + | back, err := UnmarshalCommandResult(got) | |||
| 994 | + | require.NoError(t, err) | |||
| 995 | + | require.Equal(t, msg, back) | |||
| 996 | + | assertAllPrefixesFail(t, got, UnmarshalCommandResult) | |||
| 997 | + | assertTrailingGarbageFails(t, got, UnmarshalCommandResult) | |||
| 998 | + | ||||
| 999 | + | malformed := append([]byte(nil), got...) | |||
| 1000 | + | malformed[8] = 2 | |||
| 1001 | + | _, err = UnmarshalCommandResult(malformed) | |||
| 1002 | + | require.Error(t, err) | |||
| 1003 | + | } | |||
| 1004 | + | ||||
| 1005 | + | func TestOutputResetRequestStrict(t *testing.T) { | |||
| 1006 | + | got := MarshalOutputResetRequest(OutputResetRequest{}) | |||
| 1007 | + | require.Empty(t, got) | |||
| 1008 | + | back, err := UnmarshalOutputResetRequest(got) | |||
| 1009 | + | require.NoError(t, err) | |||
| 1010 | + | require.Equal(t, OutputResetRequest{}, back) | |||
| 1011 | + | assertTrailingGarbageFails(t, got, UnmarshalOutputResetRequest) | |||
| 1012 | + | } | |||
| 1013 | + | ||||
| 1014 | + | func TestSessionMetaGoldenAndStrict(t *testing.T) { | |||
| 1015 | + | tests := []struct { | |||
| 1016 | + | name string | |||
| 1017 | + | msg SessionMeta | |||
| 1018 | + | want []byte | |||
| 1019 | + | }{ | |||
| 1020 | + | { | |||
| 1021 | + | name: "one unicode tab", | |||
| 1022 | + | msg: SessionMeta{SessionName: "démo", Active: 0, Tabs: []SessionTabMeta{{Index: 0, Name: "é", Attention: true}}}, | |||
| 1023 | + | want: []byte{0x00, 0x05, 'd', 0xc3, 0xa9, 'm', 'o', 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0xc3, 0xa9, 0x01}, | |||
| 1024 | + | }, | |||
| 1025 | + | { | |||
| 1026 | + | name: "multiple tabs", | |||
| 1027 | + | msg: SessionMeta{SessionName: "work", Active: 1, Tabs: []SessionTabMeta{{Index: 0, Name: "shell"}, {Index: 1, Name: "build", Attention: true}}}, | |||
| 1028 | + | want: []byte{0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x05, 's', 'h', 'e', 'l', 'l', 0x00, 0x00, 0x01, 0x00, 0x05, 'b', 'u', 'i', 'l', 'd', 0x01}, | |||
| 1029 | + | }, | |||
| 1030 | + | } | |||
| 1031 | + | for _, tt := range tests { | |||
| 1032 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1033 | + | got, err := MarshalSessionMeta(tt.msg) | |||
| 1034 | + | require.NoError(t, err) | |||
| 1035 | + | require.Equal(t, tt.want, got) | |||
| 1036 | + | back, err := UnmarshalSessionMeta(got) | |||
| 1037 | + | require.NoError(t, err) | |||
| 1038 | + | require.Equal(t, tt.msg, back) | |||
| 1039 | + | assertAllPrefixesFail(t, got, UnmarshalSessionMeta) | |||
| 1040 | + | assertTrailingGarbageFails(t, got, UnmarshalSessionMeta) | |||
| 1041 | + | }) | |||
| 1042 | + | } | |||
| 1043 | + | ||||
| 1044 | + | maxString := string(bytes.Repeat([]byte{'x'}, math.MaxUint16)) | |||
| 1045 | + | payload, err := MarshalSessionMeta(SessionMeta{SessionName: maxString, Tabs: []SessionTabMeta{{Name: maxString}}}) | |||
| 1046 | + | require.NoError(t, err) | |||
| 1047 | + | back, err := UnmarshalSessionMeta(payload) | |||
| 1048 | + | require.NoError(t, err) | |||
| 1049 | + | require.Equal(t, maxString, back.SessionName) | |||
| 1050 | + | require.Equal(t, maxString, back.Tabs[0].Name) | |||
| 1051 | + | ||||
| 1052 | + | invalid := []SessionMeta{ | |||
| 1053 | + | {}, | |||
| 1054 | + | {Tabs: []SessionTabMeta{{Name: "one"}}, Active: 1}, | |||
| 1055 | + | {Tabs: []SessionTabMeta{{Index: 1, Name: "one"}}}, | |||
| 1056 | + | {Tabs: []SessionTabMeta{{Name: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1))}}, Active: 0}, | |||
| 1057 | + | {SessionName: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1)), Tabs: []SessionTabMeta{{Name: "one"}}}, | |||
| 1058 | + | } | |||
| 1059 | + | for _, msg := range invalid { | |||
| 1060 | + | _, err := MarshalSessionMeta(msg) | |||
| 1061 | + | require.Error(t, err) | |||
| 1062 | + | } | |||
| 1063 | + | ||||
| 1064 | + | valid, err := MarshalSessionMeta(tests[1].msg) | |||
| 1065 | + | require.NoError(t, err) | |||
| 1066 | + | for _, tt := range []struct { | |||
| 1067 | + | name string | |||
| 1068 | + | mutate func([]byte) | |||
| 1069 | + | wantError error | |||
| 1070 | + | }{ | |||
| 1071 | + | {name: "active outside tab count", mutate: func(b []byte) { b[6] = 0; b[7] = 2 }, wantError: ErrInvalidSessionMeta}, | |||
| 1072 | + | // The first tab index is the uint16 at bytes 10–11, after the session | |||
| 1073 | + | // name, active index, and tab count. | |||
| 1074 | + | {name: "first tab index out of order", mutate: func(b []byte) { b[10] = 0; b[11] = 1 }, wantError: ErrInvalidSessionMeta}, | |||
| 1075 | + | {name: "attention flag malformed", mutate: func(b []byte) { b[len(b)-1] = 2 }}, | |||
| 1076 | + | } { | |||
| 1077 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1078 | + | malformed := append([]byte(nil), valid...) | |||
| 1079 | + | tt.mutate(malformed) | |||
| 1080 | + | _, err := UnmarshalSessionMeta(malformed) | |||
| 1081 | + | if tt.wantError != nil { | |||
| 1082 | + | require.ErrorIs(t, err, tt.wantError) | |||
| 1083 | + | return | |||
| 1084 | + | } | |||
| 1085 | + | require.Error(t, err) | |||
| 1086 | + | }) | |||
| 1087 | + | } | |||
| 1088 | + | } | |||
M internal/usecase/daemon/client.go +11 -2
| @@ -48,7 +48,10 @@ type attachedClient struct { | |||
| 48 | 48 | resumeCapable bool | |
| 49 | 49 | resumeToken uint64 | |
| 50 | 50 | parked bool | |
| 51 | - | echoAck atomic.Uint64 | |
| 51 | + | // proxied is negotiated once by Hello and remains immutable for this | |
| 52 | + | // attachment, including across transport resume. | |
| 53 | + | proxied bool | |
| 54 | + | echoAck atomic.Uint64 | |
| 52 | 55 | // prepareFailureFallback prevents a direct fallback paint from recursively | |
| 53 | 56 | // reporting the same failed prepare through its notice repaint. It is only | |
| 54 | 57 | // needed while no render coordinator is installed. | |
| @@ -59,6 +62,10 @@ type attachedClient struct { | |||
| 59 | 62 | pipelineCache composeCacheInput | |
| 60 | 63 | pipelineScratch composeCacheInput | |
| 61 | 64 | renderScratch renderCaptureScratch // only touched while sendMu is held | |
| 65 | + | // sessionMeta is the last authoritative metadata snapshot successfully sent | |
| 66 | + | // on a proxied attachment. It is only read or written while sendMu is held. | |
| 67 | + | sessionMeta ports.SessionMeta | |
| 68 | + | sessionMetaSent bool | |
| 62 | 69 | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so | |
| 63 | 70 | // snapshots cannot leak when an attachment switches tabs or sessions. | |
| 64 | 71 | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held | |
| @@ -454,6 +461,7 @@ type attachClientOptions struct { | |||
| 454 | 461 | clientID [16]byte | |
| 455 | 462 | resumeCapable bool | |
| 456 | 463 | maxOutputInFlight uint8 | |
| 464 | + | proxied bool | |
| 457 | 465 | } | |
| 458 | 466 | ||
| 459 | 467 | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, error) { | |
| @@ -500,6 +508,7 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, | |||
| 500 | 508 | clientID: opts.clientID, | |
| 501 | 509 | resumeCapable: opts.resumeCapable, | |
| 502 | 510 | resumeToken: resumeToken, | |
| 511 | + | proxied: opts.proxied, | |
| 503 | 512 | } | |
| 504 | 513 | ac.initOverlays() | |
| 505 | 514 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | |
| @@ -648,7 +657,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi | |||
| 648 | 657 | tb.mu.Unlock() | |
| 649 | 658 | ||
| 650 | 659 | outerResizeAccepted := false | |
| 651 | - | if clientSize.Valid() && wsz != tabSize(clientSize) { | |
| 660 | + | if clientSize.Valid() && wsz != contentSize(clientSize, ac.proxied) { | |
| 652 | 661 | if lease == nil { | |
| 653 | 662 | outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize) | |
| 654 | 663 | } else { | |
| @@ -48,7 +48,10 @@ type attachedClient struct { | |||||
| 48 | resumeCapable bool | 48 | resumeCapable bool | ||
| 49 | resumeToken uint64 | 49 | resumeToken uint64 | ||
| 50 | parked bool | 50 | parked bool | ||
| 51 | - | echoAck atomic.Uint64 | 51 | + | // proxied is negotiated once by Hello and remains immutable for this |
| 52 | + | // attachment, including across transport resume. | |||
| 53 | + | proxied bool | |||
| 54 | + | echoAck atomic.Uint64 | |||
| 52 | // prepareFailureFallback prevents a direct fallback paint from recursively | 55 | // prepareFailureFallback prevents a direct fallback paint from recursively | ||
| 53 | // reporting the same failed prepare through its notice repaint. It is only | 56 | // reporting the same failed prepare through its notice repaint. It is only | ||
| 54 | // needed while no render coordinator is installed. | 57 | // needed while no render coordinator is installed. | ||
| @@ -59,6 +62,10 @@ type attachedClient struct { | |||||
| 59 | pipelineCache composeCacheInput | 62 | pipelineCache composeCacheInput | ||
| 60 | pipelineScratch composeCacheInput | 63 | pipelineScratch composeCacheInput | ||
| 61 | renderScratch renderCaptureScratch // only touched while sendMu is held | 64 | renderScratch renderCaptureScratch // only touched while sendMu is held | ||
| 65 | + | // sessionMeta is the last authoritative metadata snapshot successfully sent | |||
| 66 | + | // on a proxied attachment. It is only read or written while sendMu is held. | |||
| 67 | + | sessionMeta ports.SessionMeta | |||
| 68 | + | sessionMetaSent bool | |||
| 62 | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so | 69 | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so | ||
| 63 | // snapshots cannot leak when an attachment switches tabs or sessions. | 70 | // snapshots cannot leak when an attachment switches tabs or sessions. | ||
| 64 | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held | 71 | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held | ||
| @@ -454,6 +461,7 @@ type attachClientOptions struct { | |||||
| 454 | clientID [16]byte | 461 | clientID [16]byte | ||
| 455 | resumeCapable bool | 462 | resumeCapable bool | ||
| 456 | maxOutputInFlight uint8 | 463 | maxOutputInFlight uint8 | ||
| 464 | + | proxied bool | |||
| 457 | } | 465 | } | ||
| 458 | 466 | ||||
| 459 | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, error) { | 467 | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, error) { | ||
| @@ -500,6 +508,7 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, | |||||
| 500 | clientID: opts.clientID, | 508 | clientID: opts.clientID, | ||
| 501 | resumeCapable: opts.resumeCapable, | 509 | resumeCapable: opts.resumeCapable, | ||
| 502 | resumeToken: resumeToken, | 510 | resumeToken: resumeToken, | ||
| 511 | + | proxied: opts.proxied, | |||
| 503 | } | 512 | } | ||
| 504 | ac.initOverlays() | 513 | ac.initOverlays() | ||
| 505 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | 514 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | ||
| @@ -648,7 +657,7 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi | |||||
| 648 | tb.mu.Unlock() | 657 | tb.mu.Unlock() | ||
| 649 | 658 | ||||
| 650 | outerResizeAccepted := false | 659 | outerResizeAccepted := false | ||
| 651 | - | if clientSize.Valid() && wsz != tabSize(clientSize) { | 660 | + | if clientSize.Valid() && wsz != contentSize(clientSize, ac.proxied) { |
| 652 | if lease == nil { | 661 | if lease == nil { | ||
| 653 | outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize) | 662 | outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize) | ||
| 654 | } else { | 663 | } else { | ||
M internal/usecase/daemon/client_frame_routing.go +51 -0
| @@ -104,6 +104,35 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram | |||
| 104 | 104 | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | |
| 105 | 105 | d.ackActiveOutput(token, ack.AckedStateNum) | |
| 106 | 106 | } | |
| 107 | + | case ports.MsgOutputResetRequest: | |
| 108 | + | if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil { | |
| 109 | + | d.resetActiveOutput(token) | |
| 110 | + | } | |
| 111 | + | case ports.MsgCommand: | |
| 112 | + | if token.ac.proxied { | |
| 113 | + | request, derr := ports.UnmarshalCommandRequest(f.Payload) | |
| 114 | + | if derr != nil { | |
| 115 | + | return false | |
| 116 | + | } | |
| 117 | + | // Attached commands are intentionally admitted only on the proxied | |
| 118 | + | // attachment path. Phase 6 supplies execution; until then every | |
| 119 | + | // decoded request is rejected with its original correlation ID. | |
| 120 | + | valid := request.Version == ports.ProtocolVersion && request.Attached && request.RequestID != 0 && | |
| 121 | + | request.TargetSession == "" && request.TargetTab == "" && request.TargetPane == "" | |
| 122 | + | if !valid { | |
| 123 | + | d.log.Warn("invalid attached command", "request_id", request.RequestID) | |
| 124 | + | } | |
| 125 | + | result := ports.CommandResult{ | |
| 126 | + | RequestID: request.RequestID, | |
| 127 | + | Code: ports.ErrNotScriptable, | |
| 128 | + | Text: "attached command relay is not enabled", | |
| 129 | + | } | |
| 130 | + | if err := token.sendActiveControl(frameCommandResult(result)); err != nil { | |
| 131 | + | token.endRoleEffect() | |
| 132 | + | d.detachOnRoleSendError(token, token.transport.transport) | |
| 133 | + | return true | |
| 134 | + | } | |
| 135 | + | } | |
| 107 | 136 | case ports.MsgPing: | |
| 108 | 137 | if err := token.sendActiveControl(framePong()); err != nil { | |
| 109 | 138 | token.endRoleEffect() | |
| @@ -117,6 +146,28 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram | |||
| 117 | 146 | return false | |
| 118 | 147 | } | |
| 119 | 148 | ||
| 149 | + | // resetActiveOutput rebases only the exact proxied role and transport admitted | |
| 150 | + | // by the frame router. sendMu serializes the rebase with every output send; | |
| 151 | + | // transport revalidation under that lock rejects a link replaced while waiting. | |
| 152 | + | func (d *Daemon) resetActiveOutput(token attachmentRoleToken) bool { | |
| 153 | + | ac := token.ac | |
| 154 | + | if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() { | |
| 155 | + | return false | |
| 156 | + | } | |
| 157 | + | ac.sendMu.Lock() | |
| 158 | + | if token.effect.ended.Load() || !ac.proxied || !token.activeCurrent() || ac.output == nil { | |
| 159 | + | ac.sendMu.Unlock() | |
| 160 | + | return false | |
| 161 | + | } | |
| 162 | + | ac.output.rebase() | |
| 163 | + | ac.sendMu.Unlock() | |
| 164 | + | ||
| 165 | + | rc := token.sess.core().coordinator.Load() | |
| 166 | + | return rc != nil && rc.invalidateForLease(ac, token.lease, renderInvalidation{ | |
| 167 | + | class: invalidateUrgent, reset: true, producer: "output-reset-request", | |
| 168 | + | }) | |
| 169 | + | } | |
| 170 | + | ||
| 120 | 171 | func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool { | |
| 121 | 172 | ac := token.ac | |
| 122 | 173 | if token.effect == nil || token.effect.ended.Load() { | |
| @@ -104,6 +104,35 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram | |||||
| 104 | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | 104 | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | ||
| 105 | d.ackActiveOutput(token, ack.AckedStateNum) | 105 | d.ackActiveOutput(token, ack.AckedStateNum) | ||
| 106 | } | 106 | } | ||
| 107 | + | case ports.MsgOutputResetRequest: | |||
| 108 | + | if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil { | |||
| 109 | + | d.resetActiveOutput(token) | |||
| 110 | + | } | |||
| 111 | + | case ports.MsgCommand: | |||
| 112 | + | if token.ac.proxied { | |||
| 113 | + | request, derr := ports.UnmarshalCommandRequest(f.Payload) | |||
| 114 | + | if derr != nil { | |||
| 115 | + | return false | |||
| 116 | + | } | |||
| 117 | + | // Attached commands are intentionally admitted only on the proxied | |||
| 118 | + | // attachment path. Phase 6 supplies execution; until then every | |||
| 119 | + | // decoded request is rejected with its original correlation ID. | |||
| 120 | + | valid := request.Version == ports.ProtocolVersion && request.Attached && request.RequestID != 0 && | |||
| 121 | + | request.TargetSession == "" && request.TargetTab == "" && request.TargetPane == "" | |||
| 122 | + | if !valid { | |||
| 123 | + | d.log.Warn("invalid attached command", "request_id", request.RequestID) | |||
| 124 | + | } | |||
| 125 | + | result := ports.CommandResult{ | |||
| 126 | + | RequestID: request.RequestID, | |||
| 127 | + | Code: ports.ErrNotScriptable, | |||
| 128 | + | Text: "attached command relay is not enabled", | |||
| 129 | + | } | |||
| 130 | + | if err := token.sendActiveControl(frameCommandResult(result)); err != nil { | |||
| 131 | + | token.endRoleEffect() | |||
| 132 | + | d.detachOnRoleSendError(token, token.transport.transport) | |||
| 133 | + | return true | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 107 | case ports.MsgPing: | 136 | case ports.MsgPing: | ||
| 108 | if err := token.sendActiveControl(framePong()); err != nil { | 137 | if err := token.sendActiveControl(framePong()); err != nil { | ||
| 109 | token.endRoleEffect() | 138 | token.endRoleEffect() | ||
| @@ -117,6 +146,28 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram | |||||
| 117 | return false | 146 | return false | ||
| 118 | } | 147 | } | ||
| 119 | 148 | ||||
| 149 | + | // resetActiveOutput rebases only the exact proxied role and transport admitted | |||
| 150 | + | // by the frame router. sendMu serializes the rebase with every output send; | |||
| 151 | + | // transport revalidation under that lock rejects a link replaced while waiting. | |||
| 152 | + | func (d *Daemon) resetActiveOutput(token attachmentRoleToken) bool { | |||
| 153 | + | ac := token.ac | |||
| 154 | + | if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() { | |||
| 155 | + | return false | |||
| 156 | + | } | |||
| 157 | + | ac.sendMu.Lock() | |||
| 158 | + | if token.effect.ended.Load() || !ac.proxied || !token.activeCurrent() || ac.output == nil { | |||
| 159 | + | ac.sendMu.Unlock() | |||
| 160 | + | return false | |||
| 161 | + | } | |||
| 162 | + | ac.output.rebase() | |||
| 163 | + | ac.sendMu.Unlock() | |||
| 164 | + | ||||
| 165 | + | rc := token.sess.core().coordinator.Load() | |||
| 166 | + | return rc != nil && rc.invalidateForLease(ac, token.lease, renderInvalidation{ | |||
| 167 | + | class: invalidateUrgent, reset: true, producer: "output-reset-request", | |||
| 168 | + | }) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 120 | func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool { | 171 | func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool { | ||
| 121 | ac := token.ac | 172 | ac := token.ac | ||
| 122 | if token.effect == nil || token.effect.ended.Load() { | 173 | if token.effect == nil || token.effect.ended.Load() { | ||
M internal/usecase/daemon/control.go +23 -6
| @@ -17,19 +17,36 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | // handleCommand serves one one-shot control request. The leading version is | |
| 19 | 19 | // checked before decoding the versioned body so newer layouts fail cleanly. | |
| 20 | - | func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) { | |
| 20 | + | func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) error { | |
| 21 | 21 | defer func() { _ = tr.Close() }() | |
| 22 | 22 | ||
| 23 | 23 | if version, ok := ports.PeekCommandVersion(f.Payload); !ok || version != ports.ProtocolVersion { | |
| 24 | - | _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"})) | |
| 25 | - | return | |
| 24 | + | return d.sendCommandResult(tr, ports.CommandResult{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}) | |
| 26 | 25 | } | |
| 27 | 26 | request, err := ports.UnmarshalCommandRequest(f.Payload) | |
| 28 | 27 | if err != nil { | |
| 29 | - | _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"})) | |
| 30 | - | return | |
| 28 | + | return d.sendCommandResult(tr, ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"}) | |
| 31 | 29 | } | |
| 32 | - | _ = tr.Send(frameCommandResult(d.dispatchCommand(d.serveCtx, request))) | |
| 30 | + | if request.Attached { | |
| 31 | + | return d.sendCommandResult(tr, ports.CommandResult{ | |
| 32 | + | RequestID: request.RequestID, | |
| 33 | + | Code: ports.ErrNotScriptable, | |
| 34 | + | Text: "attached command relay is not enabled", | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | result := d.dispatchCommand(d.serveCtx, request) | |
| 38 | + | result.RequestID = request.RequestID | |
| 39 | + | return d.sendCommandResult(tr, result) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // sendCommandResult gives one-shot control responses the same observable | |
| 43 | + | // transport-failure behavior regardless of which validation path produced it. | |
| 44 | + | func (d *Daemon) sendCommandResult(tr ports.Transport, result ports.CommandResult) error { | |
| 45 | + | if err := tr.Send(frameCommandResult(result)); err != nil { | |
| 46 | + | d.log.Warn("command response send failed", "err", err) | |
| 47 | + | return err | |
| 48 | + | } | |
| 49 | + | return nil | |
| 33 | 50 | } | |
| 34 | 51 | ||
| 35 | 52 | func frameCommandResult(result ports.CommandResult) ports.Frame { | |
| @@ -17,19 +17,36 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | // handleCommand serves one one-shot control request. The leading version is | 18 | // handleCommand serves one one-shot control request. The leading version is | ||
| 19 | // checked before decoding the versioned body so newer layouts fail cleanly. | 19 | // checked before decoding the versioned body so newer layouts fail cleanly. | ||
| 20 | - | func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) { | 20 | + | func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) error { |
| 21 | defer func() { _ = tr.Close() }() | 21 | defer func() { _ = tr.Close() }() | ||
| 22 | 22 | ||||
| 23 | if version, ok := ports.PeekCommandVersion(f.Payload); !ok || version != ports.ProtocolVersion { | 23 | if version, ok := ports.PeekCommandVersion(f.Payload); !ok || version != ports.ProtocolVersion { | ||
| 24 | - | _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"})) | 24 | + | return d.sendCommandResult(tr, ports.CommandResult{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}) |
| 25 | - | return | |||
| 26 | } | 25 | } | ||
| 27 | request, err := ports.UnmarshalCommandRequest(f.Payload) | 26 | request, err := ports.UnmarshalCommandRequest(f.Payload) | ||
| 28 | if err != nil { | 27 | if err != nil { | ||
| 29 | - | _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"})) | 28 | + | return d.sendCommandResult(tr, ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"}) |
| 30 | - | return | |||
| 31 | } | 29 | } | ||
| 32 | - | _ = tr.Send(frameCommandResult(d.dispatchCommand(d.serveCtx, request))) | 30 | + | if request.Attached { |
| 31 | + | return d.sendCommandResult(tr, ports.CommandResult{ | |||
| 32 | + | RequestID: request.RequestID, | |||
| 33 | + | Code: ports.ErrNotScriptable, | |||
| 34 | + | Text: "attached command relay is not enabled", | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | result := d.dispatchCommand(d.serveCtx, request) | |||
| 38 | + | result.RequestID = request.RequestID | |||
| 39 | + | return d.sendCommandResult(tr, result) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // sendCommandResult gives one-shot control responses the same observable | |||
| 43 | + | // transport-failure behavior regardless of which validation path produced it. | |||
| 44 | + | func (d *Daemon) sendCommandResult(tr ports.Transport, result ports.CommandResult) error { | |||
| 45 | + | if err := tr.Send(frameCommandResult(result)); err != nil { | |||
| 46 | + | d.log.Warn("command response send failed", "err", err) | |||
| 47 | + | return err | |||
| 48 | + | } | |||
| 49 | + | return nil | |||
| 33 | } | 50 | } | ||
| 34 | 51 | ||||
| 35 | func frameCommandResult(result ports.CommandResult) ports.Frame { | 52 | func frameCommandResult(result ports.CommandResult) ports.Frame { | ||
M internal/usecase/daemon/control_test.go +30 -0
| @@ -1,8 +1,12 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 5 | 6 | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "log/slog" | |
| 6 | 10 | "strings" | |
| 7 | 11 | "sync" | |
| 8 | 12 | "testing" | |
| @@ -122,6 +126,23 @@ func (s *actionRunnerSpy) Run(request daemonActionRequest) error { | |||
| 122 | 126 | return s.err | |
| 123 | 127 | } | |
| 124 | 128 | ||
| 129 | + | func TestHandleCommandAttachedRejectionReturnsSendFailure(t *testing.T) { | |
| 130 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 131 | + | var logs bytes.Buffer | |
| 132 | + | d.log = slog.New(slog.NewTextHandler(&logs, nil)) | |
| 133 | + | sendErr := errors.New("command result send failed") | |
| 134 | + | tr := &commandSendErrorTransport{err: sendErr} | |
| 135 | + | ||
| 136 | + | err := d.handleCommand(tr, commandFrame(t, ports.CommandRequest{ | |
| 137 | + | RequestID: 41, | |
| 138 | + | Attached: true, | |
| 139 | + | })) | |
| 140 | + | ||
| 141 | + | require.ErrorIs(t, err, sendErr) | |
| 142 | + | require.Contains(t, logs.String(), "command response send failed") | |
| 143 | + | require.True(t, tr.closed, "the failed one-shot transport must still close") | |
| 144 | + | } | |
| 145 | + | ||
| 125 | 146 | func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) { | |
| 126 | 147 | // The version prefix is valid and mismatched, while the remainder is not a | |
| 127 | 148 | // decodable request. The daemon must still return ErrVersionMismatch. | |
| @@ -1222,6 +1243,15 @@ func (f *controlPTYFactory) close() { | |||
| 1222 | 1243 | } | |
| 1223 | 1244 | } | |
| 1224 | 1245 | ||
| 1246 | + | type commandSendErrorTransport struct { | |
| 1247 | + | err error | |
| 1248 | + | closed bool | |
| 1249 | + | } | |
| 1250 | + | ||
| 1251 | + | func (tr *commandSendErrorTransport) Send(ports.Frame) error { return tr.err } | |
| 1252 | + | func (*commandSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 1253 | + | func (tr *commandSendErrorTransport) Close() error { tr.closed = true; return nil } | |
| 1254 | + | ||
| 1225 | 1255 | func addControlSession(d *Daemon, name, tabID, paneID string) *session { | |
| 1226 | 1256 | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 1227 | 1257 | tb.ctx, tb.cancel = context.WithCancel(d.serveCtx) | |
| @@ -1,8 +1,12 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "context" | 5 | "context" | ||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | + | "errors" | |||
| 8 | + | "io" | |||
| 9 | + | "log/slog" | |||
| 6 | "strings" | 10 | "strings" | ||
| 7 | "sync" | 11 | "sync" | ||
| 8 | "testing" | 12 | "testing" | ||
| @@ -122,6 +126,23 @@ func (s *actionRunnerSpy) Run(request daemonActionRequest) error { | |||||
| 122 | return s.err | 126 | return s.err | ||
| 123 | } | 127 | } | ||
| 124 | 128 | ||||
| 129 | + | func TestHandleCommandAttachedRejectionReturnsSendFailure(t *testing.T) { | |||
| 130 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 131 | + | var logs bytes.Buffer | |||
| 132 | + | d.log = slog.New(slog.NewTextHandler(&logs, nil)) | |||
| 133 | + | sendErr := errors.New("command result send failed") | |||
| 134 | + | tr := &commandSendErrorTransport{err: sendErr} | |||
| 135 | + | ||||
| 136 | + | err := d.handleCommand(tr, commandFrame(t, ports.CommandRequest{ | |||
| 137 | + | RequestID: 41, | |||
| 138 | + | Attached: true, | |||
| 139 | + | })) | |||
| 140 | + | ||||
| 141 | + | require.ErrorIs(t, err, sendErr) | |||
| 142 | + | require.Contains(t, logs.String(), "command response send failed") | |||
| 143 | + | require.True(t, tr.closed, "the failed one-shot transport must still close") | |||
| 144 | + | } | |||
| 145 | + | ||||
| 125 | func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) { | 146 | func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) { | ||
| 126 | // The version prefix is valid and mismatched, while the remainder is not a | 147 | // The version prefix is valid and mismatched, while the remainder is not a | ||
| 127 | // decodable request. The daemon must still return ErrVersionMismatch. | 148 | // decodable request. The daemon must still return ErrVersionMismatch. | ||
| @@ -1222,6 +1243,15 @@ func (f *controlPTYFactory) close() { | |||||
| 1222 | } | 1243 | } | ||
| 1223 | } | 1244 | } | ||
| 1224 | 1245 | ||||
| 1246 | + | type commandSendErrorTransport struct { | |||
| 1247 | + | err error | |||
| 1248 | + | closed bool | |||
| 1249 | + | } | |||
| 1250 | + | ||||
| 1251 | + | func (tr *commandSendErrorTransport) Send(ports.Frame) error { return tr.err } | |||
| 1252 | + | func (*commandSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |||
| 1253 | + | func (tr *commandSendErrorTransport) Close() error { tr.closed = true; return nil } | |||
| 1254 | + | ||||
| 1225 | func addControlSession(d *Daemon, name, tabID, paneID string) *session { | 1255 | func addControlSession(d *Daemon, name, tabID, paneID string) *session { | ||
| 1226 | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | 1256 | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | ||
| 1227 | tb.ctx, tb.cancel = context.WithCancel(d.serveCtx) | 1257 | tb.ctx, tb.cancel = context.WithCancel(d.serveCtx) | ||
M internal/usecase/daemon/daemon.go +21 -5
| @@ -1149,7 +1149,22 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||
| 1149 | 1149 | return | |
| 1150 | 1150 | } | |
| 1151 | 1151 | postWelcomeLease := postWelcomeToken.lease | |
| 1152 | - | if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil || !rc.markAttachmentReady(postWelcomeLease) { | |
| 1152 | + | if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil { | |
| 1153 | + | postWelcomeTicket.End() | |
| 1154 | + | d.clientGone(sess, ac, tr, false) | |
| 1155 | + | return | |
| 1156 | + | } | |
| 1157 | + | if ac.proxied { | |
| 1158 | + | meta, ok := sess.sessionMetaSnapshot() | |
| 1159 | + | metaFrame, metaErr := frameSessionMeta(meta) | |
| 1160 | + | if !ok || metaErr != nil || ac.sendExpectedTransportForRole(postWelcomeToken.transport, metaFrame, postWelcomeTicket) != nil { | |
| 1161 | + | postWelcomeTicket.End() | |
| 1162 | + | d.clientGone(sess, ac, tr, false) | |
| 1163 | + | return | |
| 1164 | + | } | |
| 1165 | + | ac.markSessionMetaSent(meta) | |
| 1166 | + | } | |
| 1167 | + | if !rc.markAttachmentReady(postWelcomeLease) { | |
| 1153 | 1168 | postWelcomeTicket.End() | |
| 1154 | 1169 | // The attachment was displaced or detached while Welcome was in flight; | |
| 1155 | 1170 | // never let this stale handshake emit an Output frame. | |
| @@ -1190,6 +1205,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||
| 1190 | 1205 | clientID: h.ClientID, | |
| 1191 | 1206 | resumeCapable: true, | |
| 1192 | 1207 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | |
| 1208 | + | proxied: h.Proxied, | |
| 1193 | 1209 | } | |
| 1194 | 1210 | ac := d.prepareAttachedClientLocked(tr, sz, opts) | |
| 1195 | 1211 | d.mu.Unlock() | |
| @@ -1336,7 +1352,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1336 | 1352 | } | |
| 1337 | 1353 | cwd := d.dirOrHome(stopped.cwd) | |
| 1338 | 1354 | var err error | |
| 1339 | - | sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames) | |
| 1355 | + | sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, h.Proxied, stopped.tabNames) | |
| 1340 | 1356 | if err != nil { | |
| 1341 | 1357 | d.mu.Unlock() | |
| 1342 | 1358 | return nil, nil, err | |
| @@ -1347,7 +1363,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1347 | 1363 | ||
| 1348 | 1364 | case ports.IntentEphemeral: | |
| 1349 | 1365 | name := d.allocEphemeralNameLocked() | |
| 1350 | - | sess, err := d.createSessionLocked(name, true, h.Cwd, sz, term, h.Env) | |
| 1366 | + | sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, term, h.Env, h.Proxied) | |
| 1351 | 1367 | if err != nil { | |
| 1352 | 1368 | d.mu.Unlock() | |
| 1353 | 1369 | return nil, nil, err | |
| @@ -1368,7 +1384,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1368 | 1384 | d.mu.Unlock() | |
| 1369 | 1385 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | |
| 1370 | 1386 | } | |
| 1371 | - | sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, term, h.Env) | |
| 1387 | + | sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, sz, term, h.Env, h.Proxied) | |
| 1372 | 1388 | if err != nil { | |
| 1373 | 1389 | d.mu.Unlock() | |
| 1374 | 1390 | return nil, nil, err | |
| @@ -1386,7 +1402,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1386 | 1402 | } | |
| 1387 | 1403 | cwd := d.dirOrHome(stopped.cwd) | |
| 1388 | 1404 | var err error | |
| 1389 | - | sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames) | |
| 1405 | + | sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, h.Proxied, stopped.tabNames) | |
| 1390 | 1406 | if err != nil { | |
| 1391 | 1407 | d.mu.Unlock() | |
| 1392 | 1408 | return nil, nil, err | |
| @@ -1149,7 +1149,22 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||||
| 1149 | return | 1149 | return | ||
| 1150 | } | 1150 | } | ||
| 1151 | postWelcomeLease := postWelcomeToken.lease | 1151 | postWelcomeLease := postWelcomeToken.lease | ||
| 1152 | - | if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil || !rc.markAttachmentReady(postWelcomeLease) { | 1152 | + | if postWelcomeToken.role != attachmentActive || rc == nil || postWelcomeLease == nil { |
| 1153 | + | postWelcomeTicket.End() | |||
| 1154 | + | d.clientGone(sess, ac, tr, false) | |||
| 1155 | + | return | |||
| 1156 | + | } | |||
| 1157 | + | if ac.proxied { | |||
| 1158 | + | meta, ok := sess.sessionMetaSnapshot() | |||
| 1159 | + | metaFrame, metaErr := frameSessionMeta(meta) | |||
| 1160 | + | if !ok || metaErr != nil || ac.sendExpectedTransportForRole(postWelcomeToken.transport, metaFrame, postWelcomeTicket) != nil { | |||
| 1161 | + | postWelcomeTicket.End() | |||
| 1162 | + | d.clientGone(sess, ac, tr, false) | |||
| 1163 | + | return | |||
| 1164 | + | } | |||
| 1165 | + | ac.markSessionMetaSent(meta) | |||
| 1166 | + | } | |||
| 1167 | + | if !rc.markAttachmentReady(postWelcomeLease) { | |||
| 1153 | postWelcomeTicket.End() | 1168 | postWelcomeTicket.End() | ||
| 1154 | // The attachment was displaced or detached while Welcome was in flight; | 1169 | // The attachment was displaced or detached while Welcome was in flight; | ||
| 1155 | // never let this stale handshake emit an Output frame. | 1170 | // never let this stale handshake emit an Output frame. | ||
| @@ -1190,6 +1205,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 1190 | clientID: h.ClientID, | 1205 | clientID: h.ClientID, | ||
| 1191 | resumeCapable: true, | 1206 | resumeCapable: true, | ||
| 1192 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | 1207 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | ||
| 1208 | + | proxied: h.Proxied, | |||
| 1193 | } | 1209 | } | ||
| 1194 | ac := d.prepareAttachedClientLocked(tr, sz, opts) | 1210 | ac := d.prepareAttachedClientLocked(tr, sz, opts) | ||
| 1195 | d.mu.Unlock() | 1211 | d.mu.Unlock() | ||
| @@ -1336,7 +1352,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 1336 | } | 1352 | } | ||
| 1337 | cwd := d.dirOrHome(stopped.cwd) | 1353 | cwd := d.dirOrHome(stopped.cwd) | ||
| 1338 | var err error | 1354 | var err error | ||
| 1339 | - | sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames) | 1355 | + | sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, h.Proxied, stopped.tabNames) |
| 1340 | if err != nil { | 1356 | if err != nil { | ||
| 1341 | d.mu.Unlock() | 1357 | d.mu.Unlock() | ||
| 1342 | return nil, nil, err | 1358 | return nil, nil, err | ||
| @@ -1347,7 +1363,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 1347 | 1363 | ||||
| 1348 | case ports.IntentEphemeral: | 1364 | case ports.IntentEphemeral: | ||
| 1349 | name := d.allocEphemeralNameLocked() | 1365 | name := d.allocEphemeralNameLocked() | ||
| 1350 | - | sess, err := d.createSessionLocked(name, true, h.Cwd, sz, term, h.Env) | 1366 | + | sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, term, h.Env, h.Proxied) |
| 1351 | if err != nil { | 1367 | if err != nil { | ||
| 1352 | d.mu.Unlock() | 1368 | d.mu.Unlock() | ||
| 1353 | return nil, nil, err | 1369 | return nil, nil, err | ||
| @@ -1368,7 +1384,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 1368 | d.mu.Unlock() | 1384 | d.mu.Unlock() | ||
| 1369 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | 1385 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | ||
| 1370 | } | 1386 | } | ||
| 1371 | - | sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, term, h.Env) | 1387 | + | sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, sz, term, h.Env, h.Proxied) |
| 1372 | if err != nil { | 1388 | if err != nil { | ||
| 1373 | d.mu.Unlock() | 1389 | d.mu.Unlock() | ||
| 1374 | return nil, nil, err | 1390 | return nil, nil, err | ||
| @@ -1386,7 +1402,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 1386 | } | 1402 | } | ||
| 1387 | cwd := d.dirOrHome(stopped.cwd) | 1403 | cwd := d.dirOrHome(stopped.cwd) | ||
| 1388 | var err error | 1404 | var err error | ||
| 1389 | - | sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames) | 1405 | + | sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, h.Proxied, stopped.tabNames) |
| 1390 | if err != nil { | 1406 | if err != nil { | ||
| 1391 | d.mu.Unlock() | 1407 | d.mu.Unlock() | ||
| 1392 | return nil, nil, err | 1408 | return nil, nil, err | ||
A internal/usecase/daemon/proxied_contract_test.go +259 -0
| @@ -0,0 +1,259 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |
| 11 | + | "github.com/bnema/vev/pkg/vt" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) { | |
| 16 | + | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |
| 17 | + | contentSize := domain.Size{Cols: 24, Rows: 4} | |
| 18 | + | hello := ports.Hello{ | |
| 19 | + | Version: ports.ProtocolVersion, | |
| 20 | + | Intent: ports.IntentNew, | |
| 21 | + | Proxied: true, | |
| 22 | + | Name: "remote-work", | |
| 23 | + | Size: contentSize, | |
| 24 | + | MaxOutputInFlight: 4, | |
| 25 | + | } | |
| 26 | + | tr, sends, release := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |
| 27 | + | defer release() | |
| 28 | + | ||
| 29 | + | var handlers sync.WaitGroup | |
| 30 | + | handlers.Go(func() { d.handleConn(tr) }) | |
| 31 | + | ||
| 32 | + | welcomeFrame := awaitFrame(t, sends, ports.MsgWelcome) | |
| 33 | + | welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | require.Equal(t, ports.CapabilityResume|ports.CapabilityProxied, welcome.Capabilities) | |
| 36 | + | ||
| 37 | + | metaFrame := awaitFrame(t, sends, ports.MsgSessionMeta) | |
| 38 | + | meta, err := ports.UnmarshalSessionMeta(metaFrame.Payload) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | require.Equal(t, "remote-work", meta.SessionName) | |
| 41 | + | require.Equal(t, uint16(0), meta.Active) | |
| 42 | + | require.Equal(t, []ports.SessionTabMeta{{Index: 0}}, meta.Tabs) | |
| 43 | + | ||
| 44 | + | outputFrame := awaitFrame(t, sends, ports.MsgOutput) | |
| 45 | + | output, err := ports.UnmarshalOutput(outputFrame.Payload) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | require.Zero(t, output.BaseStateNum) | |
| 48 | + | ||
| 49 | + | client := vt.NewScreen(contentSize.Cols, contentSize.Rows) | |
| 50 | + | client.Write(output.Data) | |
| 51 | + | require.Equal(t, contentSize.Rows, client.Frame.Height) | |
| 52 | + | for _, row := range frameRows(client.Frame) { | |
| 53 | + | require.NotContains(t, row, "remote-work", "proxied output must not contain remote session chrome") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | sess := firstSession(d) | |
| 57 | + | require.NotNil(t, sess) | |
| 58 | + | sess.activeTab().mu.Lock() | |
| 59 | + | actualContentSize := sess.activeTab().size | |
| 60 | + | sess.activeTab().mu.Unlock() | |
| 61 | + | require.Equal(t, contentSize, actualContentSize, "proxied geometry must not subtract chrome a second time") | |
| 62 | + | ||
| 63 | + | release() | |
| 64 | + | handlers.Wait() | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) { | |
| 68 | + | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |
| 69 | + | viewport := domain.Size{Cols: 24, Rows: 6} | |
| 70 | + | hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "ordinary", Size: viewport} | |
| 71 | + | tr, sends, release := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |
| 72 | + | defer release() | |
| 73 | + | ||
| 74 | + | var handlers sync.WaitGroup | |
| 75 | + | handlers.Go(func() { d.handleConn(tr) }) | |
| 76 | + | ||
| 77 | + | welcomeFrame := awaitFrame(t, sends, ports.MsgWelcome) | |
| 78 | + | welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | require.Equal(t, ports.CapabilityResume, welcome.Capabilities) | |
| 81 | + | ||
| 82 | + | outputFrame := awaitFrame(t, sends, ports.MsgOutput) | |
| 83 | + | output, err := ports.UnmarshalOutput(outputFrame.Payload) | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | require.Zero(t, output.BaseStateNum) | |
| 86 | + | client := vt.NewScreen(viewport.Cols, viewport.Rows) | |
| 87 | + | client.Write(output.Data) | |
| 88 | + | require.Contains(t, screenLineText(client, 0), "1") | |
| 89 | + | require.Contains(t, screenLineText(client, viewport.Rows-1), "ordinary") | |
| 90 | + | ||
| 91 | + | sess := firstSession(d) | |
| 92 | + | require.NotNil(t, sess) | |
| 93 | + | sess.activeTab().mu.Lock() | |
| 94 | + | actualContentSize := sess.activeTab().size | |
| 95 | + | sess.activeTab().mu.Unlock() | |
| 96 | + | require.Equal(t, tabSize(viewport), actualContentSize) | |
| 97 | + | requireNoOutputFrame(t, sends) | |
| 98 | + | ||
| 99 | + | release() | |
| 100 | + | handlers.Wait() | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestComposeFrameProxiedContentOnlyOmitsBothChromeRows(t *testing.T) { | |
| 104 | + | paneFrame := renderer.NewFrame(8, 2) | |
| 105 | + | fillOutputStateRows(paneFrame, []string{"CONTENT1", "CONTENT2"}) | |
| 106 | + | placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 8, Height: 2}} | |
| 107 | + | state := capturedRenderState{ | |
| 108 | + | contentOnly: true, | |
| 109 | + | reset: true, | |
| 110 | + | layout: capturedTabLayout{ | |
| 111 | + | area: domain.Rect{Width: 8, Height: 2}, focus: "pane", placements: []layout.Placement{placement}, valid: true, | |
| 112 | + | }, | |
| 113 | + | panes: []capturedPaneRenderState{{ | |
| 114 | + | id: "pane", frame: paneFrame, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 115 | + | }}, | |
| 116 | + | bars: barState{status: statusSnapshot{session: "REMOTE-CHROME"}, bottomRight: "BOTTOM-CHROME"}, | |
| 117 | + | cursor: capturedCursorInputs{visible: true, renderable: true, content: placement.Content}, | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | proxied := composeFrame(state, composeCacheInput{}) | |
| 121 | + | require.Equal(t, []string{"CONTENT1", "CONTENT2"}, frameRows(proxied.frame)) | |
| 122 | + | require.Equal(t, 0, proxied.cursor.row) | |
| 123 | + | ||
| 124 | + | state.contentOnly = false | |
| 125 | + | ordinary := composeFrame(state, composeCacheInput{}) | |
| 126 | + | require.Equal(t, 4, ordinary.frame.Height) | |
| 127 | + | require.Equal(t, "CONTENT1", rowText(ordinary.frame.Row(1))) | |
| 128 | + | require.Contains(t, rowText(ordinary.frame.Row(3)), "REMOTE-") | |
| 129 | + | require.Equal(t, 1, ordinary.cursor.row) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) { | |
| 133 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 134 | + | ac.proxied = true | |
| 135 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 136 | + | lease := rc.attachmentLease(ac) | |
| 137 | + | require.NotNil(t, lease) | |
| 138 | + | ||
| 139 | + | want := domain.Size{Cols: 100, Rows: 12} | |
| 140 | + | require.True(t, d.requestTransactionalResizeForLease(sess, ac, lease, want, true)) | |
| 141 | + | tb := sess.activeTab() | |
| 142 | + | tb.mu.Lock() | |
| 143 | + | require.Equal(t, want, tb.size) | |
| 144 | + | tb.mu.Unlock() | |
| 145 | + | require.Equal(t, want, ac.size) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestProxiedModeCannotChangeAcrossResume(t *testing.T) { | |
| 149 | + | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |
| 150 | + | var clientID [16]byte | |
| 151 | + | clientID[0] = 1 | |
| 152 | + | originalTransport := &closeTrackingTransport{} | |
| 153 | + | hello := ports.Hello{ | |
| 154 | + | Version: ports.ProtocolVersion, Intent: ports.IntentNew, Proxied: true, | |
| 155 | + | ClientID: clientID, Name: "immutable", Size: domain.Size{Cols: 40, Rows: 8}, | |
| 156 | + | } | |
| 157 | + | sess, ac, err := d.route(hello, originalTransport) | |
| 158 | + | require.NoError(t, err) | |
| 159 | + | token := ac.resumeToken | |
| 160 | + | d.clientGone(sess, ac, originalTransport, false) | |
| 161 | + | ||
| 162 | + | resume := hello | |
| 163 | + | resume.Intent = ports.IntentResume | |
| 164 | + | resume.ResumeToken = token | |
| 165 | + | resume.Proxied = false | |
| 166 | + | _, resumed, ok, err := d.resumeParked(resume, &closeTrackingTransport{}, resume.Size) | |
| 167 | + | require.Error(t, err) | |
| 168 | + | require.False(t, ok) | |
| 169 | + | require.Nil(t, resumed) | |
| 170 | + | require.True(t, ac.proxied) | |
| 171 | + | d.mu.Lock() | |
| 172 | + | require.NotNil(t, d.parked[token], "a mode-mismatched resume must leave the original attachment parked") | |
| 173 | + | d.mu.Unlock() | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) { | |
| 177 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 178 | + | ac.proxied = true | |
| 179 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 180 | + | lease := rc.attachmentLease(ac) | |
| 181 | + | require.NotNil(t, lease) | |
| 182 | + | ||
| 183 | + | ac.sendMu.Lock() | |
| 184 | + | ac.output.next = ac.output.maxOutstanding | |
| 185 | + | ac.output.acked = 0 | |
| 186 | + | ac.sendMu.Unlock() | |
| 187 | + | ||
| 188 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 189 | + | require.False(t, d.handleActiveClientFrame(token, ports.Frame{ | |
| 190 | + | Type: ports.MsgOutputResetRequest, | |
| 191 | + | Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), | |
| 192 | + | })) | |
| 193 | + | ||
| 194 | + | frame := awaitFrame(t, sends, ports.MsgOutput) | |
| 195 | + | out, err := ports.UnmarshalOutput(frame.Payload) | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | require.Zero(t, out.BaseStateNum) | |
| 198 | + | ac.sendMu.Lock() | |
| 199 | + | require.Equal(t, ac.output.maxOutstanding, ac.output.acked, "reset must retire the previously full output window") | |
| 200 | + | require.Equal(t, uint64(1), ac.output.outstanding(), "only the authoritative reset paint remains outstanding") | |
| 201 | + | require.Equal(t, ac.output.next, out.NewStateNum) | |
| 202 | + | ac.sendMu.Unlock() | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) { | |
| 206 | + | tests := []struct { | |
| 207 | + | name string | |
| 208 | + | proxied bool | |
| 209 | + | payload []byte | |
| 210 | + | staleRole bool | |
| 211 | + | }{ | |
| 212 | + | {name: "ordinary attachment", proxied: false, payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}, | |
| 213 | + | {name: "malformed request", proxied: true, payload: []byte{0}}, | |
| 214 | + | {name: "stale active role", proxied: true, payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), staleRole: true}, | |
| 215 | + | } | |
| 216 | + | for _, tt := range tests { | |
| 217 | + | t.Run(tt.name, func(t *testing.T) { | |
| 218 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 219 | + | ac.proxied = tt.proxied | |
| 220 | + | d.attachCoordinator(sess, nil, ac, true) | |
| 221 | + | ac.output.next, ac.output.acked = 4, 0 | |
| 222 | + | ||
| 223 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 224 | + | if tt.staleRole { | |
| 225 | + | token.role = attachmentSnatched | |
| 226 | + | } | |
| 227 | + | require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgOutputResetRequest, Payload: tt.payload})) | |
| 228 | + | require.Zero(t, ac.output.acked) | |
| 229 | + | }) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) { | |
| 234 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 235 | + | ac.proxied = true | |
| 236 | + | d.attachCoordinator(sess, nil, ac, true) | |
| 237 | + | ac.output.next, ac.output.acked = 4, 0 | |
| 238 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 239 | + | ||
| 240 | + | admitted := make(chan struct{}) | |
| 241 | + | var once sync.Once | |
| 242 | + | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { once.Do(func() { close(admitted) }) } | |
| 243 | + | ac.sendMu.Lock() | |
| 244 | + | done := make(chan bool, 1) | |
| 245 | + | go func() { | |
| 246 | + | done <- d.handleActiveClientFrame(token, ports.Frame{ | |
| 247 | + | Type: ports.MsgOutputResetRequest, | |
| 248 | + | Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), | |
| 249 | + | }) | |
| 250 | + | }() | |
| 251 | + | awaitTestCompletion(t, admitted, "reset did not admit its role effect") | |
| 252 | + | ac.replaceTransport(&closeTrackingTransport{}) | |
| 253 | + | ac.sendMu.Unlock() | |
| 254 | + | ||
| 255 | + | require.False(t, awaitTestValue(t, done, "reset did not finish after send lock release")) | |
| 256 | + | ac.sendMu.Lock() | |
| 257 | + | require.Zero(t, ac.output.acked, "a stale transport must not rebase the output stream") | |
| 258 | + | ac.sendMu.Unlock() | |
| 259 | + | } | |
| @@ -0,0 +1,259 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | + | "github.com/bnema/vev/pkg/vt" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) { | |||
| 16 | + | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |||
| 17 | + | contentSize := domain.Size{Cols: 24, Rows: 4} | |||
| 18 | + | hello := ports.Hello{ | |||
| 19 | + | Version: ports.ProtocolVersion, | |||
| 20 | + | Intent: ports.IntentNew, | |||
| 21 | + | Proxied: true, | |||
| 22 | + | Name: "remote-work", | |||
| 23 | + | Size: contentSize, | |||
| 24 | + | MaxOutputInFlight: 4, | |||
| 25 | + | } | |||
| 26 | + | tr, sends, release := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |||
| 27 | + | defer release() | |||
| 28 | + | ||||
| 29 | + | var handlers sync.WaitGroup | |||
| 30 | + | handlers.Go(func() { d.handleConn(tr) }) | |||
| 31 | + | ||||
| 32 | + | welcomeFrame := awaitFrame(t, sends, ports.MsgWelcome) | |||
| 33 | + | welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload) | |||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | require.Equal(t, ports.CapabilityResume|ports.CapabilityProxied, welcome.Capabilities) | |||
| 36 | + | ||||
| 37 | + | metaFrame := awaitFrame(t, sends, ports.MsgSessionMeta) | |||
| 38 | + | meta, err := ports.UnmarshalSessionMeta(metaFrame.Payload) | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | require.Equal(t, "remote-work", meta.SessionName) | |||
| 41 | + | require.Equal(t, uint16(0), meta.Active) | |||
| 42 | + | require.Equal(t, []ports.SessionTabMeta{{Index: 0}}, meta.Tabs) | |||
| 43 | + | ||||
| 44 | + | outputFrame := awaitFrame(t, sends, ports.MsgOutput) | |||
| 45 | + | output, err := ports.UnmarshalOutput(outputFrame.Payload) | |||
| 46 | + | require.NoError(t, err) | |||
| 47 | + | require.Zero(t, output.BaseStateNum) | |||
| 48 | + | ||||
| 49 | + | client := vt.NewScreen(contentSize.Cols, contentSize.Rows) | |||
| 50 | + | client.Write(output.Data) | |||
| 51 | + | require.Equal(t, contentSize.Rows, client.Frame.Height) | |||
| 52 | + | for _, row := range frameRows(client.Frame) { | |||
| 53 | + | require.NotContains(t, row, "remote-work", "proxied output must not contain remote session chrome") | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | sess := firstSession(d) | |||
| 57 | + | require.NotNil(t, sess) | |||
| 58 | + | sess.activeTab().mu.Lock() | |||
| 59 | + | actualContentSize := sess.activeTab().size | |||
| 60 | + | sess.activeTab().mu.Unlock() | |||
| 61 | + | require.Equal(t, contentSize, actualContentSize, "proxied geometry must not subtract chrome a second time") | |||
| 62 | + | ||||
| 63 | + | release() | |||
| 64 | + | handlers.Wait() | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) { | |||
| 68 | + | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |||
| 69 | + | viewport := domain.Size{Cols: 24, Rows: 6} | |||
| 70 | + | hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "ordinary", Size: viewport} | |||
| 71 | + | tr, sends, release := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |||
| 72 | + | defer release() | |||
| 73 | + | ||||
| 74 | + | var handlers sync.WaitGroup | |||
| 75 | + | handlers.Go(func() { d.handleConn(tr) }) | |||
| 76 | + | ||||
| 77 | + | welcomeFrame := awaitFrame(t, sends, ports.MsgWelcome) | |||
| 78 | + | welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | require.Equal(t, ports.CapabilityResume, welcome.Capabilities) | |||
| 81 | + | ||||
| 82 | + | outputFrame := awaitFrame(t, sends, ports.MsgOutput) | |||
| 83 | + | output, err := ports.UnmarshalOutput(outputFrame.Payload) | |||
| 84 | + | require.NoError(t, err) | |||
| 85 | + | require.Zero(t, output.BaseStateNum) | |||
| 86 | + | client := vt.NewScreen(viewport.Cols, viewport.Rows) | |||
| 87 | + | client.Write(output.Data) | |||
| 88 | + | require.Contains(t, screenLineText(client, 0), "1") | |||
| 89 | + | require.Contains(t, screenLineText(client, viewport.Rows-1), "ordinary") | |||
| 90 | + | ||||
| 91 | + | sess := firstSession(d) | |||
| 92 | + | require.NotNil(t, sess) | |||
| 93 | + | sess.activeTab().mu.Lock() | |||
| 94 | + | actualContentSize := sess.activeTab().size | |||
| 95 | + | sess.activeTab().mu.Unlock() | |||
| 96 | + | require.Equal(t, tabSize(viewport), actualContentSize) | |||
| 97 | + | requireNoOutputFrame(t, sends) | |||
| 98 | + | ||||
| 99 | + | release() | |||
| 100 | + | handlers.Wait() | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestComposeFrameProxiedContentOnlyOmitsBothChromeRows(t *testing.T) { | |||
| 104 | + | paneFrame := renderer.NewFrame(8, 2) | |||
| 105 | + | fillOutputStateRows(paneFrame, []string{"CONTENT1", "CONTENT2"}) | |||
| 106 | + | placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 8, Height: 2}} | |||
| 107 | + | state := capturedRenderState{ | |||
| 108 | + | contentOnly: true, | |||
| 109 | + | reset: true, | |||
| 110 | + | layout: capturedTabLayout{ | |||
| 111 | + | area: domain.Rect{Width: 8, Height: 2}, focus: "pane", placements: []layout.Placement{placement}, valid: true, | |||
| 112 | + | }, | |||
| 113 | + | panes: []capturedPaneRenderState{{ | |||
| 114 | + | id: "pane", frame: paneFrame, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | |||
| 115 | + | }}, | |||
| 116 | + | bars: barState{status: statusSnapshot{session: "REMOTE-CHROME"}, bottomRight: "BOTTOM-CHROME"}, | |||
| 117 | + | cursor: capturedCursorInputs{visible: true, renderable: true, content: placement.Content}, | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | proxied := composeFrame(state, composeCacheInput{}) | |||
| 121 | + | require.Equal(t, []string{"CONTENT1", "CONTENT2"}, frameRows(proxied.frame)) | |||
| 122 | + | require.Equal(t, 0, proxied.cursor.row) | |||
| 123 | + | ||||
| 124 | + | state.contentOnly = false | |||
| 125 | + | ordinary := composeFrame(state, composeCacheInput{}) | |||
| 126 | + | require.Equal(t, 4, ordinary.frame.Height) | |||
| 127 | + | require.Equal(t, "CONTENT1", rowText(ordinary.frame.Row(1))) | |||
| 128 | + | require.Contains(t, rowText(ordinary.frame.Row(3)), "REMOTE-") | |||
| 129 | + | require.Equal(t, 1, ordinary.cursor.row) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) { | |||
| 133 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 134 | + | ac.proxied = true | |||
| 135 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 136 | + | lease := rc.attachmentLease(ac) | |||
| 137 | + | require.NotNil(t, lease) | |||
| 138 | + | ||||
| 139 | + | want := domain.Size{Cols: 100, Rows: 12} | |||
| 140 | + | require.True(t, d.requestTransactionalResizeForLease(sess, ac, lease, want, true)) | |||
| 141 | + | tb := sess.activeTab() | |||
| 142 | + | tb.mu.Lock() | |||
| 143 | + | require.Equal(t, want, tb.size) | |||
| 144 | + | tb.mu.Unlock() | |||
| 145 | + | require.Equal(t, want, ac.size) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestProxiedModeCannotChangeAcrossResume(t *testing.T) { | |||
| 149 | + | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |||
| 150 | + | var clientID [16]byte | |||
| 151 | + | clientID[0] = 1 | |||
| 152 | + | originalTransport := &closeTrackingTransport{} | |||
| 153 | + | hello := ports.Hello{ | |||
| 154 | + | Version: ports.ProtocolVersion, Intent: ports.IntentNew, Proxied: true, | |||
| 155 | + | ClientID: clientID, Name: "immutable", Size: domain.Size{Cols: 40, Rows: 8}, | |||
| 156 | + | } | |||
| 157 | + | sess, ac, err := d.route(hello, originalTransport) | |||
| 158 | + | require.NoError(t, err) | |||
| 159 | + | token := ac.resumeToken | |||
| 160 | + | d.clientGone(sess, ac, originalTransport, false) | |||
| 161 | + | ||||
| 162 | + | resume := hello | |||
| 163 | + | resume.Intent = ports.IntentResume | |||
| 164 | + | resume.ResumeToken = token | |||
| 165 | + | resume.Proxied = false | |||
| 166 | + | _, resumed, ok, err := d.resumeParked(resume, &closeTrackingTransport{}, resume.Size) | |||
| 167 | + | require.Error(t, err) | |||
| 168 | + | require.False(t, ok) | |||
| 169 | + | require.Nil(t, resumed) | |||
| 170 | + | require.True(t, ac.proxied) | |||
| 171 | + | d.mu.Lock() | |||
| 172 | + | require.NotNil(t, d.parked[token], "a mode-mismatched resume must leave the original attachment parked") | |||
| 173 | + | d.mu.Unlock() | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) { | |||
| 177 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 178 | + | ac.proxied = true | |||
| 179 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 180 | + | lease := rc.attachmentLease(ac) | |||
| 181 | + | require.NotNil(t, lease) | |||
| 182 | + | ||||
| 183 | + | ac.sendMu.Lock() | |||
| 184 | + | ac.output.next = ac.output.maxOutstanding | |||
| 185 | + | ac.output.acked = 0 | |||
| 186 | + | ac.sendMu.Unlock() | |||
| 187 | + | ||||
| 188 | + | token := sess.attachmentToken(ac, ac.transport()) | |||
| 189 | + | require.False(t, d.handleActiveClientFrame(token, ports.Frame{ | |||
| 190 | + | Type: ports.MsgOutputResetRequest, | |||
| 191 | + | Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), | |||
| 192 | + | })) | |||
| 193 | + | ||||
| 194 | + | frame := awaitFrame(t, sends, ports.MsgOutput) | |||
| 195 | + | out, err := ports.UnmarshalOutput(frame.Payload) | |||
| 196 | + | require.NoError(t, err) | |||
| 197 | + | require.Zero(t, out.BaseStateNum) | |||
| 198 | + | ac.sendMu.Lock() | |||
| 199 | + | require.Equal(t, ac.output.maxOutstanding, ac.output.acked, "reset must retire the previously full output window") | |||
| 200 | + | require.Equal(t, uint64(1), ac.output.outstanding(), "only the authoritative reset paint remains outstanding") | |||
| 201 | + | require.Equal(t, ac.output.next, out.NewStateNum) | |||
| 202 | + | ac.sendMu.Unlock() | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) { | |||
| 206 | + | tests := []struct { | |||
| 207 | + | name string | |||
| 208 | + | proxied bool | |||
| 209 | + | payload []byte | |||
| 210 | + | staleRole bool | |||
| 211 | + | }{ | |||
| 212 | + | {name: "ordinary attachment", proxied: false, payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}, | |||
| 213 | + | {name: "malformed request", proxied: true, payload: []byte{0}}, | |||
| 214 | + | {name: "stale active role", proxied: true, payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), staleRole: true}, | |||
| 215 | + | } | |||
| 216 | + | for _, tt := range tests { | |||
| 217 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 218 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 219 | + | ac.proxied = tt.proxied | |||
| 220 | + | d.attachCoordinator(sess, nil, ac, true) | |||
| 221 | + | ac.output.next, ac.output.acked = 4, 0 | |||
| 222 | + | ||||
| 223 | + | token := sess.attachmentToken(ac, ac.transport()) | |||
| 224 | + | if tt.staleRole { | |||
| 225 | + | token.role = attachmentSnatched | |||
| 226 | + | } | |||
| 227 | + | require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgOutputResetRequest, Payload: tt.payload})) | |||
| 228 | + | require.Zero(t, ac.output.acked) | |||
| 229 | + | }) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) { | |||
| 234 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 235 | + | ac.proxied = true | |||
| 236 | + | d.attachCoordinator(sess, nil, ac, true) | |||
| 237 | + | ac.output.next, ac.output.acked = 4, 0 | |||
| 238 | + | token := sess.attachmentToken(ac, ac.transport()) | |||
| 239 | + | ||||
| 240 | + | admitted := make(chan struct{}) | |||
| 241 | + | var once sync.Once | |||
| 242 | + | d.afterRoleEffectAdmitted = func(attachmentRoleToken) { once.Do(func() { close(admitted) }) } | |||
| 243 | + | ac.sendMu.Lock() | |||
| 244 | + | done := make(chan bool, 1) | |||
| 245 | + | go func() { | |||
| 246 | + | done <- d.handleActiveClientFrame(token, ports.Frame{ | |||
| 247 | + | Type: ports.MsgOutputResetRequest, | |||
| 248 | + | Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), | |||
| 249 | + | }) | |||
| 250 | + | }() | |||
| 251 | + | awaitTestCompletion(t, admitted, "reset did not admit its role effect") | |||
| 252 | + | ac.replaceTransport(&closeTrackingTransport{}) | |||
| 253 | + | ac.sendMu.Unlock() | |||
| 254 | + | ||||
| 255 | + | require.False(t, awaitTestValue(t, done, "reset did not finish after send lock release")) | |||
| 256 | + | ac.sendMu.Lock() | |||
| 257 | + | require.Zero(t, ac.output.acked, "a stale transport must not rebase the output stream") | |||
| 258 | + | ac.sendMu.Unlock() | |||
| 259 | + | } | |||
M internal/usecase/daemon/render_capture.go +2 -1
| @@ -43,6 +43,7 @@ type capturedRenderState struct { | |||
| 43 | 43 | attachment *attachedClient // identity only; never dereferenced by composition | |
| 44 | 44 | lease *attachmentLease | |
| 45 | 45 | reset bool | |
| 46 | + | contentOnly bool | |
| 46 | 47 | layout capturedTabLayout | |
| 47 | 48 | panes []capturedPaneRenderState | |
| 48 | 49 | floating capturedFloatingRenderState | |
| @@ -257,7 +258,7 @@ func captureLocalPrimaryRenderState( | |||
| 257 | 258 | } | |
| 258 | 259 | state := &scratch.state | |
| 259 | 260 | *state = capturedRenderState{ | |
| 260 | - | attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme, | |
| 261 | + | attachment: ac, lease: lease, reset: reset, contentOnly: ac.proxied, bars: bars, theme: bars.theme, | |
| 261 | 262 | styles: request.styles, styleGeneration: request.styleGeneration, | |
| 262 | 263 | overlays: overlays, preview: preview, | |
| 263 | 264 | layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | |
| @@ -43,6 +43,7 @@ type capturedRenderState struct { | |||||
| 43 | attachment *attachedClient // identity only; never dereferenced by composition | 43 | attachment *attachedClient // identity only; never dereferenced by composition | ||
| 44 | lease *attachmentLease | 44 | lease *attachmentLease | ||
| 45 | reset bool | 45 | reset bool | ||
| 46 | + | contentOnly bool | |||
| 46 | layout capturedTabLayout | 47 | layout capturedTabLayout | ||
| 47 | panes []capturedPaneRenderState | 48 | panes []capturedPaneRenderState | ||
| 48 | floating capturedFloatingRenderState | 49 | floating capturedFloatingRenderState | ||
| @@ -257,7 +258,7 @@ func captureLocalPrimaryRenderState( | |||||
| 257 | } | 258 | } | ||
| 258 | state := &scratch.state | 259 | state := &scratch.state | ||
| 259 | *state = capturedRenderState{ | 260 | *state = capturedRenderState{ | ||
| 260 | - | attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme, | 261 | + | attachment: ac, lease: lease, reset: reset, contentOnly: ac.proxied, bars: bars, theme: bars.theme, |
| 261 | styles: request.styles, styleGeneration: request.styleGeneration, | 262 | styles: request.styles, styleGeneration: request.styleGeneration, | ||
| 262 | overlays: overlays, preview: preview, | 263 | overlays: overlays, preview: preview, | ||
| 263 | layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | 264 | layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | ||
M internal/usecase/daemon/render_pipeline.go +37 -17
| @@ -73,12 +73,18 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 73 | 73 | } | |
| 74 | 74 | // Compose into the alternate attachment-owned buffer. The committed frame is | |
| 75 | 75 | // copied before incremental drawing so prepare/send failures cannot change it. | |
| 76 | - | canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == rows+2 | |
| 76 | + | contentY := 1 | |
| 77 | + | frameHeight := rows + tabChromeRows | |
| 78 | + | if state.contentOnly { | |
| 79 | + | contentY = 0 | |
| 80 | + | frameHeight = rows | |
| 81 | + | } | |
| 82 | + | canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == frameHeight | |
| 77 | 83 | frame := scratch.frame | |
| 78 | 84 | if !canReuseFrame { | |
| 79 | - | frame = renderer.NewFrame(width, rows+2) | |
| 85 | + | frame = renderer.NewFrame(width, frameHeight) | |
| 80 | 86 | } | |
| 81 | - | if in.frame.Width == width && in.frame.Height == rows+2 { | |
| 87 | + | if in.frame.Width == width && in.frame.Height == frameHeight { | |
| 82 | 88 | for y := 0; y < frame.Height; y++ { | |
| 83 | 89 | copy(frame.Row(y), in.frame.Row(y)) | |
| 84 | 90 | } | |
| @@ -93,14 +99,16 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 93 | 99 | defaultDimmer := themeui.NewDimmer(state.theme) | |
| 94 | 100 | neutralBorder := styles.NeutralBorder | |
| 95 | 101 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | |
| 96 | - | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | |
| 97 | - | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | |
| 98 | - | content := domain.Rect{Y: 1, Width: width, Height: rows} | |
| 102 | + | if !state.contentOnly { | |
| 103 | + | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | |
| 104 | + | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | |
| 105 | + | } | |
| 106 | + | content := domain.Rect{Y: contentY, Width: width, Height: rows} | |
| 99 | 107 | if state.layout.valid { | |
| 100 | 108 | drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder)) | |
| 101 | 109 | } | |
| 102 | 110 | ||
| 103 | - | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible | |
| 111 | + | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != frameHeight || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible | |
| 104 | 112 | titles := scratch.titleGenerations | |
| 105 | 113 | if titles == nil { | |
| 106 | 114 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | |
| @@ -108,7 +116,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 108 | 116 | clear(titles) | |
| 109 | 117 | damage := scratch.damage[:0] | |
| 110 | 118 | for _, pane := range state.panes { | |
| 111 | - | pl := offsetPlacement(pane.placement, 0, 1) | |
| 119 | + | pl := offsetPlacement(pane.placement, 0, contentY) | |
| 112 | 120 | if pl.TitleBar.Height > 0 { | |
| 113 | 121 | drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer) | |
| 114 | 122 | if !full && in.titleGenerations[pane.id] != pane.titleGeneration { | |
| @@ -121,7 +129,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 121 | 129 | } | |
| 122 | 130 | for _, d := range pane.damage { | |
| 123 | 131 | if d.Kind != renderer.DamageFullRedraw { | |
| 124 | - | d.Y++ | |
| 132 | + | d.Y += contentY | |
| 125 | 133 | } | |
| 126 | 134 | damage = append(damage, d) | |
| 127 | 135 | } | |
| @@ -158,7 +166,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 158 | 166 | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | |
| 159 | 167 | frame, damage = composeCapturedOverlays(state, frame, damage) | |
| 160 | 168 | } | |
| 161 | - | if !full { | |
| 169 | + | if !state.contentOnly && !full { | |
| 162 | 170 | if !sameCells(in.bars.top, baseFrame.Row(0)) { | |
| 163 | 171 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | |
| 164 | 172 | } | |
| @@ -178,9 +186,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 178 | 186 | } | |
| 179 | 187 | cursorInputs := state.cursor | |
| 180 | 188 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | |
| 181 | - | cursor := desiredCapturedCursor(cursorInputs) | |
| 189 | + | cursor := desiredCapturedCursor(cursorInputs, contentY) | |
| 182 | 190 | outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | |
| 183 | - | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | |
| 191 | + | if !state.contentOnly { | |
| 192 | + | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | |
| 193 | + | } else { | |
| 194 | + | outCache.bars.Reset() | |
| 195 | + | } | |
| 184 | 196 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | |
| 185 | 197 | } | |
| 186 | 198 | ||
| @@ -195,7 +207,7 @@ func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title s | |||
| 195 | 207 | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | |
| 196 | 208 | } | |
| 197 | 209 | ||
| 198 | - | func desiredCapturedCursor(c capturedCursorInputs) cursorOut { | |
| 210 | + | func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | |
| 199 | 211 | if !c.renderable || c.hiddenByOverlay || !c.visible { | |
| 200 | 212 | return cursorOut{hidden: true} | |
| 201 | 213 | } | |
| @@ -203,7 +215,7 @@ func desiredCapturedCursor(c capturedCursorInputs) cursorOut { | |||
| 203 | 215 | if !c.hasStyle { | |
| 204 | 216 | style = 1 | |
| 205 | 217 | } | |
| 206 | - | return cursorOut{row: c.content.Y + 1 + c.row, col: c.content.X + c.col, style: style, hasStyle: true} | |
| 218 | + | return cursorOut{row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true} | |
| 207 | 219 | } | |
| 208 | 220 | ||
| 209 | 221 | func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) { | |
| @@ -222,7 +234,11 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 222 | 234 | if styles == (themeui.Styles{}) { | |
| 223 | 235 | styles = fallbackChromeStyles | |
| 224 | 236 | } | |
| 225 | - | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | |
| 237 | + | rows := state.layout.area.Height | |
| 238 | + | if !state.contentOnly { | |
| 239 | + | rows += tabChromeRows | |
| 240 | + | } | |
| 241 | + | size := domain.Size{Cols: state.layout.area.Width, Rows: rows} | |
| 226 | 242 | if snap.copySearchModel != nil { | |
| 227 | 243 | presentation := copySearchModal.Resolve(size) | |
| 228 | 244 | o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true} | |
| @@ -472,10 +488,14 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen | |||
| 472 | 488 | if len(data) > 0 { | |
| 473 | 489 | sendTransport := ac.transportSnapshot() | |
| 474 | 490 | sendTr = sendTransport.transport | |
| 491 | + | if ac.proxied { | |
| 492 | + | sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect) | |
| 493 | + | } | |
| 475 | 494 | endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data))) | |
| 476 | - | if sendTr == nil { | |
| 495 | + | if sendErr == nil && sendTr == nil { | |
| 477 | 496 | sendErr = errors.New("client transport is nil") | |
| 478 | - | } else { | |
| 497 | + | } | |
| 498 | + | if sendErr == nil { | |
| 479 | 499 | send := sendTr.Send | |
| 480 | 500 | if async, ok := sendTr.(ports.AsyncTransport); ok { | |
| 481 | 501 | send = async.SendAsync | |
| @@ -73,12 +73,18 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 73 | } | 73 | } | ||
| 74 | // Compose into the alternate attachment-owned buffer. The committed frame is | 74 | // Compose into the alternate attachment-owned buffer. The committed frame is | ||
| 75 | // copied before incremental drawing so prepare/send failures cannot change it. | 75 | // copied before incremental drawing so prepare/send failures cannot change it. | ||
| 76 | - | canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == rows+2 | 76 | + | contentY := 1 |
| 77 | + | frameHeight := rows + tabChromeRows | |||
| 78 | + | if state.contentOnly { | |||
| 79 | + | contentY = 0 | |||
| 80 | + | frameHeight = rows | |||
| 81 | + | } | |||
| 82 | + | canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == frameHeight | |||
| 77 | frame := scratch.frame | 83 | frame := scratch.frame | ||
| 78 | if !canReuseFrame { | 84 | if !canReuseFrame { | ||
| 79 | - | frame = renderer.NewFrame(width, rows+2) | 85 | + | frame = renderer.NewFrame(width, frameHeight) |
| 80 | } | 86 | } | ||
| 81 | - | if in.frame.Width == width && in.frame.Height == rows+2 { | 87 | + | if in.frame.Width == width && in.frame.Height == frameHeight { |
| 82 | for y := 0; y < frame.Height; y++ { | 88 | for y := 0; y < frame.Height; y++ { | ||
| 83 | copy(frame.Row(y), in.frame.Row(y)) | 89 | copy(frame.Row(y), in.frame.Row(y)) | ||
| 84 | } | 90 | } | ||
| @@ -93,14 +99,16 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 93 | defaultDimmer := themeui.NewDimmer(state.theme) | 99 | defaultDimmer := themeui.NewDimmer(state.theme) | ||
| 94 | neutralBorder := styles.NeutralBorder | 100 | neutralBorder := styles.NeutralBorder | ||
| 95 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | 101 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | ||
| 96 | - | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | 102 | + | if !state.contentOnly { |
| 97 | - | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | 103 | + | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) |
| 98 | - | content := domain.Rect{Y: 1, Width: width, Height: rows} | 104 | + | drawStatusBarState(frame.Row(rows+1), state.bars, styles) |
| 105 | + | } | |||
| 106 | + | content := domain.Rect{Y: contentY, Width: width, Height: rows} | |||
| 99 | if state.layout.valid { | 107 | if state.layout.valid { | ||
| 100 | drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder)) | 108 | drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder)) | ||
| 101 | } | 109 | } | ||
| 102 | 110 | ||||
| 103 | - | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible | 111 | + | full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != frameHeight || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible |
| 104 | titles := scratch.titleGenerations | 112 | titles := scratch.titleGenerations | ||
| 105 | if titles == nil { | 113 | if titles == nil { | ||
| 106 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | 114 | titles = make(map[layout.PaneID]uint64, len(state.panes)) | ||
| @@ -108,7 +116,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 108 | clear(titles) | 116 | clear(titles) | ||
| 109 | damage := scratch.damage[:0] | 117 | damage := scratch.damage[:0] | ||
| 110 | for _, pane := range state.panes { | 118 | for _, pane := range state.panes { | ||
| 111 | - | pl := offsetPlacement(pane.placement, 0, 1) | 119 | + | pl := offsetPlacement(pane.placement, 0, contentY) |
| 112 | if pl.TitleBar.Height > 0 { | 120 | if pl.TitleBar.Height > 0 { | ||
| 113 | drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer) | 121 | drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer) | ||
| 114 | if !full && in.titleGenerations[pane.id] != pane.titleGeneration { | 122 | if !full && in.titleGenerations[pane.id] != pane.titleGeneration { | ||
| @@ -121,7 +129,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 121 | } | 129 | } | ||
| 122 | for _, d := range pane.damage { | 130 | for _, d := range pane.damage { | ||
| 123 | if d.Kind != renderer.DamageFullRedraw { | 131 | if d.Kind != renderer.DamageFullRedraw { | ||
| 124 | - | d.Y++ | 132 | + | d.Y += contentY |
| 125 | } | 133 | } | ||
| 126 | damage = append(damage, d) | 134 | damage = append(damage, d) | ||
| 127 | } | 135 | } | ||
| @@ -158,7 +166,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 158 | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | 166 | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | ||
| 159 | frame, damage = composeCapturedOverlays(state, frame, damage) | 167 | frame, damage = composeCapturedOverlays(state, frame, damage) | ||
| 160 | } | 168 | } | ||
| 161 | - | if !full { | 169 | + | if !state.contentOnly && !full { |
| 162 | if !sameCells(in.bars.top, baseFrame.Row(0)) { | 170 | if !sameCells(in.bars.top, baseFrame.Row(0)) { | ||
| 163 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | 171 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | ||
| 164 | } | 172 | } | ||
| @@ -178,9 +186,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 178 | } | 186 | } | ||
| 179 | cursorInputs := state.cursor | 187 | cursorInputs := state.cursor | ||
| 180 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | 188 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | ||
| 181 | - | cursor := desiredCapturedCursor(cursorInputs) | 189 | + | cursor := desiredCapturedCursor(cursorInputs, contentY) |
| 182 | outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | 190 | outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | ||
| 183 | - | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | 191 | + | if !state.contentOnly { |
| 192 | + | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | |||
| 193 | + | } else { | |||
| 194 | + | outCache.bars.Reset() | |||
| 195 | + | } | |||
| 184 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | 196 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | ||
| 185 | } | 197 | } | ||
| 186 | 198 | ||||
| @@ -195,7 +207,7 @@ func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title s | |||||
| 195 | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | 207 | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | ||
| 196 | } | 208 | } | ||
| 197 | 209 | ||||
| 198 | - | func desiredCapturedCursor(c capturedCursorInputs) cursorOut { | 210 | + | func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { |
| 199 | if !c.renderable || c.hiddenByOverlay || !c.visible { | 211 | if !c.renderable || c.hiddenByOverlay || !c.visible { | ||
| 200 | return cursorOut{hidden: true} | 212 | return cursorOut{hidden: true} | ||
| 201 | } | 213 | } | ||
| @@ -203,7 +215,7 @@ func desiredCapturedCursor(c capturedCursorInputs) cursorOut { | |||||
| 203 | if !c.hasStyle { | 215 | if !c.hasStyle { | ||
| 204 | style = 1 | 216 | style = 1 | ||
| 205 | } | 217 | } | ||
| 206 | - | return cursorOut{row: c.content.Y + 1 + c.row, col: c.content.X + c.col, style: style, hasStyle: true} | 218 | + | return cursorOut{row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true} |
| 207 | } | 219 | } | ||
| 208 | 220 | ||||
| 209 | func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) { | 221 | func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) { | ||
| @@ -222,7 +234,11 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 222 | if styles == (themeui.Styles{}) { | 234 | if styles == (themeui.Styles{}) { | ||
| 223 | styles = fallbackChromeStyles | 235 | styles = fallbackChromeStyles | ||
| 224 | } | 236 | } | ||
| 225 | - | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | 237 | + | rows := state.layout.area.Height |
| 238 | + | if !state.contentOnly { | |||
| 239 | + | rows += tabChromeRows | |||
| 240 | + | } | |||
| 241 | + | size := domain.Size{Cols: state.layout.area.Width, Rows: rows} | |||
| 226 | if snap.copySearchModel != nil { | 242 | if snap.copySearchModel != nil { | ||
| 227 | presentation := copySearchModal.Resolve(size) | 243 | presentation := copySearchModal.Resolve(size) | ||
| 228 | o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true} | 244 | o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true} | ||
| @@ -472,10 +488,14 @@ func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRen | |||||
| 472 | if len(data) > 0 { | 488 | if len(data) > 0 { | ||
| 473 | sendTransport := ac.transportSnapshot() | 489 | sendTransport := ac.transportSnapshot() | ||
| 474 | sendTr = sendTransport.transport | 490 | sendTr = sendTransport.transport | ||
| 491 | + | if ac.proxied { | |||
| 492 | + | sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect) | |||
| 493 | + | } | |||
| 475 | endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data))) | 494 | endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data))) | ||
| 476 | - | if sendTr == nil { | 495 | + | if sendErr == nil && sendTr == nil { |
| 477 | sendErr = errors.New("client transport is nil") | 496 | sendErr = errors.New("client transport is nil") | ||
| 478 | - | } else { | 497 | + | } |
| 498 | + | if sendErr == nil { | |||
| 479 | send := sendTr.Send | 499 | send := sendTr.Send | ||
| 480 | if async, ok := sendTr.(ports.AsyncTransport); ok { | 500 | if async, ok := sendTr.(ports.AsyncTransport); ok { | ||
| 481 | send = async.SendAsync | 501 | send = async.SendAsync | ||
M internal/usecase/daemon/resume.go +15 -4
| @@ -384,6 +384,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||
| 384 | 384 | oldSnap transportSnapshot | |
| 385 | 385 | credentialMatch bool | |
| 386 | 386 | clientMismatch bool | |
| 387 | + | modeMismatch bool | |
| 387 | 388 | ) | |
| 388 | 389 | if sess != nil { | |
| 389 | 390 | sess.mu.Lock() | |
| @@ -394,6 +395,8 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||
| 394 | 395 | if ac != nil && ac.resumeCapable && ac.resumeToken == h.ResumeToken { | |
| 395 | 396 | if ac.clientID != h.ClientID { | |
| 396 | 397 | clientMismatch = true | |
| 398 | + | } else if ac.proxied != h.Proxied { | |
| 399 | + | modeMismatch = true | |
| 397 | 400 | } else { | |
| 398 | 401 | oldSnap = ac.transportSnapshot() | |
| 399 | 402 | credentialMatch = oldSnap.transport != nil | |
| @@ -408,8 +411,12 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||
| 408 | 411 | } | |
| 409 | 412 | d.mu.Unlock() | |
| 410 | 413 | ||
| 411 | - | if clientMismatch { | |
| 412 | - | d.log.Warn("resume rejected", "session", sess.name, "err", "client id mismatch") | |
| 414 | + | if clientMismatch || modeMismatch { | |
| 415 | + | reason := "client id mismatch" | |
| 416 | + | if modeMismatch { | |
| 417 | + | reason = "proxied mode mismatch" | |
| 418 | + | } | |
| 419 | + | d.log.Warn("resume rejected", "session", sess.name, "err", reason) | |
| 413 | 420 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 414 | 421 | } | |
| 415 | 422 | if !credentialMatch { | |
| @@ -513,8 +520,12 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 513 | 520 | parked.role = attachmentSnatched | |
| 514 | 521 | } | |
| 515 | 522 | ac := parked.ac | |
| 516 | - | if ac.clientID != h.ClientID { | |
| 517 | - | d.log.Warn("resume rejected", "session", sess.name, "err", "client id mismatch") | |
| 523 | + | if ac.clientID != h.ClientID || ac.proxied != h.Proxied { | |
| 524 | + | reason := "client id mismatch" | |
| 525 | + | if ac.proxied != h.Proxied { | |
| 526 | + | reason = "proxied mode mismatch" | |
| 527 | + | } | |
| 528 | + | d.log.Warn("resume rejected", "session", sess.name, "err", reason) | |
| 518 | 529 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 519 | 530 | } | |
| 520 | 531 | delete(d.parked, h.ResumeToken) | |
| @@ -384,6 +384,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||||
| 384 | oldSnap transportSnapshot | 384 | oldSnap transportSnapshot | ||
| 385 | credentialMatch bool | 385 | credentialMatch bool | ||
| 386 | clientMismatch bool | 386 | clientMismatch bool | ||
| 387 | + | modeMismatch bool | |||
| 387 | ) | 388 | ) | ||
| 388 | if sess != nil { | 389 | if sess != nil { | ||
| 389 | sess.mu.Lock() | 390 | sess.mu.Lock() | ||
| @@ -394,6 +395,8 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||||
| 394 | if ac != nil && ac.resumeCapable && ac.resumeToken == h.ResumeToken { | 395 | if ac != nil && ac.resumeCapable && ac.resumeToken == h.ResumeToken { | ||
| 395 | if ac.clientID != h.ClientID { | 396 | if ac.clientID != h.ClientID { | ||
| 396 | clientMismatch = true | 397 | clientMismatch = true | ||
| 398 | + | } else if ac.proxied != h.Proxied { | |||
| 399 | + | modeMismatch = true | |||
| 397 | } else { | 400 | } else { | ||
| 398 | oldSnap = ac.transportSnapshot() | 401 | oldSnap = ac.transportSnapshot() | ||
| 399 | credentialMatch = oldSnap.transport != nil | 402 | credentialMatch = oldSnap.transport != nil | ||
| @@ -408,8 +411,12 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||||
| 408 | } | 411 | } | ||
| 409 | d.mu.Unlock() | 412 | d.mu.Unlock() | ||
| 410 | 413 | ||||
| 411 | - | if clientMismatch { | 414 | + | if clientMismatch || modeMismatch { |
| 412 | - | d.log.Warn("resume rejected", "session", sess.name, "err", "client id mismatch") | 415 | + | reason := "client id mismatch" |
| 416 | + | if modeMismatch { | |||
| 417 | + | reason = "proxied mode mismatch" | |||
| 418 | + | } | |||
| 419 | + | d.log.Warn("resume rejected", "session", sess.name, "err", reason) | |||
| 413 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | 420 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | ||
| 414 | } | 421 | } | ||
| 415 | if !credentialMatch { | 422 | if !credentialMatch { | ||
| @@ -513,8 +520,12 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||||
| 513 | parked.role = attachmentSnatched | 520 | parked.role = attachmentSnatched | ||
| 514 | } | 521 | } | ||
| 515 | ac := parked.ac | 522 | ac := parked.ac | ||
| 516 | - | if ac.clientID != h.ClientID { | 523 | + | if ac.clientID != h.ClientID || ac.proxied != h.Proxied { |
| 517 | - | d.log.Warn("resume rejected", "session", sess.name, "err", "client id mismatch") | 524 | + | reason := "client id mismatch" |
| 525 | + | if ac.proxied != h.Proxied { | |||
| 526 | + | reason = "proxied mode mismatch" | |||
| 527 | + | } | |||
| 528 | + | d.log.Warn("resume rejected", "session", sess.name, "err", reason) | |||
| 518 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | 529 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | ||
| 519 | } | 530 | } | ||
| 520 | delete(d.parked, h.ResumeToken) | 531 | delete(d.parked, h.ResumeToken) | ||
M internal/usecase/daemon/session.go +18 -4
| @@ -178,6 +178,10 @@ func (d *Daemon) touchMRU(sess *session) { | |||
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | 180 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) { | |
| 181 | + | return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, term, env, false, restoredTabNames...) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, proxied bool, restoredTabNames ...[]string) (*session, error) { | |
| 181 | 185 | env = copyEnvironment(env) | |
| 182 | 186 | if _, reserved := d.creating[name]; reserved { | |
| 183 | 187 | return nil, errSessionNameInUse | |
| @@ -231,7 +235,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 231 | 235 | } | |
| 232 | 236 | } | |
| 233 | 237 | ||
| 234 | - | tbSize := tabSize(sz) | |
| 238 | + | tbSize := contentSize(sz, proxied) | |
| 235 | 239 | var names []string | |
| 236 | 240 | if len(restoredTabNames) > 0 { | |
| 237 | 241 | names = append([]string(nil), restoredTabNames[0]...) | |
| @@ -412,7 +416,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 412 | 416 | } | |
| 413 | 417 | from.mu.Unlock() | |
| 414 | 418 | ||
| 415 | - | newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env) | |
| 419 | + | newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied) | |
| 416 | 420 | d.mu.Unlock() | |
| 417 | 421 | if err != nil { | |
| 418 | 422 | return err | |
| @@ -478,7 +482,7 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s | |||
| 478 | 482 | cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env) | |
| 479 | 483 | source.mu.Unlock() | |
| 480 | 484 | var createErr error | |
| 481 | - | created, createErr = d.createSessionLocked(name, false, cwd, token.ac.size, term, env) | |
| 485 | + | created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.size, term, env, token.ac.proxied) | |
| 482 | 486 | return created, createErr | |
| 483 | 487 | }, | |
| 484 | 488 | }) | |
| @@ -500,7 +504,6 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s | |||
| 500 | 504 | } | |
| 501 | 505 | ||
| 502 | 506 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |
| 503 | - | tbSize := tabSize(sz) | |
| 504 | 507 | sess.mu.Lock() | |
| 505 | 508 | name := sess.name | |
| 506 | 509 | cwd := sess.cwd | |
| @@ -508,6 +511,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 508 | 511 | term := sess.terminal | |
| 509 | 512 | env := copyEnvironment(sess.env) | |
| 510 | 513 | sess.mu.Unlock() | |
| 514 | + | tbSize := contentSize(sz, client != nil && client.proxied) | |
| 511 | 515 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | |
| 512 | 516 | if err != nil { | |
| 513 | 517 | return err | |
| @@ -627,9 +631,19 @@ func (tb *tab) closeAllPanes() { | |||
| 627 | 631 | const tabChromeRows = 2 | |
| 628 | 632 | ||
| 629 | 633 | func tabSize(clientSize domain.Size) domain.Size { | |
| 634 | + | return contentSize(clientSize, false) | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | // contentSize maps the negotiated viewport to PTY geometry. A proxied client | |
| 638 | + | // has already removed its local chrome rows, so the remote daemon must consume | |
| 639 | + | // the received size verbatim instead of subtracting chrome a second time. | |
| 640 | + | func contentSize(clientSize domain.Size, proxied bool) domain.Size { | |
| 630 | 641 | if !clientSize.Valid() { | |
| 631 | 642 | clientSize = defaultSize | |
| 632 | 643 | } | |
| 644 | + | if proxied { | |
| 645 | + | return clientSize | |
| 646 | + | } | |
| 633 | 647 | rows := max(clientSize.Rows-tabChromeRows, 1) | |
| 634 | 648 | return domain.Size{Cols: clientSize.Cols, Rows: rows} | |
| 635 | 649 | } | |
| @@ -178,6 +178,10 @@ func (d *Daemon) touchMRU(sess *session) { | |||||
| 178 | } | 178 | } | ||
| 179 | 179 | ||||
| 180 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) { | 180 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) { | ||
| 181 | + | return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, term, env, false, restoredTabNames...) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, proxied bool, restoredTabNames ...[]string) (*session, error) { | |||
| 181 | env = copyEnvironment(env) | 185 | env = copyEnvironment(env) | ||
| 182 | if _, reserved := d.creating[name]; reserved { | 186 | if _, reserved := d.creating[name]; reserved { | ||
| 183 | return nil, errSessionNameInUse | 187 | return nil, errSessionNameInUse | ||
| @@ -231,7 +235,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 231 | } | 235 | } | ||
| 232 | } | 236 | } | ||
| 233 | 237 | ||||
| 234 | - | tbSize := tabSize(sz) | 238 | + | tbSize := contentSize(sz, proxied) |
| 235 | var names []string | 239 | var names []string | ||
| 236 | if len(restoredTabNames) > 0 { | 240 | if len(restoredTabNames) > 0 { | ||
| 237 | names = append([]string(nil), restoredTabNames[0]...) | 241 | names = append([]string(nil), restoredTabNames[0]...) | ||
| @@ -412,7 +416,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||||
| 412 | } | 416 | } | ||
| 413 | from.mu.Unlock() | 417 | from.mu.Unlock() | ||
| 414 | 418 | ||||
| 415 | - | newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env) | 419 | + | newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied) |
| 416 | d.mu.Unlock() | 420 | d.mu.Unlock() | ||
| 417 | if err != nil { | 421 | if err != nil { | ||
| 418 | return err | 422 | return err | ||
| @@ -478,7 +482,7 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s | |||||
| 478 | cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env) | 482 | cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env) | ||
| 479 | source.mu.Unlock() | 483 | source.mu.Unlock() | ||
| 480 | var createErr error | 484 | var createErr error | ||
| 481 | - | created, createErr = d.createSessionLocked(name, false, cwd, token.ac.size, term, env) | 485 | + | created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.size, term, env, token.ac.proxied) |
| 482 | return created, createErr | 486 | return created, createErr | ||
| 483 | }, | 487 | }, | ||
| 484 | }) | 488 | }) | ||
| @@ -500,7 +504,6 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s | |||||
| 500 | } | 504 | } | ||
| 501 | 505 | ||||
| 502 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | 506 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | ||
| 503 | - | tbSize := tabSize(sz) | |||
| 504 | sess.mu.Lock() | 507 | sess.mu.Lock() | ||
| 505 | name := sess.name | 508 | name := sess.name | ||
| 506 | cwd := sess.cwd | 509 | cwd := sess.cwd | ||
| @@ -508,6 +511,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 508 | term := sess.terminal | 511 | term := sess.terminal | ||
| 509 | env := copyEnvironment(sess.env) | 512 | env := copyEnvironment(sess.env) | ||
| 510 | sess.mu.Unlock() | 513 | sess.mu.Unlock() | ||
| 514 | + | tbSize := contentSize(sz, client != nil && client.proxied) | |||
| 511 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | 515 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | ||
| 512 | if err != nil { | 516 | if err != nil { | ||
| 513 | return err | 517 | return err | ||
| @@ -627,9 +631,19 @@ func (tb *tab) closeAllPanes() { | |||||
| 627 | const tabChromeRows = 2 | 631 | const tabChromeRows = 2 | ||
| 628 | 632 | ||||
| 629 | func tabSize(clientSize domain.Size) domain.Size { | 633 | func tabSize(clientSize domain.Size) domain.Size { | ||
| 634 | + | return contentSize(clientSize, false) | |||
| 635 | + | } | |||
| 636 | + | ||||
| 637 | + | // contentSize maps the negotiated viewport to PTY geometry. A proxied client | |||
| 638 | + | // has already removed its local chrome rows, so the remote daemon must consume | |||
| 639 | + | // the received size verbatim instead of subtracting chrome a second time. | |||
| 640 | + | func contentSize(clientSize domain.Size, proxied bool) domain.Size { | |||
| 630 | if !clientSize.Valid() { | 641 | if !clientSize.Valid() { | ||
| 631 | clientSize = defaultSize | 642 | clientSize = defaultSize | ||
| 632 | } | 643 | } | ||
| 644 | + | if proxied { | |||
| 645 | + | return clientSize | |||
| 646 | + | } | |||
| 633 | rows := max(clientSize.Rows-tabChromeRows, 1) | 647 | rows := max(clientSize.Rows-tabChromeRows, 1) | ||
| 634 | return domain.Size{Cols: clientSize.Cols, Rows: rows} | 648 | return domain.Size{Cols: clientSize.Cols, Rows: rows} | ||
| 635 | } | 649 | } | ||
A internal/usecase/daemon/session_meta.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // sessionMetaSnapshot captures the authoritative tab order while session.mu | |
| 10 | + | // owns every field represented on the wire. | |
| 11 | + | func (s *session) sessionMetaSnapshot() (ports.SessionMeta, bool) { | |
| 12 | + | if s == nil { | |
| 13 | + | return ports.SessionMeta{}, false | |
| 14 | + | } | |
| 15 | + | s.mu.Lock() | |
| 16 | + | defer s.mu.Unlock() | |
| 17 | + | if len(s.tabs) == 0 || s.active < 0 || s.active >= len(s.tabs) { | |
| 18 | + | return ports.SessionMeta{}, false | |
| 19 | + | } | |
| 20 | + | meta := ports.SessionMeta{ | |
| 21 | + | SessionName: s.name, | |
| 22 | + | Active: uint16(s.active), | |
| 23 | + | Tabs: make([]ports.SessionTabMeta, len(s.tabs)), | |
| 24 | + | } | |
| 25 | + | for i, tb := range s.tabs { | |
| 26 | + | meta.Tabs[i] = ports.SessionTabMeta{ | |
| 27 | + | Index: uint16(i), | |
| 28 | + | Name: tb.name, | |
| 29 | + | Attention: tb.attention, | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | return meta, true | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func frameSessionMeta(meta ports.SessionMeta) (ports.Frame, error) { | |
| 36 | + | payload, err := ports.MarshalSessionMeta(meta) | |
| 37 | + | if err != nil { | |
| 38 | + | return ports.Frame{}, err | |
| 39 | + | } | |
| 40 | + | return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}, nil | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func sameSessionMeta(a, b ports.SessionMeta) bool { | |
| 44 | + | if a.SessionName != b.SessionName || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) { | |
| 45 | + | return false | |
| 46 | + | } | |
| 47 | + | for i := range a.Tabs { | |
| 48 | + | if a.Tabs[i] != b.Tabs[i] { | |
| 49 | + | return false | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | return true | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta { | |
| 56 | + | meta.Tabs = append([]ports.SessionTabMeta(nil), meta.Tabs...) | |
| 57 | + | return meta | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // sendSessionMetaIfChanged sends metadata before a proxied output frame. The | |
| 61 | + | // caller holds ac.sendMu, which serializes this snapshot shadow with every | |
| 62 | + | // output write. The shadow is committed only after the transport accepts the | |
| 63 | + | // metadata frame. | |
| 64 | + | func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected transportSnapshot, ticket *roleEffectTicket) error { | |
| 65 | + | if ac == nil || sess == nil || !ac.proxied { | |
| 66 | + | return nil | |
| 67 | + | } | |
| 68 | + | meta, ok := sess.sessionMetaSnapshot() | |
| 69 | + | if !ok { | |
| 70 | + | return errors.New("session metadata is unavailable") | |
| 71 | + | } | |
| 72 | + | if ac.sessionMetaSent && sameSessionMeta(ac.sessionMeta, meta) { | |
| 73 | + | return nil | |
| 74 | + | } | |
| 75 | + | frame, err := frameSessionMeta(meta) | |
| 76 | + | if err != nil { | |
| 77 | + | return err | |
| 78 | + | } | |
| 79 | + | if expected.transport == nil || !ac.transportSnapshotCurrent(expected) { | |
| 80 | + | return errTransportReplaced | |
| 81 | + | } | |
| 82 | + | if ticket != nil && (ticket.ended.Load() || !ticket.beginTransportSend(expected)) { | |
| 83 | + | return errAttachmentTransition | |
| 84 | + | } | |
| 85 | + | send := expected.transport.Send | |
| 86 | + | if async, ok := expected.transport.(ports.AsyncTransport); ok { | |
| 87 | + | send = async.SendAsync | |
| 88 | + | } | |
| 89 | + | err = send(frame) | |
| 90 | + | if ticket != nil { | |
| 91 | + | if err != nil { | |
| 92 | + | ticket.reportTransportFailure(expected) | |
| 93 | + | } | |
| 94 | + | ticket.endTransportSend() | |
| 95 | + | } | |
| 96 | + | if err == nil { | |
| 97 | + | ac.sessionMeta = cloneSessionMeta(meta) | |
| 98 | + | ac.sessionMetaSent = true | |
| 99 | + | } | |
| 100 | + | return err | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // markSessionMetaSent records a successfully sent snapshot. It acquires | |
| 104 | + | // ac.sendMu itself, so callers must not hold that lock. | |
| 105 | + | func (ac *attachedClient) markSessionMetaSent(meta ports.SessionMeta) { | |
| 106 | + | if ac == nil { | |
| 107 | + | return | |
| 108 | + | } | |
| 109 | + | ac.sendMu.Lock() | |
| 110 | + | ac.sessionMeta = cloneSessionMeta(meta) | |
| 111 | + | ac.sessionMetaSent = true | |
| 112 | + | ac.sendMu.Unlock() | |
| 113 | + | } | |
| @@ -0,0 +1,113 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // sessionMetaSnapshot captures the authoritative tab order while session.mu | |||
| 10 | + | // owns every field represented on the wire. | |||
| 11 | + | func (s *session) sessionMetaSnapshot() (ports.SessionMeta, bool) { | |||
| 12 | + | if s == nil { | |||
| 13 | + | return ports.SessionMeta{}, false | |||
| 14 | + | } | |||
| 15 | + | s.mu.Lock() | |||
| 16 | + | defer s.mu.Unlock() | |||
| 17 | + | if len(s.tabs) == 0 || s.active < 0 || s.active >= len(s.tabs) { | |||
| 18 | + | return ports.SessionMeta{}, false | |||
| 19 | + | } | |||
| 20 | + | meta := ports.SessionMeta{ | |||
| 21 | + | SessionName: s.name, | |||
| 22 | + | Active: uint16(s.active), | |||
| 23 | + | Tabs: make([]ports.SessionTabMeta, len(s.tabs)), | |||
| 24 | + | } | |||
| 25 | + | for i, tb := range s.tabs { | |||
| 26 | + | meta.Tabs[i] = ports.SessionTabMeta{ | |||
| 27 | + | Index: uint16(i), | |||
| 28 | + | Name: tb.name, | |||
| 29 | + | Attention: tb.attention, | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | return meta, true | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func frameSessionMeta(meta ports.SessionMeta) (ports.Frame, error) { | |||
| 36 | + | payload, err := ports.MarshalSessionMeta(meta) | |||
| 37 | + | if err != nil { | |||
| 38 | + | return ports.Frame{}, err | |||
| 39 | + | } | |||
| 40 | + | return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}, nil | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func sameSessionMeta(a, b ports.SessionMeta) bool { | |||
| 44 | + | if a.SessionName != b.SessionName || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) { | |||
| 45 | + | return false | |||
| 46 | + | } | |||
| 47 | + | for i := range a.Tabs { | |||
| 48 | + | if a.Tabs[i] != b.Tabs[i] { | |||
| 49 | + | return false | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | return true | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta { | |||
| 56 | + | meta.Tabs = append([]ports.SessionTabMeta(nil), meta.Tabs...) | |||
| 57 | + | return meta | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // sendSessionMetaIfChanged sends metadata before a proxied output frame. The | |||
| 61 | + | // caller holds ac.sendMu, which serializes this snapshot shadow with every | |||
| 62 | + | // output write. The shadow is committed only after the transport accepts the | |||
| 63 | + | // metadata frame. | |||
| 64 | + | func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected transportSnapshot, ticket *roleEffectTicket) error { | |||
| 65 | + | if ac == nil || sess == nil || !ac.proxied { | |||
| 66 | + | return nil | |||
| 67 | + | } | |||
| 68 | + | meta, ok := sess.sessionMetaSnapshot() | |||
| 69 | + | if !ok { | |||
| 70 | + | return errors.New("session metadata is unavailable") | |||
| 71 | + | } | |||
| 72 | + | if ac.sessionMetaSent && sameSessionMeta(ac.sessionMeta, meta) { | |||
| 73 | + | return nil | |||
| 74 | + | } | |||
| 75 | + | frame, err := frameSessionMeta(meta) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return err | |||
| 78 | + | } | |||
| 79 | + | if expected.transport == nil || !ac.transportSnapshotCurrent(expected) { | |||
| 80 | + | return errTransportReplaced | |||
| 81 | + | } | |||
| 82 | + | if ticket != nil && (ticket.ended.Load() || !ticket.beginTransportSend(expected)) { | |||
| 83 | + | return errAttachmentTransition | |||
| 84 | + | } | |||
| 85 | + | send := expected.transport.Send | |||
| 86 | + | if async, ok := expected.transport.(ports.AsyncTransport); ok { | |||
| 87 | + | send = async.SendAsync | |||
| 88 | + | } | |||
| 89 | + | err = send(frame) | |||
| 90 | + | if ticket != nil { | |||
| 91 | + | if err != nil { | |||
| 92 | + | ticket.reportTransportFailure(expected) | |||
| 93 | + | } | |||
| 94 | + | ticket.endTransportSend() | |||
| 95 | + | } | |||
| 96 | + | if err == nil { | |||
| 97 | + | ac.sessionMeta = cloneSessionMeta(meta) | |||
| 98 | + | ac.sessionMetaSent = true | |||
| 99 | + | } | |||
| 100 | + | return err | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // markSessionMetaSent records a successfully sent snapshot. It acquires | |||
| 104 | + | // ac.sendMu itself, so callers must not hold that lock. | |||
| 105 | + | func (ac *attachedClient) markSessionMetaSent(meta ports.SessionMeta) { | |||
| 106 | + | if ac == nil { | |||
| 107 | + | return | |||
| 108 | + | } | |||
| 109 | + | ac.sendMu.Lock() | |||
| 110 | + | ac.sessionMeta = cloneSessionMeta(meta) | |||
| 111 | + | ac.sessionMetaSent = true | |||
| 112 | + | ac.sendMu.Unlock() | |||
| 113 | + | } | |||
A internal/usecase/daemon/session_meta_test.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "io" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type sessionMetaFailTransport struct{} | |
| 13 | + | ||
| 14 | + | func (sessionMetaFailTransport) Send(ports.Frame) error { return errors.New("metadata send failed") } | |
| 15 | + | func (sessionMetaFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 16 | + | func (sessionMetaFailTransport) Close() error { return nil } | |
| 17 | + | ||
| 18 | + | func TestProxiedMetadataPrecedesChangedOutput(t *testing.T) { | |
| 19 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 20 | + | ac.proxied = true | |
| 21 | + | ||
| 22 | + | emit := func(title string) { | |
| 23 | + | state := cacheBarState(title, true) | |
| 24 | + | state.attachment = ac | |
| 25 | + | composed := composeFrame(state, ac.pipelineCache, ac.pipelineScratch) | |
| 26 | + | ac.sendMu.Lock() | |
| 27 | + | require.True(t, d.emitFrame(sess, ac, &state, composed)) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | emit("initial") | |
| 31 | + | firstMetaFrame := <-sends | |
| 32 | + | firstOutputFrame := <-sends | |
| 33 | + | require.Equal(t, ports.MsgSessionMeta, firstMetaFrame.Type) | |
| 34 | + | require.Equal(t, ports.MsgOutput, firstOutputFrame.Type) | |
| 35 | + | firstMeta, err := ports.UnmarshalSessionMeta(firstMetaFrame.Payload) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | require.Equal(t, "work", firstMeta.SessionName) | |
| 38 | + | ||
| 39 | + | sess.mu.Lock() | |
| 40 | + | sess.tabs[0].name = "renamed" | |
| 41 | + | sess.mu.Unlock() | |
| 42 | + | emit("changed") | |
| 43 | + | changedMetaFrame := <-sends | |
| 44 | + | changedOutputFrame := <-sends | |
| 45 | + | require.Equal(t, ports.MsgSessionMeta, changedMetaFrame.Type) | |
| 46 | + | require.Equal(t, ports.MsgOutput, changedOutputFrame.Type) | |
| 47 | + | changedMeta, err := ports.UnmarshalSessionMeta(changedMetaFrame.Payload) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | require.Equal(t, "renamed", changedMeta.Tabs[0].Name) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestSessionMetaSnapshotIsImmutable(t *testing.T) { | |
| 53 | + | _, sess, _, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 54 | + | meta, ok := sess.sessionMetaSnapshot() | |
| 55 | + | require.True(t, ok) | |
| 56 | + | ||
| 57 | + | sess.mu.Lock() | |
| 58 | + | sess.name = "renamed-session" | |
| 59 | + | sess.tabs[0].name = "renamed-tab" | |
| 60 | + | sess.mu.Unlock() | |
| 61 | + | ||
| 62 | + | require.Equal(t, "work", meta.SessionName) | |
| 63 | + | require.Equal(t, "", meta.Tabs[0].Name) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestSessionMetaShadowUpdatesOnlyAfterSuccessfulSend(t *testing.T) { | |
| 67 | + | _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 68 | + | ac.proxied = true | |
| 69 | + | ac.replaceTransport(sessionMetaFailTransport{}) | |
| 70 | + | ||
| 71 | + | ac.sendMu.Lock() | |
| 72 | + | err := ac.sendSessionMetaIfChanged(sess, ac.transportSnapshot(), nil) | |
| 73 | + | ac.sendMu.Unlock() | |
| 74 | + | require.Error(t, err) | |
| 75 | + | require.False(t, ac.sessionMetaSent) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestProxiedAttachedCommandRejectionPreservesRequestID(t *testing.T) { | |
| 79 | + | tests := []struct { | |
| 80 | + | name string | |
| 81 | + | request ports.CommandRequest | |
| 82 | + | }{ | |
| 83 | + | { | |
| 84 | + | name: "valid attached command", | |
| 85 | + | request: ports.CommandRequest{ | |
| 86 | + | Version: ports.ProtocolVersion, RequestID: 41, Attached: true, Slug: "new-tab", | |
| 87 | + | }, | |
| 88 | + | }, | |
| 89 | + | { | |
| 90 | + | name: "target override is rejected", | |
| 91 | + | request: ports.CommandRequest{ | |
| 92 | + | Version: ports.ProtocolVersion, RequestID: 42, Attached: true, Slug: "new-tab", TargetSession: "other", | |
| 93 | + | }, | |
| 94 | + | }, | |
| 95 | + | { | |
| 96 | + | name: "missing attached flag is rejected", | |
| 97 | + | request: ports.CommandRequest{ | |
| 98 | + | Version: ports.ProtocolVersion, RequestID: 43, Slug: "new-tab", | |
| 99 | + | }, | |
| 100 | + | }, | |
| 101 | + | } | |
| 102 | + | for _, tt := range tests { | |
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |
| 104 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 105 | + | ac.proxied = true | |
| 106 | + | d.attachCoordinator(sess, nil, ac, true) | |
| 107 | + | payload, err := ports.MarshalCommandRequest(tt.request) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | ||
| 110 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 111 | + | require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})) | |
| 112 | + | ||
| 113 | + | reply := <-sends | |
| 114 | + | require.Equal(t, ports.MsgCommandResult, reply.Type) | |
| 115 | + | result, err := ports.UnmarshalCommandResult(reply.Payload) | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | require.Equal(t, tt.request.RequestID, result.RequestID) | |
| 118 | + | require.False(t, result.OK) | |
| 119 | + | require.Equal(t, ports.ErrNotScriptable, result.Code) | |
| 120 | + | require.Equal(t, "attached command relay is not enabled", result.Text) | |
| 121 | + | }) | |
| 122 | + | } | |
| 123 | + | } | |
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "io" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type sessionMetaFailTransport struct{} | |||
| 13 | + | ||||
| 14 | + | func (sessionMetaFailTransport) Send(ports.Frame) error { return errors.New("metadata send failed") } | |||
| 15 | + | func (sessionMetaFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |||
| 16 | + | func (sessionMetaFailTransport) Close() error { return nil } | |||
| 17 | + | ||||
| 18 | + | func TestProxiedMetadataPrecedesChangedOutput(t *testing.T) { | |||
| 19 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 20 | + | ac.proxied = true | |||
| 21 | + | ||||
| 22 | + | emit := func(title string) { | |||
| 23 | + | state := cacheBarState(title, true) | |||
| 24 | + | state.attachment = ac | |||
| 25 | + | composed := composeFrame(state, ac.pipelineCache, ac.pipelineScratch) | |||
| 26 | + | ac.sendMu.Lock() | |||
| 27 | + | require.True(t, d.emitFrame(sess, ac, &state, composed)) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | emit("initial") | |||
| 31 | + | firstMetaFrame := <-sends | |||
| 32 | + | firstOutputFrame := <-sends | |||
| 33 | + | require.Equal(t, ports.MsgSessionMeta, firstMetaFrame.Type) | |||
| 34 | + | require.Equal(t, ports.MsgOutput, firstOutputFrame.Type) | |||
| 35 | + | firstMeta, err := ports.UnmarshalSessionMeta(firstMetaFrame.Payload) | |||
| 36 | + | require.NoError(t, err) | |||
| 37 | + | require.Equal(t, "work", firstMeta.SessionName) | |||
| 38 | + | ||||
| 39 | + | sess.mu.Lock() | |||
| 40 | + | sess.tabs[0].name = "renamed" | |||
| 41 | + | sess.mu.Unlock() | |||
| 42 | + | emit("changed") | |||
| 43 | + | changedMetaFrame := <-sends | |||
| 44 | + | changedOutputFrame := <-sends | |||
| 45 | + | require.Equal(t, ports.MsgSessionMeta, changedMetaFrame.Type) | |||
| 46 | + | require.Equal(t, ports.MsgOutput, changedOutputFrame.Type) | |||
| 47 | + | changedMeta, err := ports.UnmarshalSessionMeta(changedMetaFrame.Payload) | |||
| 48 | + | require.NoError(t, err) | |||
| 49 | + | require.Equal(t, "renamed", changedMeta.Tabs[0].Name) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestSessionMetaSnapshotIsImmutable(t *testing.T) { | |||
| 53 | + | _, sess, _, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 54 | + | meta, ok := sess.sessionMetaSnapshot() | |||
| 55 | + | require.True(t, ok) | |||
| 56 | + | ||||
| 57 | + | sess.mu.Lock() | |||
| 58 | + | sess.name = "renamed-session" | |||
| 59 | + | sess.tabs[0].name = "renamed-tab" | |||
| 60 | + | sess.mu.Unlock() | |||
| 61 | + | ||||
| 62 | + | require.Equal(t, "work", meta.SessionName) | |||
| 63 | + | require.Equal(t, "", meta.Tabs[0].Name) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestSessionMetaShadowUpdatesOnlyAfterSuccessfulSend(t *testing.T) { | |||
| 67 | + | _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 68 | + | ac.proxied = true | |||
| 69 | + | ac.replaceTransport(sessionMetaFailTransport{}) | |||
| 70 | + | ||||
| 71 | + | ac.sendMu.Lock() | |||
| 72 | + | err := ac.sendSessionMetaIfChanged(sess, ac.transportSnapshot(), nil) | |||
| 73 | + | ac.sendMu.Unlock() | |||
| 74 | + | require.Error(t, err) | |||
| 75 | + | require.False(t, ac.sessionMetaSent) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestProxiedAttachedCommandRejectionPreservesRequestID(t *testing.T) { | |||
| 79 | + | tests := []struct { | |||
| 80 | + | name string | |||
| 81 | + | request ports.CommandRequest | |||
| 82 | + | }{ | |||
| 83 | + | { | |||
| 84 | + | name: "valid attached command", | |||
| 85 | + | request: ports.CommandRequest{ | |||
| 86 | + | Version: ports.ProtocolVersion, RequestID: 41, Attached: true, Slug: "new-tab", | |||
| 87 | + | }, | |||
| 88 | + | }, | |||
| 89 | + | { | |||
| 90 | + | name: "target override is rejected", | |||
| 91 | + | request: ports.CommandRequest{ | |||
| 92 | + | Version: ports.ProtocolVersion, RequestID: 42, Attached: true, Slug: "new-tab", TargetSession: "other", | |||
| 93 | + | }, | |||
| 94 | + | }, | |||
| 95 | + | { | |||
| 96 | + | name: "missing attached flag is rejected", | |||
| 97 | + | request: ports.CommandRequest{ | |||
| 98 | + | Version: ports.ProtocolVersion, RequestID: 43, Slug: "new-tab", | |||
| 99 | + | }, | |||
| 100 | + | }, | |||
| 101 | + | } | |||
| 102 | + | for _, tt := range tests { | |||
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 104 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 105 | + | ac.proxied = true | |||
| 106 | + | d.attachCoordinator(sess, nil, ac, true) | |||
| 107 | + | payload, err := ports.MarshalCommandRequest(tt.request) | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | ||||
| 110 | + | token := sess.attachmentToken(ac, ac.transport()) | |||
| 111 | + | require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})) | |||
| 112 | + | ||||
| 113 | + | reply := <-sends | |||
| 114 | + | require.Equal(t, ports.MsgCommandResult, reply.Type) | |||
| 115 | + | result, err := ports.UnmarshalCommandResult(reply.Payload) | |||
| 116 | + | require.NoError(t, err) | |||
| 117 | + | require.Equal(t, tt.request.RequestID, result.RequestID) | |||
| 118 | + | require.False(t, result.OK) | |||
| 119 | + | require.Equal(t, ports.ErrNotScriptable, result.Code) | |||
| 120 | + | require.Equal(t, "attached command relay is not enabled", result.Text) | |||
| 121 | + | }) | |||
| 122 | + | } | |||
| 123 | + | } | |||
M internal/usecase/daemon/transactional_resize.go +6 -2
| @@ -538,13 +538,17 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) { | |||
| 538 | 538 | // coordinator admits the epoch before any tab size, rectangle, VT screen, | |
| 539 | 539 | // snapshot dirtiness, or resize telemetry becomes visible. | |
| 540 | 540 | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) { | |
| 541 | + | return d.applySessionLayoutWithMode(sess, size, false, current, admit) | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, proxied bool, current, admit func() bool) ([]resizeMember, bool) { | |
| 541 | 545 | if sess == nil { | |
| 542 | 546 | return nil, false | |
| 543 | 547 | } | |
| 544 | 548 | sess.layoutApplyMu.Lock() | |
| 545 | 549 | defer sess.layoutApplyMu.Unlock() | |
| 546 | 550 | ||
| 547 | - | target := tabSize(size) | |
| 551 | + | target := contentSize(size, proxied) | |
| 548 | 552 | for { | |
| 549 | 553 | if sess.ctx != nil && sess.ctx.Err() != nil { | |
| 550 | 554 | return nil, false | |
| @@ -661,7 +665,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||
| 661 | 665 | return false | |
| 662 | 666 | } | |
| 663 | 667 | d.exitCopyMode(ac) | |
| 664 | - | failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool { | |
| 668 | + | failed, ok := d.applySessionLayoutWithMode(sess, snap.size, ac.proxied, current, func() bool { | |
| 665 | 669 | return rc.resizeCurrentForLease(epoch, ac, lease, true) | |
| 666 | 670 | }) | |
| 667 | 671 | if !ok { | |
| @@ -538,13 +538,17 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) { | |||||
| 538 | // coordinator admits the epoch before any tab size, rectangle, VT screen, | 538 | // coordinator admits the epoch before any tab size, rectangle, VT screen, | ||
| 539 | // snapshot dirtiness, or resize telemetry becomes visible. | 539 | // snapshot dirtiness, or resize telemetry becomes visible. | ||
| 540 | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) { | 540 | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) { | ||
| 541 | + | return d.applySessionLayoutWithMode(sess, size, false, current, admit) | |||
| 542 | + | } | |||
| 543 | + | ||||
| 544 | + | func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, proxied bool, current, admit func() bool) ([]resizeMember, bool) { | |||
| 541 | if sess == nil { | 545 | if sess == nil { | ||
| 542 | return nil, false | 546 | return nil, false | ||
| 543 | } | 547 | } | ||
| 544 | sess.layoutApplyMu.Lock() | 548 | sess.layoutApplyMu.Lock() | ||
| 545 | defer sess.layoutApplyMu.Unlock() | 549 | defer sess.layoutApplyMu.Unlock() | ||
| 546 | 550 | ||||
| 547 | - | target := tabSize(size) | 551 | + | target := contentSize(size, proxied) |
| 548 | for { | 552 | for { | ||
| 549 | if sess.ctx != nil && sess.ctx.Err() != nil { | 553 | if sess.ctx != nil && sess.ctx.Err() != nil { | ||
| 550 | return nil, false | 554 | return nil, false | ||
| @@ -661,7 +665,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||||
| 661 | return false | 665 | return false | ||
| 662 | } | 666 | } | ||
| 663 | d.exitCopyMode(ac) | 667 | d.exitCopyMode(ac) | ||
| 664 | - | failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool { | 668 | + | failed, ok := d.applySessionLayoutWithMode(sess, snap.size, ac.proxied, current, func() bool { |
| 665 | return rc.resizeCurrentForLease(epoch, ac, lease, true) | 669 | return rc.resizeCurrentForLease(epoch, ac, lease, true) | ||
| 666 | }) | 670 | }) | ||
| 667 | if !ok { | 671 | if !ok { | ||
M internal/usecase/daemon/wire.go +5 -1
| @@ -6,11 +6,15 @@ import ( | |||
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | func frameWelcome(s *session, ac *attachedClient) ports.Frame { | |
| 9 | + | capabilities := ports.CapabilityResume | |
| 10 | + | if ac.proxied { | |
| 11 | + | capabilities |= ports.CapabilityProxied | |
| 12 | + | } | |
| 9 | 13 | w := ports.Welcome{ | |
| 10 | 14 | SessionID: string(s.id), | |
| 11 | 15 | SessionName: s.name, | |
| 12 | 16 | Ephemeral: s.ephemeral, | |
| 13 | - | Capabilities: ports.CapabilityResume, | |
| 17 | + | Capabilities: capabilities, | |
| 14 | 18 | ResumeToken: ac.resumeToken, | |
| 15 | 19 | } | |
| 16 | 20 | return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)} | |
| @@ -6,11 +6,15 @@ import ( | |||||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | func frameWelcome(s *session, ac *attachedClient) ports.Frame { | 8 | func frameWelcome(s *session, ac *attachedClient) ports.Frame { | ||
| 9 | + | capabilities := ports.CapabilityResume | |||
| 10 | + | if ac.proxied { | |||
| 11 | + | capabilities |= ports.CapabilityProxied | |||
| 12 | + | } | |||
| 9 | w := ports.Welcome{ | 13 | w := ports.Welcome{ | ||
| 10 | SessionID: string(s.id), | 14 | SessionID: string(s.id), | ||
| 11 | SessionName: s.name, | 15 | SessionName: s.name, | ||
| 12 | Ephemeral: s.ephemeral, | 16 | Ephemeral: s.ephemeral, | ||
| 13 | - | Capabilities: ports.CapabilityResume, | 17 | + | Capabilities: capabilities, |
| 14 | ResumeToken: ac.resumeToken, | 18 | ResumeToken: ac.resumeToken, | ||
| 15 | } | 19 | } | ||
| 16 | return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)} | 20 | return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)} | ||