feat(wire): route client notices and displacement reasons
- commit
- 29b1a9254aa71268ed59834ef4f2681f66600b07
- author
- brice · · 2026-07-21 08:20 UTC
- parent
- ebf4652
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
17 files · +296 -212
M internal/domain/notice.go +4 -0
| @@ -32,7 +32,9 @@ const ( | |||
| 32 | 32 | NoticeInputDropped | |
| 33 | 33 | NoticeResizeFailed | |
| 34 | 34 | NoticeClipboard | |
| 35 | + | NoticeClipboardTooLarge | |
| 35 | 36 | NoticeAutoResume | |
| 37 | + | NoticeConnection | |
| 36 | 38 | ) | |
| 37 | 39 | ||
| 38 | 40 | var noticeSlugs = map[NoticeCode]string{ | |
| @@ -53,7 +55,9 @@ var noticeSlugs = map[NoticeCode]string{ | |||
| 53 | 55 | NoticeInputDropped: "input-dropped", | |
| 54 | 56 | NoticeResizeFailed: "resize-failed", | |
| 55 | 57 | NoticeClipboard: "clipboard", | |
| 58 | + | NoticeClipboardTooLarge: "clipboard-too-large", | |
| 56 | 59 | NoticeAutoResume: "auto-resume", | |
| 60 | + | NoticeConnection: "connection", | |
| 57 | 61 | } | |
| 58 | 62 | ||
| 59 | 63 | func (c NoticeCode) String() string { | |
| @@ -32,7 +32,9 @@ const ( | |||||
| 32 | NoticeInputDropped | 32 | NoticeInputDropped | ||
| 33 | NoticeResizeFailed | 33 | NoticeResizeFailed | ||
| 34 | NoticeClipboard | 34 | NoticeClipboard | ||
| 35 | + | NoticeClipboardTooLarge | |||
| 35 | NoticeAutoResume | 36 | NoticeAutoResume | ||
| 37 | + | NoticeConnection | |||
| 36 | ) | 38 | ) | ||
| 37 | 39 | ||||
| 38 | var noticeSlugs = map[NoticeCode]string{ | 40 | var noticeSlugs = map[NoticeCode]string{ | ||
| @@ -53,7 +55,9 @@ var noticeSlugs = map[NoticeCode]string{ | |||||
| 53 | NoticeInputDropped: "input-dropped", | 55 | NoticeInputDropped: "input-dropped", | ||
| 54 | NoticeResizeFailed: "resize-failed", | 56 | NoticeResizeFailed: "resize-failed", | ||
| 55 | NoticeClipboard: "clipboard", | 57 | NoticeClipboard: "clipboard", | ||
| 58 | + | NoticeClipboardTooLarge: "clipboard-too-large", | |||
| 56 | NoticeAutoResume: "auto-resume", | 59 | NoticeAutoResume: "auto-resume", | ||
| 60 | + | NoticeConnection: "connection", | |||
| 57 | } | 61 | } | ||
| 58 | 62 | ||||
| 59 | func (c NoticeCode) String() string { | 63 | func (c NoticeCode) String() string { | ||
M internal/ports/frame.go +12 -11
| @@ -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 = 16 | |
| 4 | + | const ProtocolVersion uint16 = 17 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -10,16 +10,17 @@ 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 | |
| 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 | |
| 23 | 24 | ||
| 24 | 25 | MsgWelcome MsgType = 16 | |
| 25 | 26 | MsgError MsgType = 17 | |
| @@ -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 = 16 | 4 | + | const ProtocolVersion uint16 = 17 |
| 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,16 +10,17 @@ 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 | 24 | ||||
| 24 | MsgWelcome MsgType = 16 | 25 | MsgWelcome MsgType = 16 | ||
| 25 | MsgError MsgType = 17 | 26 | MsgError MsgType = 17 | ||
M internal/ports/frame_test.go +3 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 16 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 16", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 17 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 17", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -23,6 +23,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 23 | 23 | {"MsgTheme", MsgTheme}, | |
| 24 | 24 | {"MsgAck", MsgAck}, | |
| 25 | 25 | {"MsgImagePush", MsgImagePush}, | |
| 26 | + | {"MsgClientNotice", MsgClientNotice}, | |
| 26 | 27 | {"MsgWelcome", MsgWelcome}, | |
| 27 | 28 | {"MsgError", MsgError}, | |
| 28 | 29 | {"MsgOutput", MsgOutput}, | |
| @@ -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 != 16 { | 6 | + | if ProtocolVersion != 17 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 16", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 17", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| @@ -23,6 +23,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 23 | {"MsgTheme", MsgTheme}, | 23 | {"MsgTheme", MsgTheme}, | ||
| 24 | {"MsgAck", MsgAck}, | 24 | {"MsgAck", MsgAck}, | ||
| 25 | {"MsgImagePush", MsgImagePush}, | 25 | {"MsgImagePush", MsgImagePush}, | ||
| 26 | + | {"MsgClientNotice", MsgClientNotice}, | |||
| 26 | {"MsgWelcome", MsgWelcome}, | 27 | {"MsgWelcome", MsgWelcome}, | ||
| 27 | {"MsgError", MsgError}, | 28 | {"MsgError", MsgError}, | ||
| 28 | {"MsgOutput", MsgOutput}, | 29 | {"MsgOutput", MsgOutput}, | ||
M internal/ports/wire.go +46 -0
| @@ -53,6 +53,7 @@ const ( | |||
| 53 | 53 | ReasonDetach uint8 = 0 | |
| 54 | 54 | ReasonSessionKilled uint8 = 1 | |
| 55 | 55 | ReasonServerShutdown uint8 = 2 | |
| 56 | + | ReasonReplaced uint8 = 3 | |
| 56 | 57 | ) | |
| 57 | 58 | ||
| 58 | 59 | // Hello is sent by the client immediately after connecting. | |
| @@ -108,6 +109,29 @@ type ImagePush struct { | |||
| 108 | 109 | Data []byte | |
| 109 | 110 | } | |
| 110 | 111 | ||
| 112 | + | // ClientNotice reports a fixed client-side event for daemon-rendered user | |
| 113 | + | // feedback. Action values are deliberately closed so a client cannot inject | |
| 114 | + | // arbitrary display text into a shared session. | |
| 115 | + | type ClientNotice struct { | |
| 116 | + | Action uint8 | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | const ( | |
| 120 | + | ClientNoticeClipboardFallback uint8 = 1 | |
| 121 | + | ClientNoticeClipboardTooLarge uint8 = 2 | |
| 122 | + | ClientNoticeLinkDegraded uint8 = 3 | |
| 123 | + | ClientNoticeLinkConnected uint8 = 4 | |
| 124 | + | ) | |
| 125 | + | ||
| 126 | + | func validClientNoticeAction(action uint8) bool { | |
| 127 | + | switch action { | |
| 128 | + | case ClientNoticeClipboardFallback, ClientNoticeClipboardTooLarge, ClientNoticeLinkDegraded, ClientNoticeLinkConnected: | |
| 129 | + | return true | |
| 130 | + | default: | |
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 111 | 135 | // Detach asks the daemon to detach the current client without killing the | |
| 112 | 136 | // session. | |
| 113 | 137 | type Detach struct{} | |
| @@ -458,6 +482,28 @@ func UnmarshalImagePush(b []byte) (ImagePush, error) { | |||
| 458 | 482 | return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil | |
| 459 | 483 | } | |
| 460 | 484 | ||
| 485 | + | // MarshalClientNotice encodes a fixed one-byte client notice action. | |
| 486 | + | func MarshalClientNotice(m ClientNotice) []byte { | |
| 487 | + | return []byte{m.Action} | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | // UnmarshalClientNotice decodes a fixed client notice action and rejects both | |
| 491 | + | // unknown values and any trailing bytes. | |
| 492 | + | func UnmarshalClientNotice(b []byte) (ClientNotice, error) { | |
| 493 | + | r := payloadReader{b: b} | |
| 494 | + | action, err := r.getUint8() | |
| 495 | + | if err != nil { | |
| 496 | + | return ClientNotice{}, err | |
| 497 | + | } | |
| 498 | + | if !validClientNoticeAction(action) { | |
| 499 | + | return ClientNotice{}, errors.New("ports: unknown client notice action") | |
| 500 | + | } | |
| 501 | + | if err := r.done(); err != nil { | |
| 502 | + | return ClientNotice{}, err | |
| 503 | + | } | |
| 504 | + | return ClientNotice{Action: action}, nil | |
| 505 | + | } | |
| 506 | + | ||
| 461 | 507 | // MarshalResize encodes m into a Resize message payload. | |
| 462 | 508 | func MarshalResize(m Resize) []byte { | |
| 463 | 509 | w := payloadWriter{} | |
| @@ -53,6 +53,7 @@ const ( | |||||
| 53 | ReasonDetach uint8 = 0 | 53 | ReasonDetach uint8 = 0 | ||
| 54 | ReasonSessionKilled uint8 = 1 | 54 | ReasonSessionKilled uint8 = 1 | ||
| 55 | ReasonServerShutdown uint8 = 2 | 55 | ReasonServerShutdown uint8 = 2 | ||
| 56 | + | ReasonReplaced uint8 = 3 | |||
| 56 | ) | 57 | ) | ||
| 57 | 58 | ||||
| 58 | // Hello is sent by the client immediately after connecting. | 59 | // Hello is sent by the client immediately after connecting. | ||
| @@ -108,6 +109,29 @@ type ImagePush struct { | |||||
| 108 | Data []byte | 109 | Data []byte | ||
| 109 | } | 110 | } | ||
| 110 | 111 | ||||
| 112 | + | // ClientNotice reports a fixed client-side event for daemon-rendered user | |||
| 113 | + | // feedback. Action values are deliberately closed so a client cannot inject | |||
| 114 | + | // arbitrary display text into a shared session. | |||
| 115 | + | type ClientNotice struct { | |||
| 116 | + | Action uint8 | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | const ( | |||
| 120 | + | ClientNoticeClipboardFallback uint8 = 1 | |||
| 121 | + | ClientNoticeClipboardTooLarge uint8 = 2 | |||
| 122 | + | ClientNoticeLinkDegraded uint8 = 3 | |||
| 123 | + | ClientNoticeLinkConnected uint8 = 4 | |||
| 124 | + | ) | |||
| 125 | + | ||||
| 126 | + | func validClientNoticeAction(action uint8) bool { | |||
| 127 | + | switch action { | |||
| 128 | + | case ClientNoticeClipboardFallback, ClientNoticeClipboardTooLarge, ClientNoticeLinkDegraded, ClientNoticeLinkConnected: | |||
| 129 | + | return true | |||
| 130 | + | default: | |||
| 131 | + | return false | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 111 | // Detach asks the daemon to detach the current client without killing the | 135 | // Detach asks the daemon to detach the current client without killing the | ||
| 112 | // session. | 136 | // session. | ||
| 113 | type Detach struct{} | 137 | type Detach struct{} | ||
| @@ -458,6 +482,28 @@ func UnmarshalImagePush(b []byte) (ImagePush, error) { | |||||
| 458 | return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil | 482 | return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil | ||
| 459 | } | 483 | } | ||
| 460 | 484 | ||||
| 485 | + | // MarshalClientNotice encodes a fixed one-byte client notice action. | |||
| 486 | + | func MarshalClientNotice(m ClientNotice) []byte { | |||
| 487 | + | return []byte{m.Action} | |||
| 488 | + | } | |||
| 489 | + | ||||
| 490 | + | // UnmarshalClientNotice decodes a fixed client notice action and rejects both | |||
| 491 | + | // unknown values and any trailing bytes. | |||
| 492 | + | func UnmarshalClientNotice(b []byte) (ClientNotice, error) { | |||
| 493 | + | r := payloadReader{b: b} | |||
| 494 | + | action, err := r.getUint8() | |||
| 495 | + | if err != nil { | |||
| 496 | + | return ClientNotice{}, err | |||
| 497 | + | } | |||
| 498 | + | if !validClientNoticeAction(action) { | |||
| 499 | + | return ClientNotice{}, errors.New("ports: unknown client notice action") | |||
| 500 | + | } | |||
| 501 | + | if err := r.done(); err != nil { | |||
| 502 | + | return ClientNotice{}, err | |||
| 503 | + | } | |||
| 504 | + | return ClientNotice{Action: action}, nil | |||
| 505 | + | } | |||
| 506 | + | ||||
| 461 | // MarshalResize encodes m into a Resize message payload. | 507 | // MarshalResize encodes m into a Resize message payload. | ||
| 462 | func MarshalResize(m Resize) []byte { | 508 | func MarshalResize(m Resize) []byte { | ||
| 463 | w := payloadWriter{} | 509 | w := payloadWriter{} | ||
M internal/ports/wire_test.go +32 -5
| @@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 121 | 121 | Env: []string{"A=B", "XY=123"}, | |
| 122 | 122 | }) | |
| 123 | 123 | want := []byte{ | |
| 124 | - | 0x00, 0x10, 0x00, // version, intent | |
| 124 | + | 0x00, 0x11, 0x00, // version, intent | |
| 125 | 125 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 126 | 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 127 | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -335,7 +335,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 335 | 335 | } | |
| 336 | 336 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 337 | 337 | require.Equal(t, want, MarshalTheme(cleared)) | |
| 338 | - | require.Equal(t, uint16(16), ProtocolVersion) | |
| 338 | + | require.Equal(t, uint16(17), ProtocolVersion) | |
| 339 | 339 | } | |
| 340 | 340 | ||
| 341 | 341 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -502,8 +502,8 @@ func TestAckGoldenAndRoundTrip(t *testing.T) { | |||
| 502 | 502 | } | |
| 503 | 503 | ||
| 504 | 504 | func TestDetachedGoldenAndRoundTrip(t *testing.T) { | |
| 505 | - | msg := Detached{Reason: ReasonSessionKilled} | |
| 506 | - | want := []byte{0x01} | |
| 505 | + | msg := Detached{Reason: ReasonReplaced} | |
| 506 | + | want := []byte{0x03} | |
| 507 | 507 | ||
| 508 | 508 | got := MarshalDetached(msg) | |
| 509 | 509 | if !bytes.Equal(got, want) { | |
| @@ -521,6 +521,32 @@ func TestDetachedGoldenAndRoundTrip(t *testing.T) { | |||
| 521 | 521 | assertTrailingGarbageFails(t, got, UnmarshalDetached) | |
| 522 | 522 | } | |
| 523 | 523 | ||
| 524 | + | func TestClientNoticeGoldenAndStrict(t *testing.T) { | |
| 525 | + | tests := []struct { | |
| 526 | + | name string | |
| 527 | + | msg ClientNotice | |
| 528 | + | want []byte | |
| 529 | + | }{ | |
| 530 | + | {name: "clipboard fallback", msg: ClientNotice{Action: ClientNoticeClipboardFallback}, want: []byte{0x01}}, | |
| 531 | + | {name: "clipboard too large", msg: ClientNotice{Action: ClientNoticeClipboardTooLarge}, want: []byte{0x02}}, | |
| 532 | + | {name: "link degraded", msg: ClientNotice{Action: ClientNoticeLinkDegraded}, want: []byte{0x03}}, | |
| 533 | + | {name: "link connected", msg: ClientNotice{Action: ClientNoticeLinkConnected}, want: []byte{0x04}}, | |
| 534 | + | } | |
| 535 | + | for _, tt := range tests { | |
| 536 | + | t.Run(tt.name, func(t *testing.T) { | |
| 537 | + | got := MarshalClientNotice(tt.msg) | |
| 538 | + | require.Equal(t, tt.want, got) | |
| 539 | + | back, err := UnmarshalClientNotice(got) | |
| 540 | + | require.NoError(t, err) | |
| 541 | + | require.Equal(t, tt.msg, back) | |
| 542 | + | assertAllPrefixesFail(t, got, UnmarshalClientNotice) | |
| 543 | + | assertTrailingGarbageFails(t, got, UnmarshalClientNotice) | |
| 544 | + | }) | |
| 545 | + | } | |
| 546 | + | _, err := UnmarshalClientNotice([]byte{0xff}) | |
| 547 | + | require.Error(t, err, "unknown actions must not become display text") | |
| 548 | + | } | |
| 549 | + | ||
| 524 | 550 | func TestListGoldenAndRoundTrip(t *testing.T) { | |
| 525 | 551 | got := MarshalList(List{}) | |
| 526 | 552 | if len(got) != 0 { | |
| @@ -661,8 +687,9 @@ func TestMsgTypeConstantsDistinct(t *testing.T) { | |||
| 661 | 687 | ReasonDetach: "ReasonDetach", | |
| 662 | 688 | ReasonSessionKilled: "ReasonSessionKilled", | |
| 663 | 689 | ReasonServerShutdown: "ReasonServerShutdown", | |
| 690 | + | ReasonReplaced: "ReasonReplaced", | |
| 664 | 691 | } | |
| 665 | - | if len(vals) != 3 { | |
| 692 | + | if len(vals) != 4 { | |
| 666 | 693 | t.Fatalf("expected 3 distinct detached reasons, got %d", len(vals)) | |
| 667 | 694 | } | |
| 668 | 695 | }) | |
| @@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 121 | Env: []string{"A=B", "XY=123"}, | 121 | Env: []string{"A=B", "XY=123"}, | ||
| 122 | }) | 122 | }) | ||
| 123 | want := []byte{ | 123 | want := []byte{ | ||
| 124 | - | 0x00, 0x10, 0x00, // version, intent | 124 | + | 0x00, 0x11, 0x00, // version, intent |
| 125 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 125 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -335,7 +335,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 335 | } | 335 | } | ||
| 336 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 336 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 337 | require.Equal(t, want, MarshalTheme(cleared)) | 337 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 338 | - | require.Equal(t, uint16(16), ProtocolVersion) | 338 | + | require.Equal(t, uint16(17), ProtocolVersion) |
| 339 | } | 339 | } | ||
| 340 | 340 | ||||
| 341 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 341 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -502,8 +502,8 @@ func TestAckGoldenAndRoundTrip(t *testing.T) { | |||||
| 502 | } | 502 | } | ||
| 503 | 503 | ||||
| 504 | func TestDetachedGoldenAndRoundTrip(t *testing.T) { | 504 | func TestDetachedGoldenAndRoundTrip(t *testing.T) { | ||
| 505 | - | msg := Detached{Reason: ReasonSessionKilled} | 505 | + | msg := Detached{Reason: ReasonReplaced} |
| 506 | - | want := []byte{0x01} | 506 | + | want := []byte{0x03} |
| 507 | 507 | ||||
| 508 | got := MarshalDetached(msg) | 508 | got := MarshalDetached(msg) | ||
| 509 | if !bytes.Equal(got, want) { | 509 | if !bytes.Equal(got, want) { | ||
| @@ -521,6 +521,32 @@ func TestDetachedGoldenAndRoundTrip(t *testing.T) { | |||||
| 521 | assertTrailingGarbageFails(t, got, UnmarshalDetached) | 521 | assertTrailingGarbageFails(t, got, UnmarshalDetached) | ||
| 522 | } | 522 | } | ||
| 523 | 523 | ||||
| 524 | + | func TestClientNoticeGoldenAndStrict(t *testing.T) { | |||
| 525 | + | tests := []struct { | |||
| 526 | + | name string | |||
| 527 | + | msg ClientNotice | |||
| 528 | + | want []byte | |||
| 529 | + | }{ | |||
| 530 | + | {name: "clipboard fallback", msg: ClientNotice{Action: ClientNoticeClipboardFallback}, want: []byte{0x01}}, | |||
| 531 | + | {name: "clipboard too large", msg: ClientNotice{Action: ClientNoticeClipboardTooLarge}, want: []byte{0x02}}, | |||
| 532 | + | {name: "link degraded", msg: ClientNotice{Action: ClientNoticeLinkDegraded}, want: []byte{0x03}}, | |||
| 533 | + | {name: "link connected", msg: ClientNotice{Action: ClientNoticeLinkConnected}, want: []byte{0x04}}, | |||
| 534 | + | } | |||
| 535 | + | for _, tt := range tests { | |||
| 536 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 537 | + | got := MarshalClientNotice(tt.msg) | |||
| 538 | + | require.Equal(t, tt.want, got) | |||
| 539 | + | back, err := UnmarshalClientNotice(got) | |||
| 540 | + | require.NoError(t, err) | |||
| 541 | + | require.Equal(t, tt.msg, back) | |||
| 542 | + | assertAllPrefixesFail(t, got, UnmarshalClientNotice) | |||
| 543 | + | assertTrailingGarbageFails(t, got, UnmarshalClientNotice) | |||
| 544 | + | }) | |||
| 545 | + | } | |||
| 546 | + | _, err := UnmarshalClientNotice([]byte{0xff}) | |||
| 547 | + | require.Error(t, err, "unknown actions must not become display text") | |||
| 548 | + | } | |||
| 549 | + | ||||
| 524 | func TestListGoldenAndRoundTrip(t *testing.T) { | 550 | func TestListGoldenAndRoundTrip(t *testing.T) { | ||
| 525 | got := MarshalList(List{}) | 551 | got := MarshalList(List{}) | ||
| 526 | if len(got) != 0 { | 552 | if len(got) != 0 { | ||
| @@ -661,8 +687,9 @@ func TestMsgTypeConstantsDistinct(t *testing.T) { | |||||
| 661 | ReasonDetach: "ReasonDetach", | 687 | ReasonDetach: "ReasonDetach", | ||
| 662 | ReasonSessionKilled: "ReasonSessionKilled", | 688 | ReasonSessionKilled: "ReasonSessionKilled", | ||
| 663 | ReasonServerShutdown: "ReasonServerShutdown", | 689 | ReasonServerShutdown: "ReasonServerShutdown", | ||
| 690 | + | ReasonReplaced: "ReasonReplaced", | |||
| 664 | } | 691 | } | ||
| 665 | - | if len(vals) != 3 { | 692 | + | if len(vals) != 4 { |
| 666 | t.Fatalf("expected 3 distinct detached reasons, got %d", len(vals)) | 693 | t.Fatalf("expected 3 distinct detached reasons, got %d", len(vals)) | ||
| 667 | } | 694 | } | ||
| 668 | }) | 695 | }) | ||
M internal/usecase/client/client.go +15 -56
| @@ -650,9 +650,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 650 | 650 | defer revokeInputClaim() | |
| 651 | 651 | ||
| 652 | 652 | sendCh := make(chan ports.Frame, sendQueueDepth) | |
| 653 | - | // Client-local clipboard failures are published from stdinPump and rendered | |
| 654 | - | // only here, the attach loop's sole terminal writer. | |
| 655 | - | clientToasts := make(chan string, 1) | |
| 656 | 653 | ackQueue := newCumulativeAckQueue() | |
| 657 | 654 | sendErrCh := make(chan error, 1) | |
| 658 | 655 | senderStarted := false | |
| @@ -765,7 +762,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 765 | 762 | stdinDone := make(chan struct{}) | |
| 766 | 763 | go func() { | |
| 767 | 764 | defer close(stdinDone) | |
| 768 | - | (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, clientToasts: clientToasts}).run() | |
| 765 | + | (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run() | |
| 769 | 766 | }() | |
| 770 | 767 | // Scanner cancellation can leave an undecided marker suffix (including a | |
| 771 | 768 | // standalone Escape) that it must hand back to the lifecycle-owned reader. | |
| @@ -783,8 +780,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 783 | 780 | recvCh := make(chan recvResult, 1) | |
| 784 | 781 | go runRecv(loopCtx, transport, recvCh, log) | |
| 785 | 782 | ||
| 786 | - | var transientToast clientToastUI | |
| 787 | - | awaitingToastResync := false | |
| 788 | 783 | for { | |
| 789 | 784 | select { | |
| 790 | 785 | case <-loopCtx.Done(): | |
| @@ -796,25 +791,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 796 | 791 | default: | |
| 797 | 792 | return welcomedResult(nil) | |
| 798 | 793 | } | |
| 799 | - | case message := <-clientToasts: | |
| 800 | - | size, serr := term.Size() | |
| 801 | - | if serr != nil { | |
| 802 | - | log.Warn("reading terminal size for client toast", "err", serr) | |
| 803 | - | continue | |
| 804 | - | } | |
| 805 | - | if !transientToast.draw(term, size, message) || awaitingToastResync { | |
| 806 | - | continue | |
| 807 | - | } | |
| 808 | - | // MsgResize is the existing reset-producing primitive. Sending the | |
| 809 | - | // current size preserves wire compatibility and causes the daemon to | |
| 810 | - | // publish an authoritative full frame; until then incremental frames | |
| 811 | - | // are acknowledged but not written over this local overlay. | |
| 812 | - | select { | |
| 813 | - | case sendCh <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: size})}: | |
| 814 | - | awaitingToastResync = true | |
| 815 | - | case <-loopCtx.Done(): | |
| 816 | - | return welcomedResult(nil) | |
| 817 | - | } | |
| 818 | 794 | case ev, ok := <-linkEvents: | |
| 819 | 795 | if !ok { | |
| 820 | 796 | linkEvents = nil | |
| @@ -822,11 +798,20 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 822 | 798 | } | |
| 823 | 799 | if ev.State == ports.LinkStateConnected { | |
| 824 | 800 | reconnect.clear() | |
| 801 | + | select { | |
| 802 | + | case sendCh <- ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkConnected})}: | |
| 803 | + | case <-loopCtx.Done(): | |
| 804 | + | return welcomedResult(nil) | |
| 805 | + | } | |
| 825 | 806 | continue | |
| 826 | 807 | } | |
| 827 | 808 | if ev.State == ports.LinkStateDegraded { | |
| 828 | 809 | log.Warn("UDP link degraded") | |
| 829 | - | reconnect.drawStage(reconnectStageDegraded) | |
| 810 | + | select { | |
| 811 | + | case sendCh <- ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded})}: | |
| 812 | + | case <-loopCtx.Done(): | |
| 813 | + | return welcomedResult(nil) | |
| 814 | + | } | |
| 830 | 815 | continue | |
| 831 | 816 | } | |
| 832 | 817 | reconnect.drawStage(stageForLinkState(ev.State)) | |
| @@ -865,26 +850,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 865 | 850 | if derr != nil { | |
| 866 | 851 | return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr)) | |
| 867 | 852 | } | |
| 868 | - | // A local toast obscures terminal cells unknown to an incremental | |
| 869 | - | // daemon diff. A same-size MsgResize above produces this reset | |
| 870 | - | // frame (BaseStateNum=0); acknowledge any intervening state without | |
| 871 | - | // writing it, then apply only the authoritative full frame. | |
| 872 | - | if awaitingToastResync && !isClientToastResyncFrame(o) { | |
| 873 | - | if o.NewStateNum != 0 { | |
| 874 | - | ackQueue.offer(o.NewStateNum) | |
| 875 | - | } | |
| 876 | - | continue | |
| 877 | - | } | |
| 878 | 853 | if _, werr := term.Out().Write(o.Data); werr != nil { | |
| 879 | 854 | return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr)) | |
| 880 | 855 | } | |
| 881 | 856 | if ferr := term.Flush(); ferr != nil { | |
| 882 | 857 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | |
| 883 | 858 | } | |
| 884 | - | if awaitingToastResync { | |
| 885 | - | transientToast.reconcile() | |
| 886 | - | awaitingToastResync = false | |
| 887 | - | } | |
| 888 | 859 | // The terminal boundary is after a successful flush and before ACK. | |
| 889 | 860 | if observer != nil { | |
| 890 | 861 | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true)) | |
| @@ -920,13 +891,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 920 | 891 | ||
| 921 | 892 | // recvResult carries one framed message (or a read error) from the receive | |
| 922 | 893 | // pump to the main loop. | |
| 923 | - | // isClientToastResyncFrame identifies the authoritative reset emitted by the | |
| 924 | - | // daemon's existing resize path. State-less side effects also have base zero, | |
| 925 | - | // so they must not dismiss an overlay. | |
| 926 | - | func isClientToastResyncFrame(o ports.Output) bool { | |
| 927 | - | return o.BaseStateNum == 0 && o.NewStateNum != 0 | |
| 928 | - | } | |
| 929 | - | ||
| 930 | 894 | type recvResult struct { | |
| 931 | 895 | frame ports.Frame | |
| 932 | 896 | err error | |
| @@ -1349,7 +1313,6 @@ type stdinPump struct { | |||
| 1349 | 1313 | logger *slog.Logger | |
| 1350 | 1314 | paletteEvents chan<- paletteGenerationEvent | |
| 1351 | 1315 | activeGeneration *atomic.Uint64 | |
| 1352 | - | clientToasts chan<- string | |
| 1353 | 1316 | afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook | |
| 1354 | 1317 | afterInputTake func() // test synchronization hook | |
| 1355 | 1318 | afterInputDelivered func() // test synchronization hook | |
| @@ -1412,14 +1375,8 @@ func (p *stdinPump) run() { | |||
| 1412 | 1375 | coalescer: coalescer, | |
| 1413 | 1376 | reader: p.clipboard, | |
| 1414 | 1377 | log: p.logger, | |
| 1415 | - | showToast: func(message string) { | |
| 1416 | - | if p.clientToasts == nil { | |
| 1417 | - | return | |
| 1418 | - | } | |
| 1419 | - | select { | |
| 1420 | - | case p.clientToasts <- message: | |
| 1421 | - | case <-p.ctx.Done(): | |
| 1422 | - | } | |
| 1378 | + | sendNotice: func(action uint8) { | |
| 1379 | + | send(ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: action})}) | |
| 1423 | 1380 | }, | |
| 1424 | 1381 | sendImage: func(mime string, data []byte) { | |
| 1425 | 1382 | inputSeq++ | |
| @@ -1617,6 +1574,8 @@ func detachedResult(reason uint8) error { | |||
| 1617 | 1574 | return &DetachedError{Reason: reason, Text: "session was killed"} | |
| 1618 | 1575 | case ports.ReasonServerShutdown: | |
| 1619 | 1576 | return &DetachedError{Reason: reason, Text: "daemon shut down"} | |
| 1577 | + | case ports.ReasonReplaced: | |
| 1578 | + | return &DetachedError{Reason: reason, Text: "session taken over by another client"} | |
| 1620 | 1579 | default: | |
| 1621 | 1580 | return &DetachedError{Reason: reason, Text: "detached by daemon"} | |
| 1622 | 1581 | } | |
| @@ -650,9 +650,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 650 | defer revokeInputClaim() | 650 | defer revokeInputClaim() | ||
| 651 | 651 | ||||
| 652 | sendCh := make(chan ports.Frame, sendQueueDepth) | 652 | sendCh := make(chan ports.Frame, sendQueueDepth) | ||
| 653 | - | // Client-local clipboard failures are published from stdinPump and rendered | |||
| 654 | - | // only here, the attach loop's sole terminal writer. | |||
| 655 | - | clientToasts := make(chan string, 1) | |||
| 656 | ackQueue := newCumulativeAckQueue() | 653 | ackQueue := newCumulativeAckQueue() | ||
| 657 | sendErrCh := make(chan error, 1) | 654 | sendErrCh := make(chan error, 1) | ||
| 658 | senderStarted := false | 655 | senderStarted := false | ||
| @@ -765,7 +762,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 765 | stdinDone := make(chan struct{}) | 762 | stdinDone := make(chan struct{}) | ||
| 766 | go func() { | 763 | go func() { | ||
| 767 | defer close(stdinDone) | 764 | defer close(stdinDone) | ||
| 768 | - | (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration, clientToasts: clientToasts}).run() | 765 | + | (&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run() |
| 769 | }() | 766 | }() | ||
| 770 | // Scanner cancellation can leave an undecided marker suffix (including a | 767 | // Scanner cancellation can leave an undecided marker suffix (including a | ||
| 771 | // standalone Escape) that it must hand back to the lifecycle-owned reader. | 768 | // standalone Escape) that it must hand back to the lifecycle-owned reader. | ||
| @@ -783,8 +780,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 783 | recvCh := make(chan recvResult, 1) | 780 | recvCh := make(chan recvResult, 1) | ||
| 784 | go runRecv(loopCtx, transport, recvCh, log) | 781 | go runRecv(loopCtx, transport, recvCh, log) | ||
| 785 | 782 | ||||
| 786 | - | var transientToast clientToastUI | |||
| 787 | - | awaitingToastResync := false | |||
| 788 | for { | 783 | for { | ||
| 789 | select { | 784 | select { | ||
| 790 | case <-loopCtx.Done(): | 785 | case <-loopCtx.Done(): | ||
| @@ -796,25 +791,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 796 | default: | 791 | default: | ||
| 797 | return welcomedResult(nil) | 792 | return welcomedResult(nil) | ||
| 798 | } | 793 | } | ||
| 799 | - | case message := <-clientToasts: | |||
| 800 | - | size, serr := term.Size() | |||
| 801 | - | if serr != nil { | |||
| 802 | - | log.Warn("reading terminal size for client toast", "err", serr) | |||
| 803 | - | continue | |||
| 804 | - | } | |||
| 805 | - | if !transientToast.draw(term, size, message) || awaitingToastResync { | |||
| 806 | - | continue | |||
| 807 | - | } | |||
| 808 | - | // MsgResize is the existing reset-producing primitive. Sending the | |||
| 809 | - | // current size preserves wire compatibility and causes the daemon to | |||
| 810 | - | // publish an authoritative full frame; until then incremental frames | |||
| 811 | - | // are acknowledged but not written over this local overlay. | |||
| 812 | - | select { | |||
| 813 | - | case sendCh <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: size})}: | |||
| 814 | - | awaitingToastResync = true | |||
| 815 | - | case <-loopCtx.Done(): | |||
| 816 | - | return welcomedResult(nil) | |||
| 817 | - | } | |||
| 818 | case ev, ok := <-linkEvents: | 794 | case ev, ok := <-linkEvents: | ||
| 819 | if !ok { | 795 | if !ok { | ||
| 820 | linkEvents = nil | 796 | linkEvents = nil | ||
| @@ -822,11 +798,20 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 822 | } | 798 | } | ||
| 823 | if ev.State == ports.LinkStateConnected { | 799 | if ev.State == ports.LinkStateConnected { | ||
| 824 | reconnect.clear() | 800 | reconnect.clear() | ||
| 801 | + | select { | |||
| 802 | + | case sendCh <- ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkConnected})}: | |||
| 803 | + | case <-loopCtx.Done(): | |||
| 804 | + | return welcomedResult(nil) | |||
| 805 | + | } | |||
| 825 | continue | 806 | continue | ||
| 826 | } | 807 | } | ||
| 827 | if ev.State == ports.LinkStateDegraded { | 808 | if ev.State == ports.LinkStateDegraded { | ||
| 828 | log.Warn("UDP link degraded") | 809 | log.Warn("UDP link degraded") | ||
| 829 | - | reconnect.drawStage(reconnectStageDegraded) | 810 | + | select { |
| 811 | + | case sendCh <- ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded})}: | |||
| 812 | + | case <-loopCtx.Done(): | |||
| 813 | + | return welcomedResult(nil) | |||
| 814 | + | } | |||
| 830 | continue | 815 | continue | ||
| 831 | } | 816 | } | ||
| 832 | reconnect.drawStage(stageForLinkState(ev.State)) | 817 | reconnect.drawStage(stageForLinkState(ev.State)) | ||
| @@ -865,26 +850,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 865 | if derr != nil { | 850 | if derr != nil { | ||
| 866 | return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr)) | 851 | return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr)) | ||
| 867 | } | 852 | } | ||
| 868 | - | // A local toast obscures terminal cells unknown to an incremental | |||
| 869 | - | // daemon diff. A same-size MsgResize above produces this reset | |||
| 870 | - | // frame (BaseStateNum=0); acknowledge any intervening state without | |||
| 871 | - | // writing it, then apply only the authoritative full frame. | |||
| 872 | - | if awaitingToastResync && !isClientToastResyncFrame(o) { | |||
| 873 | - | if o.NewStateNum != 0 { | |||
| 874 | - | ackQueue.offer(o.NewStateNum) | |||
| 875 | - | } | |||
| 876 | - | continue | |||
| 877 | - | } | |||
| 878 | if _, werr := term.Out().Write(o.Data); werr != nil { | 853 | if _, werr := term.Out().Write(o.Data); werr != nil { | ||
| 879 | return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr)) | 854 | return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr)) | ||
| 880 | } | 855 | } | ||
| 881 | if ferr := term.Flush(); ferr != nil { | 856 | if ferr := term.Flush(); ferr != nil { | ||
| 882 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | 857 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | ||
| 883 | } | 858 | } | ||
| 884 | - | if awaitingToastResync { | |||
| 885 | - | transientToast.reconcile() | |||
| 886 | - | awaitingToastResync = false | |||
| 887 | - | } | |||
| 888 | // The terminal boundary is after a successful flush and before ACK. | 859 | // The terminal boundary is after a successful flush and before ACK. | ||
| 889 | if observer != nil { | 860 | if observer != nil { | ||
| 890 | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true)) | 861 | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true)) | ||
| @@ -920,13 +891,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 920 | 891 | ||||
| 921 | // recvResult carries one framed message (or a read error) from the receive | 892 | // recvResult carries one framed message (or a read error) from the receive | ||
| 922 | // pump to the main loop. | 893 | // pump to the main loop. | ||
| 923 | - | // isClientToastResyncFrame identifies the authoritative reset emitted by the | |||
| 924 | - | // daemon's existing resize path. State-less side effects also have base zero, | |||
| 925 | - | // so they must not dismiss an overlay. | |||
| 926 | - | func isClientToastResyncFrame(o ports.Output) bool { | |||
| 927 | - | return o.BaseStateNum == 0 && o.NewStateNum != 0 | |||
| 928 | - | } | |||
| 929 | - | ||||
| 930 | type recvResult struct { | 894 | type recvResult struct { | ||
| 931 | frame ports.Frame | 895 | frame ports.Frame | ||
| 932 | err error | 896 | err error | ||
| @@ -1349,7 +1313,6 @@ type stdinPump struct { | |||||
| 1349 | logger *slog.Logger | 1313 | logger *slog.Logger | ||
| 1350 | paletteEvents chan<- paletteGenerationEvent | 1314 | paletteEvents chan<- paletteGenerationEvent | ||
| 1351 | activeGeneration *atomic.Uint64 | 1315 | activeGeneration *atomic.Uint64 | ||
| 1352 | - | clientToasts chan<- string | |||
| 1353 | afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook | 1316 | afterPaletteEvent func(paletteGenerationEvent) // test synchronization hook | ||
| 1354 | afterInputTake func() // test synchronization hook | 1317 | afterInputTake func() // test synchronization hook | ||
| 1355 | afterInputDelivered func() // test synchronization hook | 1318 | afterInputDelivered func() // test synchronization hook | ||
| @@ -1412,14 +1375,8 @@ func (p *stdinPump) run() { | |||||
| 1412 | coalescer: coalescer, | 1375 | coalescer: coalescer, | ||
| 1413 | reader: p.clipboard, | 1376 | reader: p.clipboard, | ||
| 1414 | log: p.logger, | 1377 | log: p.logger, | ||
| 1415 | - | showToast: func(message string) { | 1378 | + | sendNotice: func(action uint8) { |
| 1416 | - | if p.clientToasts == nil { | 1379 | + | send(ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: action})}) |
| 1417 | - | return | |||
| 1418 | - | } | |||
| 1419 | - | select { | |||
| 1420 | - | case p.clientToasts <- message: | |||
| 1421 | - | case <-p.ctx.Done(): | |||
| 1422 | - | } | |||
| 1423 | }, | 1380 | }, | ||
| 1424 | sendImage: func(mime string, data []byte) { | 1381 | sendImage: func(mime string, data []byte) { | ||
| 1425 | inputSeq++ | 1382 | inputSeq++ | ||
| @@ -1617,6 +1574,8 @@ func detachedResult(reason uint8) error { | |||||
| 1617 | return &DetachedError{Reason: reason, Text: "session was killed"} | 1574 | return &DetachedError{Reason: reason, Text: "session was killed"} | ||
| 1618 | case ports.ReasonServerShutdown: | 1575 | case ports.ReasonServerShutdown: | ||
| 1619 | return &DetachedError{Reason: reason, Text: "daemon shut down"} | 1576 | return &DetachedError{Reason: reason, Text: "daemon shut down"} | ||
| 1577 | + | case ports.ReasonReplaced: | |||
| 1578 | + | return &DetachedError{Reason: reason, Text: "session taken over by another client"} | |||
| 1620 | default: | 1579 | default: | ||
| 1621 | return &DetachedError{Reason: reason, Text: "detached by daemon"} | 1580 | return &DetachedError{Reason: reason, Text: "detached by daemon"} | ||
| 1622 | } | 1581 | } | ||
M internal/usecase/client/clipboard_intercept.go +10 -10
| @@ -25,12 +25,12 @@ const maxClipboardImagePush = 1 << 20 // 1 MiB | |||
| 25 | 25 | // text may legitimately contain it) — is forwarded to next unchanged, so it | |
| 26 | 26 | // still gets the coalescer's marker-splitting treatment. | |
| 27 | 27 | type clipboardIntercept struct { | |
| 28 | - | coalescer *pasteCoalescer | |
| 29 | - | reader ports.ClipboardReader | |
| 30 | - | log *slog.Logger | |
| 31 | - | sendImage func(mime string, data []byte) | |
| 32 | - | showToast func(string) | |
| 33 | - | next func([]byte) | |
| 28 | + | coalescer *pasteCoalescer | |
| 29 | + | reader ports.ClipboardReader | |
| 30 | + | log *slog.Logger | |
| 31 | + | sendImage func(mime string, data []byte) | |
| 32 | + | sendNotice func(action uint8) | |
| 33 | + | next func([]byte) | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | // Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it | |
| @@ -100,8 +100,8 @@ func (c *clipboardIntercept) handleCtrlV() { | |||
| 100 | 100 | if err != nil { | |
| 101 | 101 | if !errors.Is(err, ports.ErrNoClipboardImage) { | |
| 102 | 102 | c.log.Warn("clipboard image read failed", "err", err) | |
| 103 | - | if c.showToast != nil { | |
| 104 | - | c.showToast("image paste failed; sent Ctrl+V") | |
| 103 | + | if c.sendNotice != nil { | |
| 104 | + | c.sendNotice(ports.ClientNoticeClipboardFallback) | |
| 105 | 105 | } | |
| 106 | 106 | } | |
| 107 | 107 | c.next([]byte{ctrlV}) | |
| @@ -109,8 +109,8 @@ func (c *clipboardIntercept) handleCtrlV() { | |||
| 109 | 109 | } | |
| 110 | 110 | if len(data) > maxClipboardImagePush { | |
| 111 | 111 | c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush) | |
| 112 | - | if c.showToast != nil { | |
| 113 | - | c.showToast("image too large to paste") | |
| 112 | + | if c.sendNotice != nil { | |
| 113 | + | c.sendNotice(ports.ClientNoticeClipboardTooLarge) | |
| 114 | 114 | } | |
| 115 | 115 | c.next([]byte{ctrlV}) | |
| 116 | 116 | return | |
| @@ -25,12 +25,12 @@ const maxClipboardImagePush = 1 << 20 // 1 MiB | |||||
| 25 | // text may legitimately contain it) — is forwarded to next unchanged, so it | 25 | // text may legitimately contain it) — is forwarded to next unchanged, so it | ||
| 26 | // still gets the coalescer's marker-splitting treatment. | 26 | // still gets the coalescer's marker-splitting treatment. | ||
| 27 | type clipboardIntercept struct { | 27 | type clipboardIntercept struct { | ||
| 28 | - | coalescer *pasteCoalescer | 28 | + | coalescer *pasteCoalescer |
| 29 | - | reader ports.ClipboardReader | 29 | + | reader ports.ClipboardReader |
| 30 | - | log *slog.Logger | 30 | + | log *slog.Logger |
| 31 | - | sendImage func(mime string, data []byte) | 31 | + | sendImage func(mime string, data []byte) |
| 32 | - | showToast func(string) | 32 | + | sendNotice func(action uint8) |
| 33 | - | next func([]byte) | 33 | + | next func([]byte) |
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | // Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it | 36 | // Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it | ||
| @@ -100,8 +100,8 @@ func (c *clipboardIntercept) handleCtrlV() { | |||||
| 100 | if err != nil { | 100 | if err != nil { | ||
| 101 | if !errors.Is(err, ports.ErrNoClipboardImage) { | 101 | if !errors.Is(err, ports.ErrNoClipboardImage) { | ||
| 102 | c.log.Warn("clipboard image read failed", "err", err) | 102 | c.log.Warn("clipboard image read failed", "err", err) | ||
| 103 | - | if c.showToast != nil { | 103 | + | if c.sendNotice != nil { |
| 104 | - | c.showToast("image paste failed; sent Ctrl+V") | 104 | + | c.sendNotice(ports.ClientNoticeClipboardFallback) |
| 105 | } | 105 | } | ||
| 106 | } | 106 | } | ||
| 107 | c.next([]byte{ctrlV}) | 107 | c.next([]byte{ctrlV}) | ||
| @@ -109,8 +109,8 @@ func (c *clipboardIntercept) handleCtrlV() { | |||||
| 109 | } | 109 | } | ||
| 110 | if len(data) > maxClipboardImagePush { | 110 | if len(data) > maxClipboardImagePush { | ||
| 111 | c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush) | 111 | c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush) | ||
| 112 | - | if c.showToast != nil { | 112 | + | if c.sendNotice != nil { |
| 113 | - | c.showToast("image too large to paste") | 113 | + | c.sendNotice(ports.ClientNoticeClipboardTooLarge) |
| 114 | } | 114 | } | ||
| 115 | c.next([]byte{ctrlV}) | 115 | c.next([]byte{ctrlV}) | ||
| 116 | return | 116 | return | ||
M internal/usecase/client/clipboard_intercept_internal_test.go +7 -7
| @@ -79,25 +79,25 @@ func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) { | |||
| 79 | 79 | require.Equal(t, [][]byte{{ctrlV}}, col.snapshot()) | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | - | func TestClipboardInterceptFailureToastsAndForwardsCtrlV(t *testing.T) { | |
| 82 | + | func TestClipboardInterceptFailureNotifiesDaemonAndForwardsCtrlV(t *testing.T) { | |
| 83 | 83 | tests := []struct { | |
| 84 | 84 | name string | |
| 85 | 85 | reader ports.ClipboardReader | |
| 86 | - | want string | |
| 86 | + | want uint8 | |
| 87 | 87 | }{ | |
| 88 | - | {name: "image read failure", reader: &ciFakeReader{err: errors.New("boom")}, want: "image paste failed; sent Ctrl+V"}, | |
| 89 | - | {name: "oversized image", reader: &ciFakeReader{mime: "image/png", data: make([]byte, maxClipboardImagePush+1)}, want: "image too large to paste"}, | |
| 88 | + | {name: "image read failure", reader: &ciFakeReader{err: errors.New("boom")}, want: ports.ClientNoticeClipboardFallback}, | |
| 89 | + | {name: "oversized image", reader: &ciFakeReader{mime: "image/png", data: make([]byte, maxClipboardImagePush+1)}, want: ports.ClientNoticeClipboardTooLarge}, | |
| 90 | 90 | } | |
| 91 | 91 | for _, tt := range tests { | |
| 92 | 92 | t.Run(tt.name, func(t *testing.T) { | |
| 93 | 93 | ci, pc, _, col, images := newTestClipboardIntercept(tt.reader) | |
| 94 | 94 | defer pc.Close() | |
| 95 | - | var toasts []string | |
| 96 | - | ci.showToast = func(message string) { toasts = append(toasts, message) } | |
| 95 | + | var actions []uint8 | |
| 96 | + | ci.sendNotice = func(action uint8) { actions = append(actions, action) } | |
| 97 | 97 | ||
| 98 | 98 | ci.Scan([]byte{ctrlV}) | |
| 99 | 99 | ||
| 100 | - | require.Equal(t, []string{tt.want}, toasts) | |
| 100 | + | require.Equal(t, []uint8{tt.want}, actions) | |
| 101 | 101 | require.Empty(t, *images) | |
| 102 | 102 | require.Equal(t, [][]byte{{ctrlV}}, col.snapshot()) | |
| 103 | 103 | }) | |
| @@ -79,25 +79,25 @@ func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) { | |||||
| 79 | require.Equal(t, [][]byte{{ctrlV}}, col.snapshot()) | 79 | require.Equal(t, [][]byte{{ctrlV}}, col.snapshot()) | ||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | - | func TestClipboardInterceptFailureToastsAndForwardsCtrlV(t *testing.T) { | 82 | + | func TestClipboardInterceptFailureNotifiesDaemonAndForwardsCtrlV(t *testing.T) { |
| 83 | tests := []struct { | 83 | tests := []struct { | ||
| 84 | name string | 84 | name string | ||
| 85 | reader ports.ClipboardReader | 85 | reader ports.ClipboardReader | ||
| 86 | - | want string | 86 | + | want uint8 |
| 87 | }{ | 87 | }{ | ||
| 88 | - | {name: "image read failure", reader: &ciFakeReader{err: errors.New("boom")}, want: "image paste failed; sent Ctrl+V"}, | 88 | + | {name: "image read failure", reader: &ciFakeReader{err: errors.New("boom")}, want: ports.ClientNoticeClipboardFallback}, |
| 89 | - | {name: "oversized image", reader: &ciFakeReader{mime: "image/png", data: make([]byte, maxClipboardImagePush+1)}, want: "image too large to paste"}, | 89 | + | {name: "oversized image", reader: &ciFakeReader{mime: "image/png", data: make([]byte, maxClipboardImagePush+1)}, want: ports.ClientNoticeClipboardTooLarge}, |
| 90 | } | 90 | } | ||
| 91 | for _, tt := range tests { | 91 | for _, tt := range tests { | ||
| 92 | t.Run(tt.name, func(t *testing.T) { | 92 | t.Run(tt.name, func(t *testing.T) { | ||
| 93 | ci, pc, _, col, images := newTestClipboardIntercept(tt.reader) | 93 | ci, pc, _, col, images := newTestClipboardIntercept(tt.reader) | ||
| 94 | defer pc.Close() | 94 | defer pc.Close() | ||
| 95 | - | var toasts []string | 95 | + | var actions []uint8 |
| 96 | - | ci.showToast = func(message string) { toasts = append(toasts, message) } | 96 | + | ci.sendNotice = func(action uint8) { actions = append(actions, action) } |
| 97 | 97 | ||||
| 98 | ci.Scan([]byte{ctrlV}) | 98 | ci.Scan([]byte{ctrlV}) | ||
| 99 | 99 | ||||
| 100 | - | require.Equal(t, []string{tt.want}, toasts) | 100 | + | require.Equal(t, []uint8{tt.want}, actions) |
| 101 | require.Empty(t, *images) | 101 | require.Empty(t, *images) | ||
| 102 | require.Equal(t, [][]byte{{ctrlV}}, col.snapshot()) | 102 | require.Equal(t, [][]byte{{ctrlV}}, col.snapshot()) | ||
| 103 | }) | 103 | }) | ||
M internal/usecase/client/clipboard_intercept_test.go +13 -31
| @@ -61,6 +61,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade | |||
| 61 | 61 | gotImage <- ip | |
| 62 | 62 | return nil | |
| 63 | 63 | }).Maybe() | |
| 64 | + | tr.EXPECT().Send(isType(ports.MsgClientNotice)).Return(nil).Maybe() | |
| 64 | 65 | ||
| 65 | 66 | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | |
| 66 | 67 | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| @@ -158,7 +159,7 @@ func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.Transport, e | |||
| 158 | 159 | return d.transport, nil | |
| 159 | 160 | } | |
| 160 | 161 | ||
| 161 | - | func TestRunRemoteClipboardFailureReconcilesOnResetFrame(t *testing.T) { | |
| 162 | + | func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testing.T) { | |
| 162 | 163 | var out bytes.Buffer | |
| 163 | 164 | resizeEvents := make(chan domain.Size) | |
| 164 | 165 | input := newOneShotBlockingReader([]byte{0x16}) | |
| @@ -181,51 +182,32 @@ func TestRunRemoteClipboardFailureReconcilesOnResetFrame(t *testing.T) { | |||
| 181 | 182 | result <- client.NewRunner(client.Dependencies{Dialer: clipboardToastLifecycleDialer{transport: transport}, Terminal: term, Clock: realClock{}, Clipboard: clipboard}).Run(context.Background(), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: true}) | |
| 182 | 183 | }() | |
| 183 | 184 | ||
| 184 | - | for { | |
| 185 | - | select { | |
| 186 | - | case sent := <-transport.sends: | |
| 187 | - | if sent.Type != ports.MsgResize { | |
| 188 | - | continue | |
| 189 | - | } | |
| 190 | - | goto resized | |
| 191 | - | case <-time.After(time.Second): | |
| 192 | - | t.Fatal("client did not request a daemon reset after drawing clipboard toast") | |
| 193 | - | } | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | resized: | |
| 197 | - | require.Contains(t, out.String(), "image paste failed; sent Ctrl+V") | |
| 198 | - | transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("incremental")})) | |
| 199 | - | for { | |
| 185 | + | var gotNotice bool | |
| 186 | + | for !gotNotice { | |
| 200 | 187 | select { | |
| 201 | 188 | case sent := <-transport.sends: | |
| 202 | - | if sent.Type != ports.MsgAck { | |
| 189 | + | if sent.Type != ports.MsgClientNotice { | |
| 203 | 190 | continue | |
| 204 | 191 | } | |
| 205 | - | ack, err := ports.UnmarshalAck(sent.Payload) | |
| 192 | + | notice, err := ports.UnmarshalClientNotice(sent.Payload) | |
| 206 | 193 | require.NoError(t, err) | |
| 207 | - | if ack.AckedStateNum == 2 { | |
| 208 | - | goto incrementalAcknowledged | |
| 209 | - | } | |
| 194 | + | require.Equal(t, ports.ClientNoticeClipboardFallback, notice.Action) | |
| 195 | + | gotNotice = true | |
| 210 | 196 | case <-time.After(time.Second): | |
| 211 | - | t.Fatal("client did not ACK discarded incremental output") | |
| 197 | + | t.Fatal("client did not notify daemon of clipboard failure") | |
| 212 | 198 | } | |
| 213 | 199 | } | |
| 214 | - | ||
| 215 | - | incrementalAcknowledged: | |
| 216 | - | require.NotContains(t, out.String(), "incremental", "incremental output must not overwrite a local toast before reset") | |
| 217 | - | transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 3, Data: []byte("authoritative reset")})) | |
| 200 | + | beforeOutput := out.String() | |
| 201 | + | transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("incremental")})) | |
| 218 | 202 | transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| 219 | 203 | ||
| 220 | 204 | select { | |
| 221 | 205 | case err := <-result: | |
| 222 | 206 | require.NoError(t, err) | |
| 223 | 207 | case <-time.After(time.Second): | |
| 224 | - | t.Fatal("client did not finish after reset frame") | |
| 208 | + | t.Fatal("client did not finish") | |
| 225 | 209 | } | |
| 226 | - | got := out.String() | |
| 227 | - | require.NotContains(t, got, "incremental", "incremental output must not overwrite a local toast before reset") | |
| 228 | - | require.Contains(t, got, "authoritative reset") | |
| 210 | + | require.Equal(t, beforeOutput+"incremental", out.String(), "daemon output must remain verbatim with no local toast bytes") | |
| 229 | 211 | } | |
| 230 | 212 | ||
| 231 | 213 | func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) { | |
| @@ -61,6 +61,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade | |||||
| 61 | gotImage <- ip | 61 | gotImage <- ip | ||
| 62 | return nil | 62 | return nil | ||
| 63 | }).Maybe() | 63 | }).Maybe() | ||
| 64 | + | tr.EXPECT().Send(isType(ports.MsgClientNotice)).Return(nil).Maybe() | |||
| 64 | 65 | ||||
| 65 | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | 66 | welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})) | ||
| 66 | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | 67 | detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | ||
| @@ -158,7 +159,7 @@ func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.Transport, e | |||||
| 158 | return d.transport, nil | 159 | return d.transport, nil | ||
| 159 | } | 160 | } | ||
| 160 | 161 | ||||
| 161 | - | func TestRunRemoteClipboardFailureReconcilesOnResetFrame(t *testing.T) { | 162 | + | func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testing.T) { |
| 162 | var out bytes.Buffer | 163 | var out bytes.Buffer | ||
| 163 | resizeEvents := make(chan domain.Size) | 164 | resizeEvents := make(chan domain.Size) | ||
| 164 | input := newOneShotBlockingReader([]byte{0x16}) | 165 | input := newOneShotBlockingReader([]byte{0x16}) | ||
| @@ -181,51 +182,32 @@ func TestRunRemoteClipboardFailureReconcilesOnResetFrame(t *testing.T) { | |||||
| 181 | result <- client.NewRunner(client.Dependencies{Dialer: clipboardToastLifecycleDialer{transport: transport}, Terminal: term, Clock: realClock{}, Clipboard: clipboard}).Run(context.Background(), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: true}) | 182 | result <- client.NewRunner(client.Dependencies{Dialer: clipboardToastLifecycleDialer{transport: transport}, Terminal: term, Clock: realClock{}, Clipboard: clipboard}).Run(context.Background(), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: true}) | ||
| 182 | }() | 183 | }() | ||
| 183 | 184 | ||||
| 184 | - | for { | 185 | + | var gotNotice bool |
| 185 | - | select { | 186 | + | for !gotNotice { |
| 186 | - | case sent := <-transport.sends: | |||
| 187 | - | if sent.Type != ports.MsgResize { | |||
| 188 | - | continue | |||
| 189 | - | } | |||
| 190 | - | goto resized | |||
| 191 | - | case <-time.After(time.Second): | |||
| 192 | - | t.Fatal("client did not request a daemon reset after drawing clipboard toast") | |||
| 193 | - | } | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | resized: | |||
| 197 | - | require.Contains(t, out.String(), "image paste failed; sent Ctrl+V") | |||
| 198 | - | transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("incremental")})) | |||
| 199 | - | for { | |||
| 200 | select { | 187 | select { | ||
| 201 | case sent := <-transport.sends: | 188 | case sent := <-transport.sends: | ||
| 202 | - | if sent.Type != ports.MsgAck { | 189 | + | if sent.Type != ports.MsgClientNotice { |
| 203 | continue | 190 | continue | ||
| 204 | } | 191 | } | ||
| 205 | - | ack, err := ports.UnmarshalAck(sent.Payload) | 192 | + | notice, err := ports.UnmarshalClientNotice(sent.Payload) |
| 206 | require.NoError(t, err) | 193 | require.NoError(t, err) | ||
| 207 | - | if ack.AckedStateNum == 2 { | 194 | + | require.Equal(t, ports.ClientNoticeClipboardFallback, notice.Action) |
| 208 | - | goto incrementalAcknowledged | 195 | + | gotNotice = true |
| 209 | - | } | |||
| 210 | case <-time.After(time.Second): | 196 | case <-time.After(time.Second): | ||
| 211 | - | t.Fatal("client did not ACK discarded incremental output") | 197 | + | t.Fatal("client did not notify daemon of clipboard failure") |
| 212 | } | 198 | } | ||
| 213 | } | 199 | } | ||
| 214 | - | 200 | + | beforeOutput := out.String() | |
| 215 | - | incrementalAcknowledged: | 201 | + | transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("incremental")})) |
| 216 | - | require.NotContains(t, out.String(), "incremental", "incremental output must not overwrite a local toast before reset") | |||
| 217 | - | transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 3, Data: []byte("authoritative reset")})) | |||
| 218 | transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | 202 | transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | ||
| 219 | 203 | ||||
| 220 | select { | 204 | select { | ||
| 221 | case err := <-result: | 205 | case err := <-result: | ||
| 222 | require.NoError(t, err) | 206 | require.NoError(t, err) | ||
| 223 | case <-time.After(time.Second): | 207 | case <-time.After(time.Second): | ||
| 224 | - | t.Fatal("client did not finish after reset frame") | 208 | + | t.Fatal("client did not finish") |
| 225 | } | 209 | } | ||
| 226 | - | got := out.String() | 210 | + | require.Equal(t, beforeOutput+"incremental", out.String(), "daemon output must remain verbatim with no local toast bytes") |
| 227 | - | require.NotContains(t, got, "incremental", "incremental output must not overwrite a local toast before reset") | |||
| 228 | - | require.Contains(t, got, "authoritative reset") | |||
| 229 | } | 211 | } | ||
| 230 | 212 | ||||
| 231 | func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) { | 213 | func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) { | ||
M internal/usecase/client/reconnect_toast.go +0 -34
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/internal/ports" | |
| 10 | 9 | "github.com/bnema/vev/internal/usecase/ui" | |
| 11 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 11 | ) | |
| @@ -48,39 +47,6 @@ func drawClientToast(out io.Writer, size domain.Size, message string) (domain.Re | |||
| 48 | 47 | return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message)) | |
| 49 | 48 | } | |
| 50 | 49 | ||
| 51 | - | // clientToastUI owns a transient client-local overlay. It is intentionally | |
| 52 | - | // reconciled by a daemon reset frame rather than erased locally: an | |
| 53 | - | // incremental daemon diff cannot restore cells hidden by the overlay. | |
| 54 | - | type clientToastUI struct { | |
| 55 | - | showing bool | |
| 56 | - | rect domain.Rect | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | func (u *clientToastUI) draw(term ports.Terminal, size domain.Size, message string) bool { | |
| 60 | - | changed := u.showing | |
| 61 | - | if u.showing { | |
| 62 | - | _ = clearReconnectToast(term.Out(), u.rect) | |
| 63 | - | u.showing = false | |
| 64 | - | u.rect = domain.Rect{} | |
| 65 | - | } | |
| 66 | - | rect, _ := drawClientToast(term.Out(), size, message) | |
| 67 | - | if rect.Width <= 0 || rect.Height <= 0 { | |
| 68 | - | _ = term.Flush() | |
| 69 | - | return changed | |
| 70 | - | } | |
| 71 | - | u.rect = rect | |
| 72 | - | u.showing = true | |
| 73 | - | _ = term.Flush() | |
| 74 | - | return true | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | // reconcile marks the overlay gone after the daemon has painted a full reset | |
| 78 | - | // frame over it. It deliberately emits no terminal bytes. | |
| 79 | - | func (u *clientToastUI) reconcile() { | |
| 80 | - | u.showing = false | |
| 81 | - | u.rect = domain.Rect{} | |
| 82 | - | } | |
| 83 | - | ||
| 84 | 50 | func clearReconnectToast(out io.Writer, bounds domain.Rect) error { | |
| 85 | 51 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 86 | 52 | return nil | |
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | 7 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 9 | - | "github.com/bnema/vev/internal/ports" | |||
| 10 | "github.com/bnema/vev/internal/usecase/ui" | 9 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 11 | "github.com/bnema/vev/pkg/renderer" | 10 | "github.com/bnema/vev/pkg/renderer" | ||
| 12 | ) | 11 | ) | ||
| @@ -48,39 +47,6 @@ func drawClientToast(out io.Writer, size domain.Size, message string) (domain.Re | |||||
| 48 | return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message)) | 47 | return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message)) | ||
| 49 | } | 48 | } | ||
| 50 | 49 | ||||
| 51 | - | // clientToastUI owns a transient client-local overlay. It is intentionally | |||
| 52 | - | // reconciled by a daemon reset frame rather than erased locally: an | |||
| 53 | - | // incremental daemon diff cannot restore cells hidden by the overlay. | |||
| 54 | - | type clientToastUI struct { | |||
| 55 | - | showing bool | |||
| 56 | - | rect domain.Rect | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | func (u *clientToastUI) draw(term ports.Terminal, size domain.Size, message string) bool { | |||
| 60 | - | changed := u.showing | |||
| 61 | - | if u.showing { | |||
| 62 | - | _ = clearReconnectToast(term.Out(), u.rect) | |||
| 63 | - | u.showing = false | |||
| 64 | - | u.rect = domain.Rect{} | |||
| 65 | - | } | |||
| 66 | - | rect, _ := drawClientToast(term.Out(), size, message) | |||
| 67 | - | if rect.Width <= 0 || rect.Height <= 0 { | |||
| 68 | - | _ = term.Flush() | |||
| 69 | - | return changed | |||
| 70 | - | } | |||
| 71 | - | u.rect = rect | |||
| 72 | - | u.showing = true | |||
| 73 | - | _ = term.Flush() | |||
| 74 | - | return true | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | // reconcile marks the overlay gone after the daemon has painted a full reset | |||
| 78 | - | // frame over it. It deliberately emits no terminal bytes. | |||
| 79 | - | func (u *clientToastUI) reconcile() { | |||
| 80 | - | u.showing = false | |||
| 81 | - | u.rect = domain.Rect{} | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | func clearReconnectToast(out io.Writer, bounds domain.Rect) error { | 50 | func clearReconnectToast(out io.Writer, bounds domain.Rect) error { | ||
| 85 | if bounds.Width <= 0 || bounds.Height <= 0 { | 51 | if bounds.Width <= 0 || bounds.Height <= 0 { | ||
| 86 | return nil | 52 | return nil | ||
M internal/usecase/client/reconnect_toast_test.go +39 -52
| @@ -141,6 +141,7 @@ func newReconnectToastRecordingTransport(recvs ...reconnectToastRecv) *reconnect | |||
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | 143 | type reconnectToastLinkTransport struct { | |
| 144 | + | mu sync.Mutex | |
| 144 | 145 | recvCh chan reconnectToastRecv | |
| 145 | 146 | sends []ports.Frame | |
| 146 | 147 | events chan ports.LinkEvent | |
| @@ -156,10 +157,18 @@ func newReconnectToastLinkTransport() *reconnectToastLinkTransport { | |||
| 156 | 157 | } | |
| 157 | 158 | ||
| 158 | 159 | func (t *reconnectToastLinkTransport) Send(f ports.Frame) error { | |
| 160 | + | t.mu.Lock() | |
| 159 | 161 | t.sends = append(t.sends, f) | |
| 162 | + | t.mu.Unlock() | |
| 160 | 163 | return nil | |
| 161 | 164 | } | |
| 162 | 165 | ||
| 166 | + | func (t *reconnectToastLinkTransport) sentFrames() []ports.Frame { | |
| 167 | + | t.mu.Lock() | |
| 168 | + | defer t.mu.Unlock() | |
| 169 | + | return append([]ports.Frame(nil), t.sends...) | |
| 170 | + | } | |
| 171 | + | ||
| 163 | 172 | func (t *reconnectToastLinkTransport) Recv() (ports.Frame, error) { | |
| 164 | 173 | recv := <-t.recvCh | |
| 165 | 174 | return recv.frame, recv.err | |
| @@ -282,7 +291,7 @@ func newReconnectAttachAttempt(term ports.Terminal, transport ports.Transport, c | |||
| 282 | 291 | } | |
| 283 | 292 | } | |
| 284 | 293 | ||
| 285 | - | func TestReconnectToastDegradedDrawsToastAndProbingIsVisible(t *testing.T) { | |
| 294 | + | func TestReconnectLinkEventsNotifyDaemonWithoutLocalTerminalWrites(t *testing.T) { | |
| 286 | 295 | out := newReconnectToastOutputRecorder() | |
| 287 | 296 | term := newReconnectToastTerminalHarnessWithOutput(t, out) | |
| 288 | 297 | defer term.closeInput() | |
| @@ -294,25 +303,26 @@ func TestReconnectToastDegradedDrawsToastAndProbingIsVisible(t *testing.T) { | |||
| 294 | 303 | attempt := newReconnectAttachAttempt(term.term, tr, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}, 0, &terminalThemeState{}, tr.LinkEvents(), &ms) | |
| 295 | 304 | go func() { resultCh <- attempt.run(context.Background()) }() | |
| 296 | 305 | ||
| 297 | - | tr.events <- ports.LinkEvent{State: ports.LinkStateProbing} | |
| 298 | - | probed := <-out.completed | |
| 299 | - | require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP)) | |
| 300 | - | ||
| 301 | 306 | tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded} | |
| 302 | - | cleared := <-out.completed | |
| 303 | - | assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP))) | |
| 304 | - | degraded := <-out.completed | |
| 305 | - | require.Contains(t, degraded, reconnectStageMessage(reconnectStageDegraded)) | |
| 306 | - | ||
| 307 | - | tr.events <- ports.LinkEvent{State: ports.LinkStateProbing} | |
| 308 | - | cleared = <-out.completed | |
| 309 | - | assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageDegraded))) | |
| 310 | - | probed = <-out.completed | |
| 311 | - | require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP)) | |
| 312 | - | ||
| 313 | 307 | tr.events <- ports.LinkEvent{State: ports.LinkStateConnected} | |
| 314 | - | cleared = <-out.completed | |
| 315 | - | assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP))) | |
| 308 | + | require.Eventually(t, func() bool { | |
| 309 | + | var actions []uint8 | |
| 310 | + | for _, frame := range tr.sentFrames() { | |
| 311 | + | if frame.Type != ports.MsgClientNotice { | |
| 312 | + | continue | |
| 313 | + | } | |
| 314 | + | notice, err := ports.UnmarshalClientNotice(frame.Payload) | |
| 315 | + | if err == nil { | |
| 316 | + | actions = append(actions, notice.Action) | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | return len(actions) == 2 && actions[0] == ports.ClientNoticeLinkDegraded && actions[1] == ports.ClientNoticeLinkConnected | |
| 320 | + | }, time.Second, time.Millisecond) | |
| 321 | + | select { | |
| 322 | + | case bytes := <-out.completed: | |
| 323 | + | t.Fatalf("link notices wrote client-local terminal bytes: %q", bytes) | |
| 324 | + | default: | |
| 325 | + | } | |
| 316 | 326 | ||
| 317 | 327 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | |
| 318 | 328 | result := <-resultCh | |
| @@ -338,44 +348,21 @@ func TestAttachAttemptOfflineLinkEventReturnsReconnectableError(t *testing.T) { | |||
| 338 | 348 | require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable") | |
| 339 | 349 | } | |
| 340 | 350 | ||
| 341 | - | func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) { | |
| 342 | - | bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24}) | |
| 343 | - | require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds) | |
| 344 | - | } | |
| 345 | - | ||
| 346 | - | func TestClientToastReconciliationWaitsForDaemonResetFrame(t *testing.T) { | |
| 347 | - | tests := []struct { | |
| 348 | - | name string | |
| 349 | - | out ports.Output | |
| 350 | - | want bool | |
| 351 | - | }{ | |
| 352 | - | {name: "incremental frame", out: ports.Output{BaseStateNum: 7, NewStateNum: 8}, want: false}, | |
| 353 | - | {name: "state-less side effect", out: ports.Output{}, want: false}, | |
| 354 | - | {name: "reset frame", out: ports.Output{BaseStateNum: 0, NewStateNum: 9}, want: true}, | |
| 351 | + | func TestDetachedResultReplacementAndCleanDetach(t *testing.T) { | |
| 352 | + | if err := detachedResult(ports.ReasonDetach); err != nil { | |
| 353 | + | t.Fatalf("clean detach = %v, want nil", err) | |
| 355 | 354 | } | |
| 356 | - | for _, tt := range tests { | |
| 357 | - | t.Run(tt.name, func(t *testing.T) { | |
| 358 | - | require.Equal(t, tt.want, isClientToastResyncFrame(tt.out)) | |
| 359 | - | }) | |
| 355 | + | err := detachedResult(ports.ReasonReplaced) | |
| 356 | + | var detached *DetachedError | |
| 357 | + | if !errors.As(err, &detached) { | |
| 358 | + | t.Fatalf("replacement error = %T, want *DetachedError", err) | |
| 360 | 359 | } | |
| 360 | + | require.Equal(t, "session taken over by another client", detached.Text) | |
| 361 | 361 | } | |
| 362 | 362 | ||
| 363 | - | func TestClientToastDrawsWithoutInputPumpTerminalWrites(t *testing.T) { | |
| 364 | - | out := newReconnectToastOutputRecorder() | |
| 365 | - | term := newReconnectToastTerminalHarnessWithOutput(t, out) | |
| 366 | - | defer term.closeInput() | |
| 367 | - | toast := clientToastUI{} | |
| 368 | - | ||
| 369 | - | require.True(t, toast.draw(term.term, term.size, "image paste failed; sent Ctrl+V")) | |
| 370 | - | drawn := <-out.completed | |
| 371 | - | require.Contains(t, drawn, "image paste failed; sent Ctrl+V") | |
| 372 | - | toast.reconcile() | |
| 373 | - | ||
| 374 | - | select { | |
| 375 | - | case got := <-out.completed: | |
| 376 | - | t.Fatalf("reconcile wrote terminal bytes: %q", got) | |
| 377 | - | default: | |
| 378 | - | } | |
| 363 | + | func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) { | |
| 364 | + | bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24}) | |
| 365 | + | require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds) | |
| 379 | 366 | } | |
| 380 | 367 | ||
| 381 | 368 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | |
| @@ -141,6 +141,7 @@ func newReconnectToastRecordingTransport(recvs ...reconnectToastRecv) *reconnect | |||||
| 141 | } | 141 | } | ||
| 142 | 142 | ||||
| 143 | type reconnectToastLinkTransport struct { | 143 | type reconnectToastLinkTransport struct { | ||
| 144 | + | mu sync.Mutex | |||
| 144 | recvCh chan reconnectToastRecv | 145 | recvCh chan reconnectToastRecv | ||
| 145 | sends []ports.Frame | 146 | sends []ports.Frame | ||
| 146 | events chan ports.LinkEvent | 147 | events chan ports.LinkEvent | ||
| @@ -156,10 +157,18 @@ func newReconnectToastLinkTransport() *reconnectToastLinkTransport { | |||||
| 156 | } | 157 | } | ||
| 157 | 158 | ||||
| 158 | func (t *reconnectToastLinkTransport) Send(f ports.Frame) error { | 159 | func (t *reconnectToastLinkTransport) Send(f ports.Frame) error { | ||
| 160 | + | t.mu.Lock() | |||
| 159 | t.sends = append(t.sends, f) | 161 | t.sends = append(t.sends, f) | ||
| 162 | + | t.mu.Unlock() | |||
| 160 | return nil | 163 | return nil | ||
| 161 | } | 164 | } | ||
| 162 | 165 | ||||
| 166 | + | func (t *reconnectToastLinkTransport) sentFrames() []ports.Frame { | |||
| 167 | + | t.mu.Lock() | |||
| 168 | + | defer t.mu.Unlock() | |||
| 169 | + | return append([]ports.Frame(nil), t.sends...) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 163 | func (t *reconnectToastLinkTransport) Recv() (ports.Frame, error) { | 172 | func (t *reconnectToastLinkTransport) Recv() (ports.Frame, error) { | ||
| 164 | recv := <-t.recvCh | 173 | recv := <-t.recvCh | ||
| 165 | return recv.frame, recv.err | 174 | return recv.frame, recv.err | ||
| @@ -282,7 +291,7 @@ func newReconnectAttachAttempt(term ports.Terminal, transport ports.Transport, c | |||||
| 282 | } | 291 | } | ||
| 283 | } | 292 | } | ||
| 284 | 293 | ||||
| 285 | - | func TestReconnectToastDegradedDrawsToastAndProbingIsVisible(t *testing.T) { | 294 | + | func TestReconnectLinkEventsNotifyDaemonWithoutLocalTerminalWrites(t *testing.T) { |
| 286 | out := newReconnectToastOutputRecorder() | 295 | out := newReconnectToastOutputRecorder() | ||
| 287 | term := newReconnectToastTerminalHarnessWithOutput(t, out) | 296 | term := newReconnectToastTerminalHarnessWithOutput(t, out) | ||
| 288 | defer term.closeInput() | 297 | defer term.closeInput() | ||
| @@ -294,25 +303,26 @@ func TestReconnectToastDegradedDrawsToastAndProbingIsVisible(t *testing.T) { | |||||
| 294 | attempt := newReconnectAttachAttempt(term.term, tr, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}, 0, &terminalThemeState{}, tr.LinkEvents(), &ms) | 303 | attempt := newReconnectAttachAttempt(term.term, tr, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}, 0, &terminalThemeState{}, tr.LinkEvents(), &ms) | ||
| 295 | go func() { resultCh <- attempt.run(context.Background()) }() | 304 | go func() { resultCh <- attempt.run(context.Background()) }() | ||
| 296 | 305 | ||||
| 297 | - | tr.events <- ports.LinkEvent{State: ports.LinkStateProbing} | |||
| 298 | - | probed := <-out.completed | |||
| 299 | - | require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP)) | |||
| 300 | - | ||||
| 301 | tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded} | 306 | tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded} | ||
| 302 | - | cleared := <-out.completed | |||
| 303 | - | assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP))) | |||
| 304 | - | degraded := <-out.completed | |||
| 305 | - | require.Contains(t, degraded, reconnectStageMessage(reconnectStageDegraded)) | |||
| 306 | - | ||||
| 307 | - | tr.events <- ports.LinkEvent{State: ports.LinkStateProbing} | |||
| 308 | - | cleared = <-out.completed | |||
| 309 | - | assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageDegraded))) | |||
| 310 | - | probed = <-out.completed | |||
| 311 | - | require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP)) | |||
| 312 | - | ||||
| 313 | tr.events <- ports.LinkEvent{State: ports.LinkStateConnected} | 307 | tr.events <- ports.LinkEvent{State: ports.LinkStateConnected} | ||
| 314 | - | cleared = <-out.completed | 308 | + | require.Eventually(t, func() bool { |
| 315 | - | assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP))) | 309 | + | var actions []uint8 |
| 310 | + | for _, frame := range tr.sentFrames() { | |||
| 311 | + | if frame.Type != ports.MsgClientNotice { | |||
| 312 | + | continue | |||
| 313 | + | } | |||
| 314 | + | notice, err := ports.UnmarshalClientNotice(frame.Payload) | |||
| 315 | + | if err == nil { | |||
| 316 | + | actions = append(actions, notice.Action) | |||
| 317 | + | } | |||
| 318 | + | } | |||
| 319 | + | return len(actions) == 2 && actions[0] == ports.ClientNoticeLinkDegraded && actions[1] == ports.ClientNoticeLinkConnected | |||
| 320 | + | }, time.Second, time.Millisecond) | |||
| 321 | + | select { | |||
| 322 | + | case bytes := <-out.completed: | |||
| 323 | + | t.Fatalf("link notices wrote client-local terminal bytes: %q", bytes) | |||
| 324 | + | default: | |||
| 325 | + | } | |||
| 316 | 326 | ||||
| 317 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | 327 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | ||
| 318 | result := <-resultCh | 328 | result := <-resultCh | ||
| @@ -338,44 +348,21 @@ func TestAttachAttemptOfflineLinkEventReturnsReconnectableError(t *testing.T) { | |||||
| 338 | require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable") | 348 | require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable") | ||
| 339 | } | 349 | } | ||
| 340 | 350 | ||||
| 341 | - | func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) { | 351 | + | func TestDetachedResultReplacementAndCleanDetach(t *testing.T) { |
| 342 | - | bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24}) | 352 | + | if err := detachedResult(ports.ReasonDetach); err != nil { |
| 343 | - | require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds) | 353 | + | t.Fatalf("clean detach = %v, want nil", err) |
| 344 | - | } | |||
| 345 | - | ||||
| 346 | - | func TestClientToastReconciliationWaitsForDaemonResetFrame(t *testing.T) { | |||
| 347 | - | tests := []struct { | |||
| 348 | - | name string | |||
| 349 | - | out ports.Output | |||
| 350 | - | want bool | |||
| 351 | - | }{ | |||
| 352 | - | {name: "incremental frame", out: ports.Output{BaseStateNum: 7, NewStateNum: 8}, want: false}, | |||
| 353 | - | {name: "state-less side effect", out: ports.Output{}, want: false}, | |||
| 354 | - | {name: "reset frame", out: ports.Output{BaseStateNum: 0, NewStateNum: 9}, want: true}, | |||
| 355 | } | 354 | } | ||
| 356 | - | for _, tt := range tests { | 355 | + | err := detachedResult(ports.ReasonReplaced) |
| 357 | - | t.Run(tt.name, func(t *testing.T) { | 356 | + | var detached *DetachedError |
| 358 | - | require.Equal(t, tt.want, isClientToastResyncFrame(tt.out)) | 357 | + | if !errors.As(err, &detached) { |
| 359 | - | }) | 358 | + | t.Fatalf("replacement error = %T, want *DetachedError", err) |
| 360 | } | 359 | } | ||
| 360 | + | require.Equal(t, "session taken over by another client", detached.Text) | |||
| 361 | } | 361 | } | ||
| 362 | 362 | ||||
| 363 | - | func TestClientToastDrawsWithoutInputPumpTerminalWrites(t *testing.T) { | 363 | + | func TestReconnectToastBoundsUseCenterAnchor(t *testing.T) { |
| 364 | - | out := newReconnectToastOutputRecorder() | 364 | + | bounds := reconnectToastBounds(domain.Size{Cols: 80, Rows: 24}) |
| 365 | - | term := newReconnectToastTerminalHarnessWithOutput(t, out) | 365 | + | require.Equal(t, domain.Rect{X: 29, Y: 10, Width: 22, Height: 3}, bounds) |
| 366 | - | defer term.closeInput() | |||
| 367 | - | toast := clientToastUI{} | |||
| 368 | - | ||||
| 369 | - | require.True(t, toast.draw(term.term, term.size, "image paste failed; sent Ctrl+V")) | |||
| 370 | - | drawn := <-out.completed | |||
| 371 | - | require.Contains(t, drawn, "image paste failed; sent Ctrl+V") | |||
| 372 | - | toast.reconcile() | |||
| 373 | - | ||||
| 374 | - | select { | |||
| 375 | - | case got := <-out.completed: | |||
| 376 | - | t.Fatalf("reconcile wrote terminal bytes: %q", got) | |||
| 377 | - | default: | |||
| 378 | - | } | |||
| 379 | } | 366 | } | ||
| 380 | 367 | ||||
| 381 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | 368 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | ||
M internal/usecase/daemon/client.go +41 -1
| @@ -575,7 +575,7 @@ func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecyc | |||
| 575 | 575 | if !blockedRender { | |
| 576 | 576 | // A healthy idle client observes the required replacement notice. Its | |
| 577 | 577 | // watchdog still bounds a peer that stops draining after this check. | |
| 578 | - | d.boundedSend(old, frameDetached(ports.ReasonDetach)) | |
| 578 | + | d.boundedSend(old, frameDetached(ports.ReasonReplaced)) | |
| 579 | 579 | } | |
| 580 | 580 | _ = old.closeCapturedTransport(old.revokeTransport(oldTransport)) | |
| 581 | 581 | d.unregisterPreview(old) | |
| @@ -665,6 +665,12 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 665 | 665 | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | |
| 666 | 666 | d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip) | |
| 667 | 667 | } | |
| 668 | + | case ports.MsgClientNotice: | |
| 669 | + | if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil { | |
| 670 | + | d.handleClientNotice(sess, ac, notice) | |
| 671 | + | } else { | |
| 672 | + | d.log.Warn("malformed client notice", "err", derr) | |
| 673 | + | } | |
| 668 | 674 | case ports.MsgDetach: | |
| 669 | 675 | d.clientGone(sess, ac, tr, true) | |
| 670 | 676 | return | |
| @@ -754,6 +760,40 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 754 | 760 | d.requestTransactionalResize(sess, ac, sz, false) | |
| 755 | 761 | } | |
| 756 | 762 | ||
| 763 | + | // handleClientNotice maps the closed client-event enum to daemon-owned notice | |
| 764 | + | // content. The connection loop has already bound sess and ac to this transport; | |
| 765 | + | // verify that ownership is still current before changing presentation state. | |
| 766 | + | func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) { | |
| 767 | + | sess.mu.Lock() | |
| 768 | + | current := sess.client == ac | |
| 769 | + | sess.mu.Unlock() | |
| 770 | + | if !current { | |
| 771 | + | return | |
| 772 | + | } | |
| 773 | + | ||
| 774 | + | switch notice.Action { | |
| 775 | + | case ports.ClientNoticeClipboardFallback: | |
| 776 | + | d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V") | |
| 777 | + | case ports.ClientNoticeClipboardTooLarge: | |
| 778 | + | d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste") | |
| 779 | + | case ports.ClientNoticeLinkDegraded: | |
| 780 | + | d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded") | |
| 781 | + | case ports.ClientNoticeLinkConnected: | |
| 782 | + | d.dismissToast(ac, domain.NoticeConnection, sess.id) | |
| 783 | + | } | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) { | |
| 787 | + | n := d.notices.record(domain.Notification{ | |
| 788 | + | Code: code, | |
| 789 | + | Severity: sev, | |
| 790 | + | Message: message, | |
| 791 | + | Time: d.clock.Now(), | |
| 792 | + | SessionID: sess.id, | |
| 793 | + | }) | |
| 794 | + | d.showToast(ac, n) | |
| 795 | + | } | |
| 796 | + | ||
| 757 | 797 | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | |
| 758 | 798 | // returned value reports whether the synchronous request was accepted. | |
| 759 | 799 | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool { | |
| @@ -575,7 +575,7 @@ func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecyc | |||||
| 575 | if !blockedRender { | 575 | if !blockedRender { | ||
| 576 | // A healthy idle client observes the required replacement notice. Its | 576 | // A healthy idle client observes the required replacement notice. Its | ||
| 577 | // watchdog still bounds a peer that stops draining after this check. | 577 | // watchdog still bounds a peer that stops draining after this check. | ||
| 578 | - | d.boundedSend(old, frameDetached(ports.ReasonDetach)) | 578 | + | d.boundedSend(old, frameDetached(ports.ReasonReplaced)) |
| 579 | } | 579 | } | ||
| 580 | _ = old.closeCapturedTransport(old.revokeTransport(oldTransport)) | 580 | _ = old.closeCapturedTransport(old.revokeTransport(oldTransport)) | ||
| 581 | d.unregisterPreview(old) | 581 | d.unregisterPreview(old) | ||
| @@ -665,6 +665,12 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 665 | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | 665 | if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil { | ||
| 666 | d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip) | 666 | d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip) | ||
| 667 | } | 667 | } | ||
| 668 | + | case ports.MsgClientNotice: | |||
| 669 | + | if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil { | |||
| 670 | + | d.handleClientNotice(sess, ac, notice) | |||
| 671 | + | } else { | |||
| 672 | + | d.log.Warn("malformed client notice", "err", derr) | |||
| 673 | + | } | |||
| 668 | case ports.MsgDetach: | 674 | case ports.MsgDetach: | ||
| 669 | d.clientGone(sess, ac, tr, true) | 675 | d.clientGone(sess, ac, tr, true) | ||
| 670 | return | 676 | return | ||
| @@ -754,6 +760,40 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 754 | d.requestTransactionalResize(sess, ac, sz, false) | 760 | d.requestTransactionalResize(sess, ac, sz, false) | ||
| 755 | } | 761 | } | ||
| 756 | 762 | ||||
| 763 | + | // handleClientNotice maps the closed client-event enum to daemon-owned notice | |||
| 764 | + | // content. The connection loop has already bound sess and ac to this transport; | |||
| 765 | + | // verify that ownership is still current before changing presentation state. | |||
| 766 | + | func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) { | |||
| 767 | + | sess.mu.Lock() | |||
| 768 | + | current := sess.client == ac | |||
| 769 | + | sess.mu.Unlock() | |||
| 770 | + | if !current { | |||
| 771 | + | return | |||
| 772 | + | } | |||
| 773 | + | ||||
| 774 | + | switch notice.Action { | |||
| 775 | + | case ports.ClientNoticeClipboardFallback: | |||
| 776 | + | d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V") | |||
| 777 | + | case ports.ClientNoticeClipboardTooLarge: | |||
| 778 | + | d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste") | |||
| 779 | + | case ports.ClientNoticeLinkDegraded: | |||
| 780 | + | d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded") | |||
| 781 | + | case ports.ClientNoticeLinkConnected: | |||
| 782 | + | d.dismissToast(ac, domain.NoticeConnection, sess.id) | |||
| 783 | + | } | |||
| 784 | + | } | |||
| 785 | + | ||||
| 786 | + | func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) { | |||
| 787 | + | n := d.notices.record(domain.Notification{ | |||
| 788 | + | Code: code, | |||
| 789 | + | Severity: sev, | |||
| 790 | + | Message: message, | |||
| 791 | + | Time: d.clock.Now(), | |||
| 792 | + | SessionID: sess.id, | |||
| 793 | + | }) | |||
| 794 | + | d.showToast(ac, n) | |||
| 795 | + | } | |||
| 796 | + | ||||
| 757 | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | 797 | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | ||
| 758 | // returned value reports whether the synchronous request was accepted. | 798 | // returned value reports whether the synchronous request was accepted. | ||
| 759 | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool { | 799 | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool { | ||
M internal/usecase/daemon/notify.go +22 -0
| @@ -269,6 +269,28 @@ func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain. | |||
| 269 | 269 | return -1 | |
| 270 | 270 | } | |
| 271 | 271 | ||
| 272 | + | // dismissToast removes only the specified visible toast. In particular, a | |
| 273 | + | // link-connected event must not clear clipboard or other notice state. | |
| 274 | + | func (d *Daemon) dismissToast(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) { | |
| 275 | + | if ac == nil || ac.overlays == nil { | |
| 276 | + | return | |
| 277 | + | } | |
| 278 | + | rt := ac.overlays | |
| 279 | + | rt.noticeMu.Lock() | |
| 280 | + | i := rt.indexOfToastLocked(code, sid) | |
| 281 | + | if i < 0 { | |
| 282 | + | rt.noticeMu.Unlock() | |
| 283 | + | return | |
| 284 | + | } | |
| 285 | + | rt.noticeToasts[i].timer.stop() | |
| 286 | + | rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...) | |
| 287 | + | if len(rt.noticeToasts) == 0 { | |
| 288 | + | rt.noticeOverflow = 0 | |
| 289 | + | } | |
| 290 | + | rt.noticeMu.Unlock() | |
| 291 | + | d.repaintForNotice(ac) | |
| 292 | + | } | |
| 293 | + | ||
| 272 | 294 | // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the | |
| 273 | 295 | // timer goroutine re-acquires it and releases it before repainting, so the | |
| 274 | 296 | // callback never runs with noticeMu held. | |
| @@ -269,6 +269,28 @@ func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain. | |||||
| 269 | return -1 | 269 | return -1 | ||
| 270 | } | 270 | } | ||
| 271 | 271 | ||||
| 272 | + | // dismissToast removes only the specified visible toast. In particular, a | |||
| 273 | + | // link-connected event must not clear clipboard or other notice state. | |||
| 274 | + | func (d *Daemon) dismissToast(ac *attachedClient, code domain.NoticeCode, sid domain.SessionID) { | |||
| 275 | + | if ac == nil || ac.overlays == nil { | |||
| 276 | + | return | |||
| 277 | + | } | |||
| 278 | + | rt := ac.overlays | |||
| 279 | + | rt.noticeMu.Lock() | |||
| 280 | + | i := rt.indexOfToastLocked(code, sid) | |||
| 281 | + | if i < 0 { | |||
| 282 | + | rt.noticeMu.Unlock() | |||
| 283 | + | return | |||
| 284 | + | } | |||
| 285 | + | rt.noticeToasts[i].timer.stop() | |||
| 286 | + | rt.noticeToasts = append(rt.noticeToasts[:i], rt.noticeToasts[i+1:]...) | |||
| 287 | + | if len(rt.noticeToasts) == 0 { | |||
| 288 | + | rt.noticeOverflow = 0 | |||
| 289 | + | } | |||
| 290 | + | rt.noticeMu.Unlock() | |||
| 291 | + | d.repaintForNotice(ac) | |||
| 292 | + | } | |||
| 293 | + | ||||
| 272 | // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the | 294 | // retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the | ||
| 273 | // timer goroutine re-acquires it and releases it before repainting, so the | 295 | // timer goroutine re-acquires it and releases it before repainting, so the | ||
| 274 | // callback never runs with noticeMu held. | 296 | // callback never runs with noticeMu held. | ||
M internal/usecase/daemon/notify_test.go +49 -0
| @@ -14,8 +14,57 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/vev/internal/domain" | |
| 16 | 16 | "github.com/bnema/vev/internal/ports" | |
| 17 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| 20 | + | func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) { | |
| 21 | + | d, sess, ac, _ := newNoticeFixture(t, ¬iceClock{}) | |
| 22 | + | d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback}) | |
| 23 | + | d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardTooLarge}) | |
| 24 | + | d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded}) | |
| 25 | + | ||
| 26 | + | toasts, _ := visibleToasts(ac) | |
| 27 | + | require.Len(t, toasts, 3) | |
| 28 | + | require.Equal(t, []domain.Notification{ | |
| 29 | + | {Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "connection degraded", SessionID: sess.id, Count: 1}, | |
| 30 | + | {Code: domain.NoticeClipboardTooLarge, Severity: domain.NoticeWarn, Message: "image too large to paste", SessionID: sess.id, Count: 1}, | |
| 31 | + | {Code: domain.NoticeClipboard, Severity: domain.NoticeError, Message: "image paste failed; sent Ctrl+V", SessionID: sess.id, Count: 1}, | |
| 32 | + | }, stripNoticeTimes(toasts)) | |
| 33 | + | ||
| 34 | + | d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeLinkConnected}) | |
| 35 | + | toasts, _ = visibleToasts(ac) | |
| 36 | + | require.Len(t, toasts, 2) | |
| 37 | + | for _, toast := range toasts { | |
| 38 | + | require.NotEqual(t, domain.NoticeConnection, toast.Code) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestMalformedClientNoticeIsIgnored(t *testing.T) { | |
| 43 | + | d, _, ac, _ := newNoticeFixture(t, ¬iceClock{}) | |
| 44 | + | tr := ac.tr.(*portsmocks.MockTransport) | |
| 45 | + | frames := []ports.Frame{ | |
| 46 | + | {Type: ports.MsgClientNotice, Payload: []byte{0xff}}, | |
| 47 | + | {Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}, | |
| 48 | + | } | |
| 49 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 50 | + | frame := frames[0] | |
| 51 | + | frames = frames[1:] | |
| 52 | + | return frame, nil | |
| 53 | + | }).Times(2) | |
| 54 | + | d.runConnLoop(ac) | |
| 55 | + | toasts, _ := visibleToasts(ac) | |
| 56 | + | require.Empty(t, toasts) | |
| 57 | + | require.Empty(t, d.notices.history()) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func stripNoticeTimes(notices []domain.Notification) []domain.Notification { | |
| 61 | + | out := append([]domain.Notification(nil), notices...) | |
| 62 | + | for i := range out { | |
| 63 | + | out[i].Time = time.Time{} | |
| 64 | + | } | |
| 65 | + | return out | |
| 66 | + | } | |
| 67 | + | ||
| 19 | 68 | func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification { | |
| 20 | 69 | return domain.Notification{Code: code, Severity: domain.NoticeError, Message: msg, Time: time.Unix(1, 0), SessionID: sid} | |
| 21 | 70 | } | |
| @@ -14,8 +14,57 @@ import ( | |||||
| 14 | 14 | ||||
| 15 | "github.com/bnema/vev/internal/domain" | 15 | "github.com/bnema/vev/internal/domain" | ||
| 16 | "github.com/bnema/vev/internal/ports" | 16 | "github.com/bnema/vev/internal/ports" | ||
| 17 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 17 | ) | 18 | ) | ||
| 18 | 19 | ||||
| 20 | + | func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) { | |||
| 21 | + | d, sess, ac, _ := newNoticeFixture(t, ¬iceClock{}) | |||
| 22 | + | d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback}) | |||
| 23 | + | d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardTooLarge}) | |||
| 24 | + | d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded}) | |||
| 25 | + | ||||
| 26 | + | toasts, _ := visibleToasts(ac) | |||
| 27 | + | require.Len(t, toasts, 3) | |||
| 28 | + | require.Equal(t, []domain.Notification{ | |||
| 29 | + | {Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "connection degraded", SessionID: sess.id, Count: 1}, | |||
| 30 | + | {Code: domain.NoticeClipboardTooLarge, Severity: domain.NoticeWarn, Message: "image too large to paste", SessionID: sess.id, Count: 1}, | |||
| 31 | + | {Code: domain.NoticeClipboard, Severity: domain.NoticeError, Message: "image paste failed; sent Ctrl+V", SessionID: sess.id, Count: 1}, | |||
| 32 | + | }, stripNoticeTimes(toasts)) | |||
| 33 | + | ||||
| 34 | + | d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeLinkConnected}) | |||
| 35 | + | toasts, _ = visibleToasts(ac) | |||
| 36 | + | require.Len(t, toasts, 2) | |||
| 37 | + | for _, toast := range toasts { | |||
| 38 | + | require.NotEqual(t, domain.NoticeConnection, toast.Code) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestMalformedClientNoticeIsIgnored(t *testing.T) { | |||
| 43 | + | d, _, ac, _ := newNoticeFixture(t, ¬iceClock{}) | |||
| 44 | + | tr := ac.tr.(*portsmocks.MockTransport) | |||
| 45 | + | frames := []ports.Frame{ | |||
| 46 | + | {Type: ports.MsgClientNotice, Payload: []byte{0xff}}, | |||
| 47 | + | {Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}, | |||
| 48 | + | } | |||
| 49 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 50 | + | frame := frames[0] | |||
| 51 | + | frames = frames[1:] | |||
| 52 | + | return frame, nil | |||
| 53 | + | }).Times(2) | |||
| 54 | + | d.runConnLoop(ac) | |||
| 55 | + | toasts, _ := visibleToasts(ac) | |||
| 56 | + | require.Empty(t, toasts) | |||
| 57 | + | require.Empty(t, d.notices.history()) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func stripNoticeTimes(notices []domain.Notification) []domain.Notification { | |||
| 61 | + | out := append([]domain.Notification(nil), notices...) | |||
| 62 | + | for i := range out { | |||
| 63 | + | out[i].Time = time.Time{} | |||
| 64 | + | } | |||
| 65 | + | return out | |||
| 66 | + | } | |||
| 67 | + | ||||
| 19 | func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification { | 68 | func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification { | ||
| 20 | return domain.Notification{Code: code, Severity: domain.NoticeError, Message: msg, Time: time.Unix(1, 0), SessionID: sid} | 69 | return domain.Notification{Code: code, Severity: domain.NoticeError, Message: msg, Time: time.Unix(1, 0), SessionID: sid} | ||
| 21 | } | 70 | } | ||
M internal/usecase/daemon/picker.go +1 -1
| @@ -434,7 +434,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||
| 434 | 434 | d.unregisterPreview(old) | |
| 435 | 435 | old.clearPreviousSession() | |
| 436 | 436 | old.setSession(nil) | |
| 437 | - | d.notifyDetachedAsync(old, ports.ReasonDetach) | |
| 437 | + | d.notifyDetachedAsync(old, ports.ReasonReplaced) | |
| 438 | 438 | } | |
| 439 | 439 | if targetSess == from { | |
| 440 | 440 | d.activateTab(from, from.activeTab()) | |
| @@ -434,7 +434,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||||
| 434 | d.unregisterPreview(old) | 434 | d.unregisterPreview(old) | ||
| 435 | old.clearPreviousSession() | 435 | old.clearPreviousSession() | ||
| 436 | old.setSession(nil) | 436 | old.setSession(nil) | ||
| 437 | - | d.notifyDetachedAsync(old, ports.ReasonDetach) | 437 | + | d.notifyDetachedAsync(old, ports.ReasonReplaced) |
| 438 | } | 438 | } | ||
| 439 | if targetSess == from { | 439 | if targetSess == from { | ||
| 440 | d.activateTab(from, from.activeTab()) | 440 | d.activateTab(from, from.activeTab()) | ||
M internal/usecase/daemon/picker_test.go +1 -1
| @@ -399,7 +399,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||
| 399 | 399 | det := awaitFrame(t, sends2, ports.MsgDetached) | |
| 400 | 400 | dm, err := ports.UnmarshalDetached(det.Payload) | |
| 401 | 401 | require.NoError(t, err) | |
| 402 | - | require.Equal(t, ports.ReasonDetach, dm.Reason) | |
| 402 | + | require.Equal(t, ports.ReasonReplaced, dm.Reason) | |
| 403 | 403 | awaitFrame(t, sends1, ports.MsgOutput) | |
| 404 | 404 | } | |
| 405 | 405 | ||
| @@ -399,7 +399,7 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||||
| 399 | det := awaitFrame(t, sends2, ports.MsgDetached) | 399 | det := awaitFrame(t, sends2, ports.MsgDetached) | ||
| 400 | dm, err := ports.UnmarshalDetached(det.Payload) | 400 | dm, err := ports.UnmarshalDetached(det.Payload) | ||
| 401 | require.NoError(t, err) | 401 | require.NoError(t, err) | ||
| 402 | - | require.Equal(t, ports.ReasonDetach, dm.Reason) | 402 | + | require.Equal(t, ports.ReasonReplaced, dm.Reason) |
| 403 | awaitFrame(t, sends1, ports.MsgOutput) | 403 | awaitFrame(t, sends1, ports.MsgOutput) | ||
| 404 | } | 404 | } | ||
| 405 | 405 | ||||
M internal/usecase/daemon/session_test.go +1 -1
| @@ -306,7 +306,7 @@ func TestAttachReplaceDetachesOld(t *testing.T) { | |||
| 306 | 306 | // A is notified it was detached. | |
| 307 | 307 | dA := awaitFrame(t, sendsA, ports.MsgDetached) | |
| 308 | 308 | det, _ := ports.UnmarshalDetached(dA.Payload) | |
| 309 | - | require.Equal(t, ports.ReasonDetach, det.Reason) | |
| 309 | + | require.Equal(t, ports.ReasonReplaced, det.Reason) | |
| 310 | 310 | ||
| 311 | 311 | // B is now the current client. | |
| 312 | 312 | sess.mu.Lock() | |
| @@ -306,7 +306,7 @@ func TestAttachReplaceDetachesOld(t *testing.T) { | |||||
| 306 | // A is notified it was detached. | 306 | // A is notified it was detached. | ||
| 307 | dA := awaitFrame(t, sendsA, ports.MsgDetached) | 307 | dA := awaitFrame(t, sendsA, ports.MsgDetached) | ||
| 308 | det, _ := ports.UnmarshalDetached(dA.Payload) | 308 | det, _ := ports.UnmarshalDetached(dA.Payload) | ||
| 309 | - | require.Equal(t, ports.ReasonDetach, det.Reason) | 309 | + | require.Equal(t, ports.ReasonReplaced, det.Reason) |
| 310 | 310 | ||||
| 311 | // B is now the current client. | 311 | // B is now the current client. | ||
| 312 | sess.mu.Lock() | 312 | sess.mu.Lock() | ||