detached d77542a

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 UnverifiedB5690EEEBB952194 The key is unknown
13 files · +178 -31
M internal/ports/frame.go +1 -1
@@ -12,7 +12,7 @@ import (
1212 const HandshakeTimeout = 15 * time.Second
1313
1414 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 36
15+const ProtocolVersion uint16 = 37
1616
1717 // MaxFrameLen is the largest permitted frame length, including the type byte
1818 // and excluding the four-byte length prefix.
@@ -12,7 +12,7 @@ import (
12 const HandshakeTimeout = 15 * time.Second12 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 = 3615+const ProtocolVersion uint16 = 37
16 16
17 // MaxFrameLen is the largest permitted frame length, including the type byte17 // 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
33 import "testing"
44
55 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)
88 }
99 }
1010
@@ -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) {
152152 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
153153 {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},
154154 {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"},
156156 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
157157 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
158158 {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) {
200200 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
201201 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00,
202202 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01,
203- 0x00,
203+ 0x00, 0x00,
204204 }
205205 if !bytes.Equal(got, want) {
206206 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) {
139139 })
140140 },
141141 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
142- policyOffset: 2,
142+ policyOffset: 3,
143143 wantErr: ErrInvalidAttachTarget,
144144 },
145145 }
@@ -195,7 +195,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) {
195195 }
196196
197197 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}
199199 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})
200200 if !bytes.Equal(got, want) {
201201 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 {
338338 RemoteTarget *domain.RemoteSessionTarget
339339 ExactTarget *ExactSessionTarget
340340 EnvironmentPolicy EnvironmentPolicy
341+ SamePeer bool
341342 }
342343
343344 // Detached tells a client it has been disconnected from its session and why.
@@ -867,6 +868,9 @@ func ValidateAttachTarget(m AttachTarget) error {
867868 if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) {
868869 return ErrInvalidAttachTarget
869870 }
871+ if m.SamePeer && (m.Endpoint != "" || m.RemoteTarget != nil || m.ExactTarget == nil) {
872+ return ErrInvalidAttachTarget
873+ }
870874 if m.RemoteTarget == nil {
871875 return nil
872876 }
@@ -1662,6 +1666,7 @@ func MarshalAttachTarget(m AttachTarget) []byte {
16621666 w.putUint8(m.Intent)
16631667 marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy)
16641668 marshalExactTargetSection(&w, m.ExactTarget)
1669+ w.putBool(m.SamePeer)
16651670 return w.b
16661671 }
16671672
@@ -1689,6 +1694,9 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
16891694 if err := skipExactTargetSection(&probe); err != nil {
16901695 return AttachTarget{}, ErrInvalidAttachTarget
16911696 }
1697+ if _, err := probe.getBool(); err != nil {
1698+ return AttachTarget{}, ErrInvalidAttachTarget
1699+ }
16921700 if err := probe.done(); err != nil {
16931701 return AttachTarget{}, ErrInvalidAttachTarget
16941702 }
@@ -1710,6 +1718,9 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
17101718 if m.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil {
17111719 return AttachTarget{}, err
17121720 }
1721+ if m.SamePeer, err = r.getBool(); err != nil {
1722+ return AttachTarget{}, err
1723+ }
17131724 if err := r.done(); err != nil {
17141725 return AttachTarget{}, err
17151726 }
@@ -338,6 +338,7 @@ type AttachTarget struct {
338 RemoteTarget *domain.RemoteSessionTarget338 RemoteTarget *domain.RemoteSessionTarget
339 ExactTarget *ExactSessionTarget339 ExactTarget *ExactSessionTarget
340 EnvironmentPolicy EnvironmentPolicy340 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 ErrInvalidAttachTarget869 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 nil875 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.b1670 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{}, ErrInvalidAttachTarget1695 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{}, ErrInvalidAttachTarget1701 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{}, err1719 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{}, err1725 return AttachTarget{}, err
1715 }1726 }
M internal/ports/wire_final_test.go +16 -7
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 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)
1616 }
1717 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1818 if len(payload) < 2 {
@@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2626
2727 func TestFinalHelloGoldenStrict(t *testing.T) {
2828 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)...)
3030 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)
3131 want = append(want, 0, 0)
3232 got := MarshalHello(msg)
@@ -195,7 +195,7 @@ func TestFinalAckGoldenStrict(t *testing.T) {
195195
196196 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
197197 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}
199199 got := MarshalAttachTarget(msg)
200200 if !bytes.Equal(got, want) {
201201 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
@@ -211,7 +211,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
211211 if localPayload == nil {
212212 t.Fatal("MarshalAttachTarget rejected same-peer route handoff")
213213 }
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}
215215 if !bytes.Equal(localPayload, wantLocal) {
216216 t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal)
217217 }
@@ -231,10 +231,10 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
231231 func TestAttachTargetExactTargetWireStrict(t *testing.T) {
232232 var lifecycle domain.SessionLifecycleID
233233 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}
235235 payload := MarshalAttachTarget(target)
236236 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)
238238 if !bytes.Equal(payload, want) {
239239 t.Fatalf("exact target bytes = %x, want %x", payload, want)
240240 }
@@ -284,6 +284,15 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
284284 }(),
285285 decode: func(b []byte) error { _, err := UnmarshalHello(b); return err },
286286 },
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+ },
287296 {
288297 name: "welcome boolean",
289298 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.SessionLifecycleID232 var lifecycle domain.SessionLifecycleID
233 lifecycle[0] = 1233 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) {
135135 }
136136 got := MarshalHello(msg)
137137 want := []byte{
138- 0x00, 0x24, 0x00, // version, intent, first client ID byte
138+ 0x00, 0x25, 0x00, // version, intent, first client ID byte
139139 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
140140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
141141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -366,7 +366,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
366366 }
367367 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
368368 require.Equal(t, want, MarshalTheme(cleared))
369- require.Equal(t, uint16(36), ProtocolVersion)
369+ require.Equal(t, uint16(37), ProtocolVersion)
370370 }
371371
372372 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -527,7 +527,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
527527 name: "minimal",
528528 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
529529 want: []byte{
530- 0x00, 0x24,
530+ 0x00, 0x25,
531531 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
532532 0x00, // attached
533533 0x00, // self
@@ -550,7 +550,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
550550 JSON: true,
551551 },
552552 want: []byte{
553- 0x00, 0x24,
553+ 0x00, 0x25,
554554 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
555555 0x00, // attached
556556 0x01, // self
@@ -979,7 +979,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
979979 name: "attached",
980980 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
981981 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,
983983 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
984984 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
985985 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -989,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
989989 name: "control",
990990 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
991991 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,
993993 0x00, 0x02, 'l', 's', 0x00, 0x00,
994994 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
995995 },
@@ -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 byte138+ 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 ID140 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
141 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token141 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 ID531 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
532 0x00, // attached532 0x00, // attached
533 0x00, // self533 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 ID554 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
555 0x00, // attached555 0x00, // attached
556 0x01, // self556 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 {
19591959 transitionWaitingFull = false
19601960 transition.start(target)
19611961 }
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 {
19631963 if samePeerSwitch != nil {
19641964 return welcomedResult(errors.New("vev: concurrent same-peer switch offer"))
19651965 }
@@ -1959,7 +1959,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1959 transitionWaitingFull = false1959 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) {
496496 {f: welcome("first", firstLifecycle)},
497497 {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
498498 Session: "second", Intent: ports.IntentAttach, ExactTarget: &secondTarget,
499- EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
499+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned, SamePeer: true,
500500 }))},
501501 {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{Target: secondTarget}))},
502502 {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
@@ -542,6 +542,45 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
542542 require.Equal(t, request.StartupOverlay, hello.StartupOverlay)
543543 }
544544
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+
545584 func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testing.T) {
546585 term := newRunTerminal()
547586 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
33 import (
44 "context"
55 "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"
612 )
713
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+
858 func TestSamePeerInputGateBlocksUntilReleased(t *testing.T) {
959 gate := newSamePeerInputGate()
1060 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
924924 return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
925925 }
926926
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
927932 payload := ports.MarshalAttachTarget(ports.AttachTarget{
928933 Session: sessionName,
929934 Intent: ports.IntentAttach,
930935 ExactTarget: exactTarget,
931936 EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
937+ SamePeer: samePeerEligible,
932938 })
933939 if payload == nil || exactTarget == nil {
934940 return errAttachmentTransition
935941 }
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- }
944942 // A close-and-dial handoff leaves this daemon's Kitty namespace. A
945943 // same-peer transition keeps the attachment and its namespace; the target
946944 // scene diff deletes and replaces the source session's placements.
@@ -948,6 +946,8 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
948946 if err := d.cleanupAttachmentOutput(token.ac); err != nil {
949947 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't clean up attachment output before local handoff", err)
950948 }
949+ } else {
950+ token.ac.offerSamePeerTarget(*exactTarget)
951951 }
952952 if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
953953 if samePeerEligible {
@@ -958,10 +958,13 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
958958 if !samePeerEligible {
959959 // Stopped and explicit non-default index targets retain the close-and-dial
960960 // 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.
961963 if guard.closePicker {
962964 d.closePicker(token.ac)
963965 }
964- d.clientGoneForAttachment(token, false)
966+ token.effect.bindActionEnd(d, "detach")
967+ token.effect.End()
965968 }
966969 return nil
967970 }
@@ -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 errAttachmentTransition940 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. A942 // A close-and-dial handoff leaves this daemon's Kitty namespace. A
945 // same-peer transition keeps the attachment and its namespace; the target943 // 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-dial959 // 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 nil969 return nil
967 }970 }
M internal/usecase/daemon/remote_attach_test.go +35 -0
@@ -34,10 +34,45 @@ func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) {
3434 require.NoError(t, err)
3535 require.Empty(t, got.Endpoint)
3636 require.Equal(t, ports.IntentAttach, got.Intent)
37+ require.True(t, got.SamePeer)
3738 require.Equal(t, &ports.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: target.name}, got.ExactTarget)
3839 require.Same(t, source, ac.currentAttachmentSession(), "the source remains attached until the client confirms the switch")
3940 }
4041
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+
4176 func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
4277 d := newRemotePickerDaemon(nil)
4378 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()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close