fix(client): restore stopped session handoffs (#223)
- commit
- d77542a048033c416705fe9bec546a27f9f76158
- author
- bnema · · 2026-08-27 08:02 UTC
- committer
- GitHub · · 2026-08-27 08:02 UTC
- parent
- 7dfd596
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
13 files · +178 -31
M internal/ports/frame.go +1 -1
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | const HandshakeTimeout = 15 * time.Second | |
| 13 | 13 | ||
| 14 | 14 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 15 | - | const ProtocolVersion uint16 = 36 | |
| 15 | + | const ProtocolVersion uint16 = 37 | |
| 16 | 16 | ||
| 17 | 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | |
| 18 | 18 | // and excluding the four-byte length prefix. | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | const HandshakeTimeout = 15 * time.Second | 12 | const HandshakeTimeout = 15 * time.Second | ||
| 13 | 13 | ||||
| 14 | // ProtocolVersion is the current vev IPC wire protocol version. | 14 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 15 | - | const ProtocolVersion uint16 = 36 | 15 | + | const ProtocolVersion uint16 = 37 |
| 16 | 16 | ||||
| 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | ||
| 18 | // and excluding the four-byte length prefix. | 18 | // and excluding the four-byte length prefix. | ||
M internal/ports/frame_test.go +2 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 36 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 36", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 37 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 37", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -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 != 36 { | 6 | + | if ProtocolVersion != 37 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 36", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 37", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
M internal/ports/navigation_test.go +1 -1
| @@ -152,7 +152,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||
| 152 | 152 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | |
| 153 | 153 | {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | |
| 154 | 154 | {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | |
| 155 | - | {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002402" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, | |
| 155 | + | {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002502" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, | |
| 156 | 156 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | |
| 157 | 157 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | |
| 158 | 158 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | |
| @@ -152,7 +152,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||||
| 152 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | 152 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | ||
| 153 | {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | 153 | {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | ||
| 154 | {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | 154 | {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false}, | ||
| 155 | - | {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002402" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, | 155 | + | {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "002502" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, |
| 156 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | 156 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | ||
| 157 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | 157 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | ||
| 158 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | 158 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | ||
M internal/ports/remote_preview_hardening_test.go +1 -1
| @@ -200,7 +200,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) { | |||
| 200 | 200 | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | |
| 201 | 201 | 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, | |
| 202 | 202 | 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01, | |
| 203 | - | 0x00, | |
| 203 | + | 0x00, 0x00, | |
| 204 | 204 | } | |
| 205 | 205 | if !bytes.Equal(got, want) { | |
| 206 | 206 | t.Fatalf("rich target bytes = %x, want %x", got, want) | |
| @@ -200,7 +200,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) { | |||||
| 200 | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | 200 | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | ||
| 201 | 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, | 201 | 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, | ||
| 202 | 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01, | 202 | 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01, | ||
| 203 | - | 0x00, | 203 | + | 0x00, 0x00, |
| 204 | } | 204 | } | ||
| 205 | if !bytes.Equal(got, want) { | 205 | if !bytes.Equal(got, want) { | ||
| 206 | t.Fatalf("rich target bytes = %x, want %x", got, want) | 206 | t.Fatalf("rich target bytes = %x, want %x", got, want) | ||
M internal/ports/remote_target_wire_test.go +2 -2
| @@ -139,7 +139,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) { | |||
| 139 | 139 | }) | |
| 140 | 140 | }, | |
| 141 | 141 | unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err }, | |
| 142 | - | policyOffset: 2, | |
| 142 | + | policyOffset: 3, | |
| 143 | 143 | wantErr: ErrInvalidAttachTarget, | |
| 144 | 144 | }, | |
| 145 | 145 | } | |
| @@ -195,7 +195,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) { | |||
| 195 | 195 | } | |
| 196 | 196 | ||
| 197 | 197 | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { | |
| 198 | - | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0} | |
| 198 | + | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0, 0} | |
| 199 | 199 | got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}) | |
| 200 | 200 | if !bytes.Equal(got, want) { | |
| 201 | 201 | t.Fatalf("target bytes = %x, want %x", got, want) | |
| @@ -139,7 +139,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) { | |||||
| 139 | }) | 139 | }) | ||
| 140 | }, | 140 | }, | ||
| 141 | unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err }, | 141 | unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err }, | ||
| 142 | - | policyOffset: 2, | 142 | + | policyOffset: 3, |
| 143 | wantErr: ErrInvalidAttachTarget, | 143 | wantErr: ErrInvalidAttachTarget, | ||
| 144 | }, | 144 | }, | ||
| 145 | } | 145 | } | ||
| @@ -195,7 +195,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) { | |||||
| 195 | } | 195 | } | ||
| 196 | 196 | ||||
| 197 | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { | 197 | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { | ||
| 198 | - | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0} | 198 | + | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0, 0} |
| 199 | got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}) | 199 | got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}) | ||
| 200 | if !bytes.Equal(got, want) { | 200 | if !bytes.Equal(got, want) { | ||
| 201 | t.Fatalf("target bytes = %x, want %x", got, want) | 201 | t.Fatalf("target bytes = %x, want %x", got, want) | ||
M internal/ports/wire.go +11 -0
| @@ -338,6 +338,7 @@ type AttachTarget struct { | |||
| 338 | 338 | RemoteTarget *domain.RemoteSessionTarget | |
| 339 | 339 | ExactTarget *ExactSessionTarget | |
| 340 | 340 | EnvironmentPolicy EnvironmentPolicy | |
| 341 | + | SamePeer bool | |
| 341 | 342 | } | |
| 342 | 343 | ||
| 343 | 344 | // Detached tells a client it has been disconnected from its session and why. | |
| @@ -867,6 +868,9 @@ func ValidateAttachTarget(m AttachTarget) error { | |||
| 867 | 868 | if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) { | |
| 868 | 869 | return ErrInvalidAttachTarget | |
| 869 | 870 | } | |
| 871 | + | if m.SamePeer && (m.Endpoint != "" || m.RemoteTarget != nil || m.ExactTarget == nil) { | |
| 872 | + | return ErrInvalidAttachTarget | |
| 873 | + | } | |
| 870 | 874 | if m.RemoteTarget == nil { | |
| 871 | 875 | return nil | |
| 872 | 876 | } | |
| @@ -1662,6 +1666,7 @@ func MarshalAttachTarget(m AttachTarget) []byte { | |||
| 1662 | 1666 | w.putUint8(m.Intent) | |
| 1663 | 1667 | marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy) | |
| 1664 | 1668 | marshalExactTargetSection(&w, m.ExactTarget) | |
| 1669 | + | w.putBool(m.SamePeer) | |
| 1665 | 1670 | return w.b | |
| 1666 | 1671 | } | |
| 1667 | 1672 | ||
| @@ -1689,6 +1694,9 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||
| 1689 | 1694 | if err := skipExactTargetSection(&probe); err != nil { | |
| 1690 | 1695 | return AttachTarget{}, ErrInvalidAttachTarget | |
| 1691 | 1696 | } | |
| 1697 | + | if _, err := probe.getBool(); err != nil { | |
| 1698 | + | return AttachTarget{}, ErrInvalidAttachTarget | |
| 1699 | + | } | |
| 1692 | 1700 | if err := probe.done(); err != nil { | |
| 1693 | 1701 | return AttachTarget{}, ErrInvalidAttachTarget | |
| 1694 | 1702 | } | |
| @@ -1710,6 +1718,9 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||
| 1710 | 1718 | if m.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil { | |
| 1711 | 1719 | return AttachTarget{}, err | |
| 1712 | 1720 | } | |
| 1721 | + | if m.SamePeer, err = r.getBool(); err != nil { | |
| 1722 | + | return AttachTarget{}, err | |
| 1723 | + | } | |
| 1713 | 1724 | if err := r.done(); err != nil { | |
| 1714 | 1725 | return AttachTarget{}, err | |
| 1715 | 1726 | } | |
| @@ -338,6 +338,7 @@ type AttachTarget struct { | |||||
| 338 | RemoteTarget *domain.RemoteSessionTarget | 338 | RemoteTarget *domain.RemoteSessionTarget | ||
| 339 | ExactTarget *ExactSessionTarget | 339 | ExactTarget *ExactSessionTarget | ||
| 340 | EnvironmentPolicy EnvironmentPolicy | 340 | EnvironmentPolicy EnvironmentPolicy | ||
| 341 | + | SamePeer bool | |||
| 341 | } | 342 | } | ||
| 342 | 343 | ||||
| 343 | // Detached tells a client it has been disconnected from its session and why. | 344 | // Detached tells a client it has been disconnected from its session and why. | ||
| @@ -867,6 +868,9 @@ func ValidateAttachTarget(m AttachTarget) error { | |||||
| 867 | if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) { | 868 | if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) { | ||
| 868 | return ErrInvalidAttachTarget | 869 | return ErrInvalidAttachTarget | ||
| 869 | } | 870 | } | ||
| 871 | + | if m.SamePeer && (m.Endpoint != "" || m.RemoteTarget != nil || m.ExactTarget == nil) { | |||
| 872 | + | return ErrInvalidAttachTarget | |||
| 873 | + | } | |||
| 870 | if m.RemoteTarget == nil { | 874 | if m.RemoteTarget == nil { | ||
| 871 | return nil | 875 | return nil | ||
| 872 | } | 876 | } | ||
| @@ -1662,6 +1666,7 @@ func MarshalAttachTarget(m AttachTarget) []byte { | |||||
| 1662 | w.putUint8(m.Intent) | 1666 | w.putUint8(m.Intent) | ||
| 1663 | marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy) | 1667 | marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy) | ||
| 1664 | marshalExactTargetSection(&w, m.ExactTarget) | 1668 | marshalExactTargetSection(&w, m.ExactTarget) | ||
| 1669 | + | w.putBool(m.SamePeer) | |||
| 1665 | return w.b | 1670 | return w.b | ||
| 1666 | } | 1671 | } | ||
| 1667 | 1672 | ||||
| @@ -1689,6 +1694,9 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||||
| 1689 | if err := skipExactTargetSection(&probe); err != nil { | 1694 | if err := skipExactTargetSection(&probe); err != nil { | ||
| 1690 | return AttachTarget{}, ErrInvalidAttachTarget | 1695 | return AttachTarget{}, ErrInvalidAttachTarget | ||
| 1691 | } | 1696 | } | ||
| 1697 | + | if _, err := probe.getBool(); err != nil { | |||
| 1698 | + | return AttachTarget{}, ErrInvalidAttachTarget | |||
| 1699 | + | } | |||
| 1692 | if err := probe.done(); err != nil { | 1700 | if err := probe.done(); err != nil { | ||
| 1693 | return AttachTarget{}, ErrInvalidAttachTarget | 1701 | return AttachTarget{}, ErrInvalidAttachTarget | ||
| 1694 | } | 1702 | } | ||
| @@ -1710,6 +1718,9 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||||
| 1710 | if m.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil { | 1718 | if m.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil { | ||
| 1711 | return AttachTarget{}, err | 1719 | return AttachTarget{}, err | ||
| 1712 | } | 1720 | } | ||
| 1721 | + | if m.SamePeer, err = r.getBool(); err != nil { | |||
| 1722 | + | return AttachTarget{}, err | |||
| 1723 | + | } | |||
| 1713 | if err := r.done(); err != nil { | 1724 | if err := r.done(); err != nil { | ||
| 1714 | return AttachTarget{}, err | 1725 | return AttachTarget{}, err | ||
| 1715 | } | 1726 | } | ||
M internal/ports/wire_final_test.go +16 -7
| @@ -11,8 +11,8 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |
| 14 | - | if ProtocolVersion != 36 { | |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 36", ProtocolVersion) | |
| 14 | + | if ProtocolVersion != 37 { | |
| 15 | + | t.Fatalf("ProtocolVersion = %d, want 37", ProtocolVersion) | |
| 16 | 16 | } | |
| 17 | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 18 | 18 | if len(payload) < 2 { | |
| @@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 26 | 26 | ||
| 27 | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | |
| 28 | 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 29 | - | want := append([]byte{0, 36, 2}, make([]byte, 16+8)...) | |
| 29 | + | want := append([]byte{0, 37, 2}, make([]byte, 16+8)...) | |
| 30 | 30 | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | |
| 31 | 31 | want = append(want, 0, 0) | |
| 32 | 32 | got := MarshalHello(msg) | |
| @@ -195,7 +195,7 @@ func TestFinalAckGoldenStrict(t *testing.T) { | |||
| 195 | 195 | ||
| 196 | 196 | func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |
| 197 | 197 | msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach} | |
| 198 | - | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0} | |
| 198 | + | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0} | |
| 199 | 199 | got := MarshalAttachTarget(msg) | |
| 200 | 200 | if !bytes.Equal(got, want) { | |
| 201 | 201 | t.Fatalf("AttachTarget bytes = %x, want %x", got, want) | |
| @@ -211,7 +211,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||
| 211 | 211 | if localPayload == nil { | |
| 212 | 212 | t.Fatal("MarshalAttachTarget rejected same-peer route handoff") | |
| 213 | 213 | } | |
| 214 | - | wantLocal := []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0} | |
| 214 | + | wantLocal := []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0, 0} | |
| 215 | 215 | if !bytes.Equal(localPayload, wantLocal) { | |
| 216 | 216 | t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal) | |
| 217 | 217 | } | |
| @@ -231,10 +231,10 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||
| 231 | 231 | func TestAttachTargetExactTargetWireStrict(t *testing.T) { | |
| 232 | 232 | var lifecycle domain.SessionLifecycleID | |
| 233 | 233 | lifecycle[0] = 1 | |
| 234 | - | target := AttachTarget{Session: "work", Intent: IntentAttach, ExactTarget: &ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}} | |
| 234 | + | target := AttachTarget{Session: "work", Intent: IntentAttach, ExactTarget: &ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, SamePeer: true} | |
| 235 | 235 | payload := MarshalAttachTarget(target) | |
| 236 | 236 | want := append([]byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 1, 1}, make([]byte, 15)...) | |
| 237 | - | want = append(want, 0, 4, 'w', 'o', 'r', 'k') | |
| 237 | + | want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1) | |
| 238 | 238 | if !bytes.Equal(payload, want) { | |
| 239 | 239 | t.Fatalf("exact target bytes = %x, want %x", payload, want) | |
| 240 | 240 | } | |
| @@ -284,6 +284,15 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) { | |||
| 284 | 284 | }(), | |
| 285 | 285 | decode: func(b []byte) error { _, err := UnmarshalHello(b); return err }, | |
| 286 | 286 | }, | |
| 287 | + | { | |
| 288 | + | name: "attach target same-peer boolean", | |
| 289 | + | payload: func() []byte { | |
| 290 | + | b := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}) | |
| 291 | + | b[len(b)-1] = 2 | |
| 292 | + | return b | |
| 293 | + | }(), | |
| 294 | + | decode: func(b []byte) error { _, err := UnmarshalAttachTarget(b); return err }, | |
| 295 | + | }, | |
| 287 | 296 | { | |
| 288 | 297 | name: "welcome boolean", | |
| 289 | 298 | payload: func() []byte { | |
| @@ -11,8 +11,8 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | ||
| 14 | - | if ProtocolVersion != 36 { | 14 | + | if ProtocolVersion != 37 { |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 36", ProtocolVersion) | 15 | + | t.Fatalf("ProtocolVersion = %d, want 37", ProtocolVersion) |
| 16 | } | 16 | } | ||
| 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | ||
| 18 | if len(payload) < 2 { | 18 | if len(payload) < 2 { | ||
| @@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||||
| 26 | 26 | ||||
| 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | ||
| 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | ||
| 29 | - | want := append([]byte{0, 36, 2}, make([]byte, 16+8)...) | 29 | + | want := append([]byte{0, 37, 2}, make([]byte, 16+8)...) |
| 30 | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | 30 | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | ||
| 31 | want = append(want, 0, 0) | 31 | want = append(want, 0, 0) | ||
| 32 | got := MarshalHello(msg) | 32 | got := MarshalHello(msg) | ||
| @@ -195,7 +195,7 @@ func TestFinalAckGoldenStrict(t *testing.T) { | |||||
| 195 | 195 | ||||
| 196 | func TestFinalAttachTargetGoldenStrict(t *testing.T) { | 196 | func TestFinalAttachTargetGoldenStrict(t *testing.T) { | ||
| 197 | msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach} | 197 | msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach} | ||
| 198 | - | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0} | 198 | + | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0} |
| 199 | got := MarshalAttachTarget(msg) | 199 | got := MarshalAttachTarget(msg) | ||
| 200 | if !bytes.Equal(got, want) { | 200 | if !bytes.Equal(got, want) { | ||
| 201 | t.Fatalf("AttachTarget bytes = %x, want %x", got, want) | 201 | t.Fatalf("AttachTarget bytes = %x, want %x", got, want) | ||
| @@ -211,7 +211,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||||
| 211 | if localPayload == nil { | 211 | if localPayload == nil { | ||
| 212 | t.Fatal("MarshalAttachTarget rejected same-peer route handoff") | 212 | t.Fatal("MarshalAttachTarget rejected same-peer route handoff") | ||
| 213 | } | 213 | } | ||
| 214 | - | wantLocal := []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0} | 214 | + | wantLocal := []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 0, 0} |
| 215 | if !bytes.Equal(localPayload, wantLocal) { | 215 | if !bytes.Equal(localPayload, wantLocal) { | ||
| 216 | t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal) | 216 | t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal) | ||
| 217 | } | 217 | } | ||
| @@ -231,10 +231,10 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||||
| 231 | func TestAttachTargetExactTargetWireStrict(t *testing.T) { | 231 | func TestAttachTargetExactTargetWireStrict(t *testing.T) { | ||
| 232 | var lifecycle domain.SessionLifecycleID | 232 | var lifecycle domain.SessionLifecycleID | ||
| 233 | lifecycle[0] = 1 | 233 | lifecycle[0] = 1 | ||
| 234 | - | target := AttachTarget{Session: "work", Intent: IntentAttach, ExactTarget: &ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}} | 234 | + | target := AttachTarget{Session: "work", Intent: IntentAttach, ExactTarget: &ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, SamePeer: true} |
| 235 | payload := MarshalAttachTarget(target) | 235 | payload := MarshalAttachTarget(target) | ||
| 236 | want := append([]byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 1, 1}, make([]byte, 15)...) | 236 | want := append([]byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0, 1, 1}, make([]byte, 15)...) | ||
| 237 | - | want = append(want, 0, 4, 'w', 'o', 'r', 'k') | 237 | + | want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1) |
| 238 | if !bytes.Equal(payload, want) { | 238 | if !bytes.Equal(payload, want) { | ||
| 239 | t.Fatalf("exact target bytes = %x, want %x", payload, want) | 239 | t.Fatalf("exact target bytes = %x, want %x", payload, want) | ||
| 240 | } | 240 | } | ||
| @@ -284,6 +284,15 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) { | |||||
| 284 | }(), | 284 | }(), | ||
| 285 | decode: func(b []byte) error { _, err := UnmarshalHello(b); return err }, | 285 | decode: func(b []byte) error { _, err := UnmarshalHello(b); return err }, | ||
| 286 | }, | 286 | }, | ||
| 287 | + | { | |||
| 288 | + | name: "attach target same-peer boolean", | |||
| 289 | + | payload: func() []byte { | |||
| 290 | + | b := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}) | |||
| 291 | + | b[len(b)-1] = 2 | |||
| 292 | + | return b | |||
| 293 | + | }(), | |||
| 294 | + | decode: func(b []byte) error { _, err := UnmarshalAttachTarget(b); return err }, | |||
| 295 | + | }, | |||
| 287 | { | 296 | { | ||
| 288 | name: "welcome boolean", | 297 | name: "welcome boolean", | ||
| 289 | payload: func() []byte { | 298 | payload: func() []byte { | ||
M internal/ports/wire_test.go +6 -6
| @@ -135,7 +135,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 135 | 135 | } | |
| 136 | 136 | got := MarshalHello(msg) | |
| 137 | 137 | want := []byte{ | |
| 138 | - | 0x00, 0x24, 0x00, // version, intent, first client ID byte | |
| 138 | + | 0x00, 0x25, 0x00, // version, intent, first client ID byte | |
| 139 | 139 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 140 | 140 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 141 | 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -366,7 +366,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 366 | 366 | } | |
| 367 | 367 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 368 | 368 | require.Equal(t, want, MarshalTheme(cleared)) | |
| 369 | - | require.Equal(t, uint16(36), ProtocolVersion) | |
| 369 | + | require.Equal(t, uint16(37), ProtocolVersion) | |
| 370 | 370 | } | |
| 371 | 371 | ||
| 372 | 372 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -527,7 +527,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 527 | 527 | name: "minimal", | |
| 528 | 528 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 529 | 529 | want: []byte{ | |
| 530 | - | 0x00, 0x24, | |
| 530 | + | 0x00, 0x25, | |
| 531 | 531 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 532 | 532 | 0x00, // attached | |
| 533 | 533 | 0x00, // self | |
| @@ -550,7 +550,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 550 | 550 | JSON: true, | |
| 551 | 551 | }, | |
| 552 | 552 | want: []byte{ | |
| 553 | - | 0x00, 0x24, | |
| 553 | + | 0x00, 0x25, | |
| 554 | 554 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 555 | 555 | 0x00, // attached | |
| 556 | 556 | 0x01, // self | |
| @@ -979,7 +979,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 979 | 979 | name: "attached", | |
| 980 | 980 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 981 | 981 | want: []byte{ | |
| 982 | - | 0x00, 0x24, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 982 | + | 0x00, 0x25, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 983 | 983 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 984 | 984 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 985 | 985 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -989,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 989 | 989 | name: "control", | |
| 990 | 990 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | |
| 991 | 991 | want: []byte{ | |
| 992 | - | 0x00, 0x24, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 992 | + | 0x00, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 993 | 993 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 994 | 994 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 995 | 995 | }, | |
| @@ -135,7 +135,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 135 | } | 135 | } | ||
| 136 | got := MarshalHello(msg) | 136 | got := MarshalHello(msg) | ||
| 137 | want := []byte{ | 137 | want := []byte{ | ||
| 138 | - | 0x00, 0x24, 0x00, // version, intent, first client ID byte | 138 | + | 0x00, 0x25, 0x00, // version, intent, first client ID byte |
| 139 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 139 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 140 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | 140 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -366,7 +366,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 366 | } | 366 | } | ||
| 367 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 367 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 368 | require.Equal(t, want, MarshalTheme(cleared)) | 368 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 369 | - | require.Equal(t, uint16(36), ProtocolVersion) | 369 | + | require.Equal(t, uint16(37), ProtocolVersion) |
| 370 | } | 370 | } | ||
| 371 | 371 | ||||
| 372 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 372 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -527,7 +527,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 527 | name: "minimal", | 527 | name: "minimal", | ||
| 528 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | 528 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | ||
| 529 | want: []byte{ | 529 | want: []byte{ | ||
| 530 | - | 0x00, 0x24, | 530 | + | 0x00, 0x25, |
| 531 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 531 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 532 | 0x00, // attached | 532 | 0x00, // attached | ||
| 533 | 0x00, // self | 533 | 0x00, // self | ||
| @@ -550,7 +550,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 550 | JSON: true, | 550 | JSON: true, | ||
| 551 | }, | 551 | }, | ||
| 552 | want: []byte{ | 552 | want: []byte{ | ||
| 553 | - | 0x00, 0x24, | 553 | + | 0x00, 0x25, |
| 554 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 554 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 555 | 0x00, // attached | 555 | 0x00, // attached | ||
| 556 | 0x01, // self | 556 | 0x01, // self | ||
| @@ -979,7 +979,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 979 | name: "attached", | 979 | name: "attached", | ||
| 980 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | 980 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | ||
| 981 | want: []byte{ | 981 | want: []byte{ | ||
| 982 | - | 0x00, 0x24, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | 982 | + | 0x00, 0x25, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, |
| 983 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | 983 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | ||
| 984 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | 984 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | ||
| 985 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 985 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| @@ -989,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 989 | name: "control", | 989 | name: "control", | ||
| 990 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | 990 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | ||
| 991 | want: []byte{ | 991 | want: []byte{ | ||
| 992 | - | 0x00, 0x24, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 992 | + | 0x00, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, |
| 993 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | 993 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | ||
| 994 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 994 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 995 | }, | 995 | }, | ||
M internal/usecase/client/client.go +1 -1
| @@ -1959,7 +1959,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1959 | 1959 | transitionWaitingFull = false | |
| 1960 | 1960 | transition.start(target) | |
| 1961 | 1961 | } | |
| 1962 | - | if target.Endpoint == "" && target.RemoteTarget == nil && target.ExactTarget != nil && a.runner.ledger != nil { | |
| 1962 | + | if target.SamePeer && target.Endpoint == "" && target.RemoteTarget == nil && target.ExactTarget != nil && a.runner.ledger != nil { | |
| 1963 | 1963 | if samePeerSwitch != nil { | |
| 1964 | 1964 | return welcomedResult(errors.New("vev: concurrent same-peer switch offer")) | |
| 1965 | 1965 | } | |
| @@ -1959,7 +1959,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1959 | transitionWaitingFull = false | 1959 | transitionWaitingFull = false | ||
| 1960 | transition.start(target) | 1960 | transition.start(target) | ||
| 1961 | } | 1961 | } | ||
| 1962 | - | if target.Endpoint == "" && target.RemoteTarget == nil && target.ExactTarget != nil && a.runner.ledger != nil { | 1962 | + | if target.SamePeer && target.Endpoint == "" && target.RemoteTarget == nil && target.ExactTarget != nil && a.runner.ledger != nil { |
| 1963 | if samePeerSwitch != nil { | 1963 | if samePeerSwitch != nil { | ||
| 1964 | return welcomedResult(errors.New("vev: concurrent same-peer switch offer")) | 1964 | return welcomedResult(errors.New("vev: concurrent same-peer switch offer")) | ||
| 1965 | } | 1965 | } | ||
M internal/usecase/client/client_test.go +40 -1
| @@ -496,7 +496,7 @@ func TestLocalOnlyHandoffBetweenLocalSessionsKeepsClientRunning(t *testing.T) { | |||
| 496 | 496 | {f: welcome("first", firstLifecycle)}, | |
| 497 | 497 | {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{ | |
| 498 | 498 | Session: "second", Intent: ports.IntentAttach, ExactTarget: &secondTarget, | |
| 499 | - | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 499 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, SamePeer: true, | |
| 500 | 500 | }))}, | |
| 501 | 501 | {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{Target: secondTarget}))}, | |
| 502 | 502 | {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | |
| @@ -542,6 +542,45 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) { | |||
| 542 | 542 | require.Equal(t, request.StartupOverlay, hello.StartupOverlay) | |
| 543 | 543 | } | |
| 544 | 544 | ||
| 545 | + | func TestStoppedLocalHandoffDialsReplacementTransport(t *testing.T) { | |
| 546 | + | term := newRunTerminal() | |
| 547 | + | defer term.in.unblock() | |
| 548 | + | ||
| 549 | + | sourceLifecycle := domain.SessionLifecycleID{1} | |
| 550 | + | targetLifecycle := domain.SessionLifecycleID{2} | |
| 551 | + | target := ports.ExactSessionTarget{LifecycleID: targetLifecycle, SessionName: "stopped"} | |
| 552 | + | welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame { | |
| 553 | + | return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{ | |
| 554 | + | SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume, | |
| 555 | + | CommittedIdentity: &ports.CommittedRouteIdentity{ | |
| 556 | + | Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}, | |
| 557 | + | }, | |
| 558 | + | })) | |
| 559 | + | } | |
| 560 | + | first := &recordingTransport{recvs: []recvItem{ | |
| 561 | + | {f: welcome("source", sourceLifecycle)}, | |
| 562 | + | {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{ | |
| 563 | + | Session: "stopped", Intent: ports.IntentAttach, ExactTarget: &target, | |
| 564 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 565 | + | }))}, | |
| 566 | + | }} | |
| 567 | + | second := &recordingTransport{recvs: []recvItem{ | |
| 568 | + | {f: welcome("stopped", targetLifecycle)}, | |
| 569 | + | {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | |
| 570 | + | }} | |
| 571 | + | dialer := &sequenceDialer{trs: []ports.Transport{first, second}} | |
| 572 | + | ||
| 573 | + | err := runTestClient(context.Background(), testDependencies(dialer, term, realClock{}, nil, nil), client.AttachRequest{ | |
| 574 | + | Intent: ports.IntentAttach, SessionName: "source", | |
| 575 | + | Origin: ports.RouteOriginLocal, OriginKey: "local", | |
| 576 | + | EnvironmentPolicy: ports.EnvironmentPolicyClientOwned, | |
| 577 | + | }) | |
| 578 | + | ||
| 579 | + | require.NoError(t, err) | |
| 580 | + | require.Equal(t, int32(2), dialer.calls.Load()) | |
| 581 | + | require.Equal(t, &target, helloFromSend(t, second).ExactTarget) | |
| 582 | + | } | |
| 583 | + | ||
| 545 | 584 | func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testing.T) { | |
| 546 | 585 | term := newRunTerminal() | |
| 547 | 586 | defer term.in.unblock() | |
| @@ -496,7 +496,7 @@ func TestLocalOnlyHandoffBetweenLocalSessionsKeepsClientRunning(t *testing.T) { | |||||
| 496 | {f: welcome("first", firstLifecycle)}, | 496 | {f: welcome("first", firstLifecycle)}, | ||
| 497 | {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{ | 497 | {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{ | ||
| 498 | Session: "second", Intent: ports.IntentAttach, ExactTarget: &secondTarget, | 498 | Session: "second", Intent: ports.IntentAttach, ExactTarget: &secondTarget, | ||
| 499 | - | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | 499 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, SamePeer: true, |
| 500 | }))}, | 500 | }))}, | ||
| 501 | {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{Target: secondTarget}))}, | 501 | {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{Target: secondTarget}))}, | ||
| 502 | {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | 502 | {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | ||
| @@ -542,6 +542,45 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) { | |||||
| 542 | require.Equal(t, request.StartupOverlay, hello.StartupOverlay) | 542 | require.Equal(t, request.StartupOverlay, hello.StartupOverlay) | ||
| 543 | } | 543 | } | ||
| 544 | 544 | ||||
| 545 | + | func TestStoppedLocalHandoffDialsReplacementTransport(t *testing.T) { | |||
| 546 | + | term := newRunTerminal() | |||
| 547 | + | defer term.in.unblock() | |||
| 548 | + | ||||
| 549 | + | sourceLifecycle := domain.SessionLifecycleID{1} | |||
| 550 | + | targetLifecycle := domain.SessionLifecycleID{2} | |||
| 551 | + | target := ports.ExactSessionTarget{LifecycleID: targetLifecycle, SessionName: "stopped"} | |||
| 552 | + | welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame { | |||
| 553 | + | return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{ | |||
| 554 | + | SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume, | |||
| 555 | + | CommittedIdentity: &ports.CommittedRouteIdentity{ | |||
| 556 | + | Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}, | |||
| 557 | + | }, | |||
| 558 | + | })) | |||
| 559 | + | } | |||
| 560 | + | first := &recordingTransport{recvs: []recvItem{ | |||
| 561 | + | {f: welcome("source", sourceLifecycle)}, | |||
| 562 | + | {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{ | |||
| 563 | + | Session: "stopped", Intent: ports.IntentAttach, ExactTarget: &target, | |||
| 564 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |||
| 565 | + | }))}, | |||
| 566 | + | }} | |||
| 567 | + | second := &recordingTransport{recvs: []recvItem{ | |||
| 568 | + | {f: welcome("stopped", targetLifecycle)}, | |||
| 569 | + | {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | |||
| 570 | + | }} | |||
| 571 | + | dialer := &sequenceDialer{trs: []ports.Transport{first, second}} | |||
| 572 | + | ||||
| 573 | + | err := runTestClient(context.Background(), testDependencies(dialer, term, realClock{}, nil, nil), client.AttachRequest{ | |||
| 574 | + | Intent: ports.IntentAttach, SessionName: "source", | |||
| 575 | + | Origin: ports.RouteOriginLocal, OriginKey: "local", | |||
| 576 | + | EnvironmentPolicy: ports.EnvironmentPolicyClientOwned, | |||
| 577 | + | }) | |||
| 578 | + | ||||
| 579 | + | require.NoError(t, err) | |||
| 580 | + | require.Equal(t, int32(2), dialer.calls.Load()) | |||
| 581 | + | require.Equal(t, &target, helloFromSend(t, second).ExactTarget) | |||
| 582 | + | } | |||
| 583 | + | ||||
| 545 | func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testing.T) { | 584 | func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testing.T) { | ||
| 546 | term := newRunTerminal() | 585 | term := newRunTerminal() | ||
| 547 | defer term.in.unblock() | 586 | defer term.in.unblock() | ||
M internal/usecase/client/same_peer_switch_test.go +50 -0
| @@ -3,8 +3,58 @@ package client | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 6 | 12 | ) | |
| 7 | 13 | ||
| 14 | + | func TestCloseAndDialAttachTargetSkipsSamePeerSwitch(t *testing.T) { | |
| 15 | + | term := newReconnectToastTerminalHarness(t) | |
| 16 | + | defer term.closeInput() | |
| 17 | + | transport := newReconnectToastLinkTransport() | |
| 18 | + | transport.recvCh <- reconnectToastRecv{frame: reconnectToastWelcome(44)} | |
| 19 | + | ||
| 20 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 21 | + | defer cancel() | |
| 22 | + | attempt := newReconnectAttachAttempt( | |
| 23 | + | term.term, | |
| 24 | + | transport, | |
| 25 | + | newReconnectHandshakeClock(t), | |
| 26 | + | AttachRequest{Intent: ports.IntentAttach, SessionName: "source"}, | |
| 27 | + | 0, | |
| 28 | + | &terminalThemeState{}, | |
| 29 | + | nil, | |
| 30 | + | &milestones{}, | |
| 31 | + | ) | |
| 32 | + | attempt.runner.ledger = newRouteLedger() | |
| 33 | + | resultCh := make(chan attachResult, 1) | |
| 34 | + | go func() { resultCh <- attempt.run(ctx) }() | |
| 35 | + | ||
| 36 | + | exact := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{7}, SessionName: "stopped"} | |
| 37 | + | target := ports.AttachTarget{ | |
| 38 | + | Session: "stopped", Intent: ports.IntentAttach, ExactTarget: &exact, | |
| 39 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 40 | + | } | |
| 41 | + | transport.recvCh <- reconnectToastRecv{frame: ports.Frame{ | |
| 42 | + | Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(target), | |
| 43 | + | }} | |
| 44 | + | ||
| 45 | + | select { | |
| 46 | + | case result := <-resultCh: | |
| 47 | + | require.NoError(t, result.err) | |
| 48 | + | require.Equal(t, &target, result.target) | |
| 49 | + | case <-time.After(time.Second): | |
| 50 | + | t.Fatal("close-and-dial target did not return a handoff") | |
| 51 | + | } | |
| 52 | + | _, sentSamePeer := transport.sends.find(func(frame ports.Frame) bool { | |
| 53 | + | return frame.Type == ports.MsgSamePeerSwitchRequest | |
| 54 | + | }) | |
| 55 | + | require.False(t, sentSamePeer) | |
| 56 | + | } | |
| 57 | + | ||
| 8 | 58 | func TestSamePeerInputGateBlocksUntilReleased(t *testing.T) { | |
| 9 | 59 | gate := newSamePeerInputGate() | |
| 10 | 60 | gate.setPaused(true) | |
| @@ -3,8 +3,58 @@ package client | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 6 | ) | 12 | ) | ||
| 7 | 13 | ||||
| 14 | + | func TestCloseAndDialAttachTargetSkipsSamePeerSwitch(t *testing.T) { | |||
| 15 | + | term := newReconnectToastTerminalHarness(t) | |||
| 16 | + | defer term.closeInput() | |||
| 17 | + | transport := newReconnectToastLinkTransport() | |||
| 18 | + | transport.recvCh <- reconnectToastRecv{frame: reconnectToastWelcome(44)} | |||
| 19 | + | ||||
| 20 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 21 | + | defer cancel() | |||
| 22 | + | attempt := newReconnectAttachAttempt( | |||
| 23 | + | term.term, | |||
| 24 | + | transport, | |||
| 25 | + | newReconnectHandshakeClock(t), | |||
| 26 | + | AttachRequest{Intent: ports.IntentAttach, SessionName: "source"}, | |||
| 27 | + | 0, | |||
| 28 | + | &terminalThemeState{}, | |||
| 29 | + | nil, | |||
| 30 | + | &milestones{}, | |||
| 31 | + | ) | |||
| 32 | + | attempt.runner.ledger = newRouteLedger() | |||
| 33 | + | resultCh := make(chan attachResult, 1) | |||
| 34 | + | go func() { resultCh <- attempt.run(ctx) }() | |||
| 35 | + | ||||
| 36 | + | exact := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{7}, SessionName: "stopped"} | |||
| 37 | + | target := ports.AttachTarget{ | |||
| 38 | + | Session: "stopped", Intent: ports.IntentAttach, ExactTarget: &exact, | |||
| 39 | + | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |||
| 40 | + | } | |||
| 41 | + | transport.recvCh <- reconnectToastRecv{frame: ports.Frame{ | |||
| 42 | + | Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(target), | |||
| 43 | + | }} | |||
| 44 | + | ||||
| 45 | + | select { | |||
| 46 | + | case result := <-resultCh: | |||
| 47 | + | require.NoError(t, result.err) | |||
| 48 | + | require.Equal(t, &target, result.target) | |||
| 49 | + | case <-time.After(time.Second): | |||
| 50 | + | t.Fatal("close-and-dial target did not return a handoff") | |||
| 51 | + | } | |||
| 52 | + | _, sentSamePeer := transport.sends.find(func(frame ports.Frame) bool { | |||
| 53 | + | return frame.Type == ports.MsgSamePeerSwitchRequest | |||
| 54 | + | }) | |||
| 55 | + | require.False(t, sentSamePeer) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 8 | func TestSamePeerInputGateBlocksUntilReleased(t *testing.T) { | 58 | func TestSamePeerInputGateBlocksUntilReleased(t *testing.T) { | ||
| 9 | gate := newSamePeerInputGate() | 59 | gate := newSamePeerInputGate() | ||
| 10 | gate.setPaused(true) | 60 | gate.setPaused(true) | ||
M internal/usecase/daemon/picker.go +12 -9
| @@ -924,23 +924,21 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||
| 924 | 924 | return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action) | |
| 925 | 925 | } | |
| 926 | 926 | ||
| 927 | + | // An active session row with no explicit non-default tab is on the | |
| 928 | + | // authenticated daemon and can use the client-confirmed same-peer path. | |
| 929 | + | // Explicit tab rows already take the direct transition path above, while | |
| 930 | + | // stopped sessions have no active target session and retain the fallback. | |
| 931 | + | samePeerEligible := guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0 | |
| 927 | 932 | payload := ports.MarshalAttachTarget(ports.AttachTarget{ | |
| 928 | 933 | Session: sessionName, | |
| 929 | 934 | Intent: ports.IntentAttach, | |
| 930 | 935 | ExactTarget: exactTarget, | |
| 931 | 936 | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | |
| 937 | + | SamePeer: samePeerEligible, | |
| 932 | 938 | }) | |
| 933 | 939 | if payload == nil || exactTarget == nil { | |
| 934 | 940 | return errAttachmentTransition | |
| 935 | 941 | } | |
| 936 | - | // An active session row with no explicit non-default tab is on the | |
| 937 | - | // authenticated daemon and can use the client-confirmed same-peer path. | |
| 938 | - | // Explicit tab rows already take the direct transition path above, while | |
| 939 | - | // stopped sessions have no active target session and retain the fallback. | |
| 940 | - | samePeerEligible := guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0 | |
| 941 | - | if samePeerEligible { | |
| 942 | - | token.ac.offerSamePeerTarget(*exactTarget) | |
| 943 | - | } | |
| 944 | 942 | // A close-and-dial handoff leaves this daemon's Kitty namespace. A | |
| 945 | 943 | // same-peer transition keeps the attachment and its namespace; the target | |
| 946 | 944 | // scene diff deletes and replaces the source session's placements. | |
| @@ -948,6 +946,8 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||
| 948 | 946 | if err := d.cleanupAttachmentOutput(token.ac); err != nil { | |
| 949 | 947 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't clean up attachment output before local handoff", err) | |
| 950 | 948 | } | |
| 949 | + | } else { | |
| 950 | + | token.ac.offerSamePeerTarget(*exactTarget) | |
| 951 | 951 | } | |
| 952 | 952 | if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil { | |
| 953 | 953 | if samePeerEligible { | |
| @@ -958,10 +958,13 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||
| 958 | 958 | if !samePeerEligible { | |
| 959 | 959 | // Stopped and explicit non-default index targets retain the close-and-dial | |
| 960 | 960 | // handoff instead of losing their target-specific transition semantics. | |
| 961 | + | // Keep the source link open until the client receives the ordered cleanup | |
| 962 | + | // and handoff frames, then let the client's close drive ordinary parking. | |
| 961 | 963 | if guard.closePicker { | |
| 962 | 964 | d.closePicker(token.ac) | |
| 963 | 965 | } | |
| 964 | - | d.clientGoneForAttachment(token, false) | |
| 966 | + | token.effect.bindActionEnd(d, "detach") | |
| 967 | + | token.effect.End() | |
| 965 | 968 | } | |
| 966 | 969 | return nil | |
| 967 | 970 | } | |
| @@ -924,23 +924,21 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||||
| 924 | return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action) | 924 | return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action) | ||
| 925 | } | 925 | } | ||
| 926 | 926 | ||||
| 927 | + | // An active session row with no explicit non-default tab is on the | |||
| 928 | + | // authenticated daemon and can use the client-confirmed same-peer path. | |||
| 929 | + | // Explicit tab rows already take the direct transition path above, while | |||
| 930 | + | // stopped sessions have no active target session and retain the fallback. | |||
| 931 | + | samePeerEligible := guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0 | |||
| 927 | payload := ports.MarshalAttachTarget(ports.AttachTarget{ | 932 | payload := ports.MarshalAttachTarget(ports.AttachTarget{ | ||
| 928 | Session: sessionName, | 933 | Session: sessionName, | ||
| 929 | Intent: ports.IntentAttach, | 934 | Intent: ports.IntentAttach, | ||
| 930 | ExactTarget: exactTarget, | 935 | ExactTarget: exactTarget, | ||
| 931 | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | 936 | EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, | ||
| 937 | + | SamePeer: samePeerEligible, | |||
| 932 | }) | 938 | }) | ||
| 933 | if payload == nil || exactTarget == nil { | 939 | if payload == nil || exactTarget == nil { | ||
| 934 | return errAttachmentTransition | 940 | return errAttachmentTransition | ||
| 935 | } | 941 | } | ||
| 936 | - | // An active session row with no explicit non-default tab is on the | |||
| 937 | - | // authenticated daemon and can use the client-confirmed same-peer path. | |||
| 938 | - | // Explicit tab rows already take the direct transition path above, while | |||
| 939 | - | // stopped sessions have no active target session and retain the fallback. | |||
| 940 | - | samePeerEligible := guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0 | |||
| 941 | - | if samePeerEligible { | |||
| 942 | - | token.ac.offerSamePeerTarget(*exactTarget) | |||
| 943 | - | } | |||
| 944 | // A close-and-dial handoff leaves this daemon's Kitty namespace. A | 942 | // A close-and-dial handoff leaves this daemon's Kitty namespace. A | ||
| 945 | // same-peer transition keeps the attachment and its namespace; the target | 943 | // same-peer transition keeps the attachment and its namespace; the target | ||
| 946 | // scene diff deletes and replaces the source session's placements. | 944 | // scene diff deletes and replaces the source session's placements. | ||
| @@ -948,6 +946,8 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||||
| 948 | if err := d.cleanupAttachmentOutput(token.ac); err != nil { | 946 | if err := d.cleanupAttachmentOutput(token.ac); err != nil { | ||
| 949 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't clean up attachment output before local handoff", err) | 947 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't clean up attachment output before local handoff", err) | ||
| 950 | } | 948 | } | ||
| 949 | + | } else { | |||
| 950 | + | token.ac.offerSamePeerTarget(*exactTarget) | |||
| 951 | } | 951 | } | ||
| 952 | if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil { | 952 | if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil { | ||
| 953 | if samePeerEligible { | 953 | if samePeerEligible { | ||
| @@ -958,10 +958,13 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||||
| 958 | if !samePeerEligible { | 958 | if !samePeerEligible { | ||
| 959 | // Stopped and explicit non-default index targets retain the close-and-dial | 959 | // Stopped and explicit non-default index targets retain the close-and-dial | ||
| 960 | // handoff instead of losing their target-specific transition semantics. | 960 | // handoff instead of losing their target-specific transition semantics. | ||
| 961 | + | // Keep the source link open until the client receives the ordered cleanup | |||
| 962 | + | // and handoff frames, then let the client's close drive ordinary parking. | |||
| 961 | if guard.closePicker { | 963 | if guard.closePicker { | ||
| 962 | d.closePicker(token.ac) | 964 | d.closePicker(token.ac) | ||
| 963 | } | 965 | } | ||
| 964 | - | d.clientGoneForAttachment(token, false) | 966 | + | token.effect.bindActionEnd(d, "detach") |
| 967 | + | token.effect.End() | |||
| 965 | } | 968 | } | ||
| 966 | return nil | 969 | return nil | ||
| 967 | } | 970 | } | ||
M internal/usecase/daemon/remote_attach_test.go +35 -0
| @@ -34,10 +34,45 @@ func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) { | |||
| 34 | 34 | require.NoError(t, err) | |
| 35 | 35 | require.Empty(t, got.Endpoint) | |
| 36 | 36 | require.Equal(t, ports.IntentAttach, got.Intent) | |
| 37 | + | require.True(t, got.SamePeer) | |
| 37 | 38 | require.Equal(t, &ports.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: target.name}, got.ExactTarget) | |
| 38 | 39 | require.Same(t, source, ac.currentAttachmentSession(), "the source remains attached until the client confirms the switch") | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 42 | + | func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) { | |
| 43 | + | d := newRemotePickerDaemon(nil) | |
| 44 | + | source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source") | |
| 45 | + | lifecycle := remoteLifecycleForTest() | |
| 46 | + | d.inactive["stopped"] = inactiveSession{ | |
| 47 | + | name: "stopped", state: ports.SessionDown, incarnation: lifecycle, | |
| 48 | + | } | |
| 49 | + | graphics := newGraphicsOutputState() | |
| 50 | + | graphics.assets["asset"] = graphicsOutputAsset{id: graphics.namespaceBase} | |
| 51 | + | graphics.mayHaveEmitted = true | |
| 52 | + | ac.output.graphicsOutput = graphics | |
| 53 | + | ||
| 54 | + | token := source.attachmentToken(ac, ac.transport()) | |
| 55 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 56 | + | require.True(t, admitted) | |
| 57 | + | token.effect = effect | |
| 58 | + | defer effect.End() | |
| 59 | + | ||
| 60 | + | require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{ | |
| 61 | + | Name: "stopped", Incarnation: lifecycle, | |
| 62 | + | }, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select")) | |
| 63 | + | ||
| 64 | + | cleanup := receiveRemotePicker(t, sends, "graphics cleanup") | |
| 65 | + | require.Equal(t, ports.MsgOutput, cleanup.Type) | |
| 66 | + | handoff := receiveRemotePicker(t, sends, "stopped local attach target") | |
| 67 | + | require.Equal(t, ports.MsgAttachTarget, handoff.Type) | |
| 68 | + | target, err := ports.UnmarshalAttachTarget(handoff.Payload) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | require.False(t, target.SamePeer) | |
| 71 | + | require.Equal(t, &ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "stopped"}, target.ExactTarget) | |
| 72 | + | require.Same(t, source, ac.currentAttachmentSession(), "the source remains attached until the client receives the handoff and closes") | |
| 73 | + | require.True(t, attachmentRegistered(source, ac)) | |
| 74 | + | } | |
| 75 | + | ||
| 41 | 76 | func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) { | |
| 42 | 77 | d := newRemotePickerDaemon(nil) | |
| 43 | 78 | lifecycle := remoteLifecycleForTest() | |
| @@ -34,10 +34,45 @@ func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) { | |||||
| 34 | require.NoError(t, err) | 34 | require.NoError(t, err) | ||
| 35 | require.Empty(t, got.Endpoint) | 35 | require.Empty(t, got.Endpoint) | ||
| 36 | require.Equal(t, ports.IntentAttach, got.Intent) | 36 | require.Equal(t, ports.IntentAttach, got.Intent) | ||
| 37 | + | require.True(t, got.SamePeer) | |||
| 37 | require.Equal(t, &ports.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: target.name}, got.ExactTarget) | 38 | require.Equal(t, &ports.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: target.name}, got.ExactTarget) | ||
| 38 | require.Same(t, source, ac.currentAttachmentSession(), "the source remains attached until the client confirms the switch") | 39 | require.Same(t, source, ac.currentAttachmentSession(), "the source remains attached until the client confirms the switch") | ||
| 39 | } | 40 | } | ||
| 40 | 41 | ||||
| 42 | + | func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) { | |||
| 43 | + | d := newRemotePickerDaemon(nil) | |||
| 44 | + | source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source") | |||
| 45 | + | lifecycle := remoteLifecycleForTest() | |||
| 46 | + | d.inactive["stopped"] = inactiveSession{ | |||
| 47 | + | name: "stopped", state: ports.SessionDown, incarnation: lifecycle, | |||
| 48 | + | } | |||
| 49 | + | graphics := newGraphicsOutputState() | |||
| 50 | + | graphics.assets["asset"] = graphicsOutputAsset{id: graphics.namespaceBase} | |||
| 51 | + | graphics.mayHaveEmitted = true | |||
| 52 | + | ac.output.graphicsOutput = graphics | |||
| 53 | + | ||||
| 54 | + | token := source.attachmentToken(ac, ac.transport()) | |||
| 55 | + | effect, admitted := ac.beginAttachmentEffect(token) | |||
| 56 | + | require.True(t, admitted) | |||
| 57 | + | token.effect = effect | |||
| 58 | + | defer effect.End() | |||
| 59 | + | ||||
| 60 | + | require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{ | |||
| 61 | + | Name: "stopped", Incarnation: lifecycle, | |||
| 62 | + | }, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select")) | |||
| 63 | + | ||||
| 64 | + | cleanup := receiveRemotePicker(t, sends, "graphics cleanup") | |||
| 65 | + | require.Equal(t, ports.MsgOutput, cleanup.Type) | |||
| 66 | + | handoff := receiveRemotePicker(t, sends, "stopped local attach target") | |||
| 67 | + | require.Equal(t, ports.MsgAttachTarget, handoff.Type) | |||
| 68 | + | target, err := ports.UnmarshalAttachTarget(handoff.Payload) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | require.False(t, target.SamePeer) | |||
| 71 | + | require.Equal(t, &ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "stopped"}, target.ExactTarget) | |||
| 72 | + | require.Same(t, source, ac.currentAttachmentSession(), "the source remains attached until the client receives the handoff and closes") | |||
| 73 | + | require.True(t, attachmentRegistered(source, ac)) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 41 | func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) { | 76 | func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) { | ||
| 42 | d := newRemotePickerDaemon(nil) | 77 | d := newRemotePickerDaemon(nil) | ||
| 43 | lifecycle := remoteLifecycleForTest() | 78 | lifecycle := remoteLifecycleForTest() | ||