Merge pull request #207 from bnema/fix/remote-route-display-origin
fix(navigation): resolve sessions by lifecycle identity
45 files · +606 -341
M internal/adapters/dgram/transport_send_test.go +2 -1
| @@ -198,7 +198,8 @@ func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *tes | |||
| 198 | 198 | aPC, bPC := newPair() | |
| 199 | 199 | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |
| 200 | 200 | a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ | |
| 201 | - | Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour, | |
| 201 | + | Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second, | |
| 202 | + | Heartbeat: time.Hour, DeadAfter: time.Hour, | |
| 202 | 203 | }) | |
| 203 | 204 | b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1) | |
| 204 | 205 | defer func() { _ = a.Close() }() | |
| @@ -198,7 +198,8 @@ func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *tes | |||||
| 198 | aPC, bPC := newPair() | 198 | aPC, bPC := newPair() | ||
| 199 | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | 199 | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | ||
| 200 | a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ | 200 | a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ | ||
| 201 | - | Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour, | 201 | + | Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second, |
| 202 | + | Heartbeat: time.Hour, DeadAfter: time.Hour, | |||
| 202 | }) | 203 | }) | ||
| 203 | b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1) | 204 | b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1) | ||
| 204 | defer func() { _ = a.Close() }() | 205 | defer func() { _ = a.Close() }() | ||
M internal/app/remote_hosts.go +1 -1
| @@ -199,7 +199,7 @@ func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) [ | |||
| 199 | 199 | out := make([]ports.SessionInfo, 0, len(sessions)) | |
| 200 | 200 | for _, session := range sessions { | |
| 201 | 201 | info := ports.SessionInfo{ | |
| 202 | - | Name: session.Name + "@" + host, | |
| 202 | + | Name: domain.RemoteSessionDisplay(session.Name, host), | |
| 203 | 203 | Ephemeral: session.Ephemeral, | |
| 204 | 204 | Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)), | |
| 205 | 205 | Attached: session.Attached, | |
| @@ -199,7 +199,7 @@ func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) [ | |||||
| 199 | out := make([]ports.SessionInfo, 0, len(sessions)) | 199 | out := make([]ports.SessionInfo, 0, len(sessions)) | ||
| 200 | for _, session := range sessions { | 200 | for _, session := range sessions { | ||
| 201 | info := ports.SessionInfo{ | 201 | info := ports.SessionInfo{ | ||
| 202 | - | Name: session.Name + "@" + host, | 202 | + | Name: domain.RemoteSessionDisplay(session.Name, host), |
| 203 | Ephemeral: session.Ephemeral, | 203 | Ephemeral: session.Ephemeral, | ||
| 204 | Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)), | 204 | Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)), | ||
| 205 | Attached: session.Attached, | 205 | Attached: session.Attached, | ||
M internal/app/remote_test.go +1 -1
| @@ -272,7 +272,7 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) { | |||
| 272 | 272 | } | |
| 273 | 273 | for _, tt := range tests { | |
| 274 | 274 | t.Run(tt.name, func(t *testing.T) { | |
| 275 | - | infos := catalogSessionsAsInfo("arch", []ports.RemoteCatalogSession{tt.session}) | |
| 275 | + | infos := catalogSessionsAsInfo("user@arch", []ports.RemoteCatalogSession{tt.session}) | |
| 276 | 276 | require.Len(t, infos, 1) | |
| 277 | 277 | require.Equal(t, "dev@arch", infos[0].Name) | |
| 278 | 278 | require.Equal(t, tt.wantState, infos[0].State) | |
| @@ -272,7 +272,7 @@ func TestCatalogSessionsAsInfoInvariants(t *testing.T) { | |||||
| 272 | } | 272 | } | ||
| 273 | for _, tt := range tests { | 273 | for _, tt := range tests { | ||
| 274 | t.Run(tt.name, func(t *testing.T) { | 274 | t.Run(tt.name, func(t *testing.T) { | ||
| 275 | - | infos := catalogSessionsAsInfo("arch", []ports.RemoteCatalogSession{tt.session}) | 275 | + | infos := catalogSessionsAsInfo("user@arch", []ports.RemoteCatalogSession{tt.session}) |
| 276 | require.Len(t, infos, 1) | 276 | require.Len(t, infos, 1) | ||
| 277 | require.Equal(t, "dev@arch", infos[0].Name) | 277 | require.Equal(t, "dev@arch", infos[0].Name) | ||
| 278 | require.Equal(t, tt.wantState, infos[0].State) | 278 | require.Equal(t, tt.wantState, infos[0].State) | ||
M internal/app/run.go +2 -2
| @@ -942,7 +942,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 942 | 942 | Origin: ports.RouteOriginDiscovery, | |
| 943 | 943 | OriginKey: target.Endpoint, | |
| 944 | 944 | RemoteTarget: selection, | |
| 945 | - | RemoteOrigin: displayOrigin, | |
| 945 | + | HostLabel: displayOrigin, | |
| 946 | 946 | EnvironmentPolicy: policy, | |
| 947 | 947 | }, nil | |
| 948 | 948 | } | |
| @@ -977,7 +977,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 977 | 977 | Origin: routeOrigin, | |
| 978 | 978 | OriginKey: routeOriginKey, | |
| 979 | 979 | RemoteTarget: remoteSelection, | |
| 980 | - | RemoteOrigin: remoteDisplayOrigin, | |
| 980 | + | HostLabel: remoteDisplayOrigin, | |
| 981 | 981 | EnvironmentPolicy: remoteEnvironmentPolicy, | |
| 982 | 982 | }) | |
| 983 | 983 | } else { | |
| @@ -942,7 +942,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 942 | Origin: ports.RouteOriginDiscovery, | 942 | Origin: ports.RouteOriginDiscovery, | ||
| 943 | OriginKey: target.Endpoint, | 943 | OriginKey: target.Endpoint, | ||
| 944 | RemoteTarget: selection, | 944 | RemoteTarget: selection, | ||
| 945 | - | RemoteOrigin: displayOrigin, | 945 | + | HostLabel: displayOrigin, |
| 946 | EnvironmentPolicy: policy, | 946 | EnvironmentPolicy: policy, | ||
| 947 | }, nil | 947 | }, nil | ||
| 948 | } | 948 | } | ||
| @@ -977,7 +977,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 977 | Origin: routeOrigin, | 977 | Origin: routeOrigin, | ||
| 978 | OriginKey: routeOriginKey, | 978 | OriginKey: routeOriginKey, | ||
| 979 | RemoteTarget: remoteSelection, | 979 | RemoteTarget: remoteSelection, | ||
| 980 | - | RemoteOrigin: remoteDisplayOrigin, | 980 | + | HostLabel: remoteDisplayOrigin, |
| 981 | EnvironmentPolicy: remoteEnvironmentPolicy, | 981 | EnvironmentPolicy: remoteEnvironmentPolicy, | ||
| 982 | }) | 982 | }) | ||
| 983 | } else { | 983 | } else { | ||
M internal/app/run_test.go +1 -1
| @@ -962,7 +962,7 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) | |||
| 962 | 962 | runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error { | |
| 963 | 963 | if deps.Remote { | |
| 964 | 964 | remoteCalls++ | |
| 965 | - | require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", RemoteOrigin: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request) | |
| 965 | + | require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", HostLabel: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request) | |
| 966 | 966 | return nil | |
| 967 | 967 | } | |
| 968 | 968 | localCalls++ | |
| @@ -962,7 +962,7 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) | |||||
| 962 | runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error { | 962 | runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error { | ||
| 963 | if deps.Remote { | 963 | if deps.Remote { | ||
| 964 | remoteCalls++ | 964 | remoteCalls++ | ||
| 965 | - | require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", RemoteOrigin: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request) | 965 | + | require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", HostLabel: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request) |
| 966 | return nil | 966 | return nil | ||
| 967 | } | 967 | } | ||
| 968 | localCalls++ | 968 | localCalls++ | ||
M internal/domain/remote_session.go +10 -3
| @@ -42,17 +42,24 @@ func (k RemoteSessionKey) ID() SessionID { | |||
| 42 | 42 | // the login prefix regardless of whether the route came from the CLI or a | |
| 43 | 43 | // discovered picker entry. | |
| 44 | 44 | func RemoteDisplayOrigin(endpoint string) string { | |
| 45 | - | if i := strings.LastIndexByte(endpoint, '@'); i > 0 && i < len(endpoint)-1 { | |
| 45 | + | if i := strings.LastIndexByte(endpoint, '@'); i >= 0 && i < len(endpoint)-1 { | |
| 46 | 46 | return endpoint[i+1:] | |
| 47 | 47 | } | |
| 48 | 48 | return endpoint | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | // RemoteSessionDisplay returns the canonical presentation label for a remote | |
| 52 | + | // session. endpoint may be a routing target or an already-derived origin; the | |
| 53 | + | // login prefix is always removed. | |
| 54 | + | func RemoteSessionDisplay(name, endpoint string) string { | |
| 55 | + | return name + "@" + RemoteDisplayOrigin(endpoint) | |
| 56 | + | } | |
| 57 | + | ||
| 51 | 58 | // Display returns the presentation label for this remote session. | |
| 52 | 59 | func (k RemoteSessionKey) Display() string { | |
| 53 | 60 | origin := k.DisplayOrigin | |
| 54 | 61 | if origin == "" { | |
| 55 | - | origin = RemoteDisplayOrigin(k.Host) | |
| 62 | + | origin = k.Host | |
| 56 | 63 | } | |
| 57 | - | return k.Name + "@" + origin | |
| 64 | + | return RemoteSessionDisplay(k.Name, origin) | |
| 58 | 65 | } | |
| @@ -42,17 +42,24 @@ func (k RemoteSessionKey) ID() SessionID { | |||||
| 42 | // the login prefix regardless of whether the route came from the CLI or a | 42 | // the login prefix regardless of whether the route came from the CLI or a | ||
| 43 | // discovered picker entry. | 43 | // discovered picker entry. | ||
| 44 | func RemoteDisplayOrigin(endpoint string) string { | 44 | func RemoteDisplayOrigin(endpoint string) string { | ||
| 45 | - | if i := strings.LastIndexByte(endpoint, '@'); i > 0 && i < len(endpoint)-1 { | 45 | + | if i := strings.LastIndexByte(endpoint, '@'); i >= 0 && i < len(endpoint)-1 { |
| 46 | return endpoint[i+1:] | 46 | return endpoint[i+1:] | ||
| 47 | } | 47 | } | ||
| 48 | return endpoint | 48 | return endpoint | ||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | + | // RemoteSessionDisplay returns the canonical presentation label for a remote | |||
| 52 | + | // session. endpoint may be a routing target or an already-derived origin; the | |||
| 53 | + | // login prefix is always removed. | |||
| 54 | + | func RemoteSessionDisplay(name, endpoint string) string { | |||
| 55 | + | return name + "@" + RemoteDisplayOrigin(endpoint) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 51 | // Display returns the presentation label for this remote session. | 58 | // Display returns the presentation label for this remote session. | ||
| 52 | func (k RemoteSessionKey) Display() string { | 59 | func (k RemoteSessionKey) Display() string { | ||
| 53 | origin := k.DisplayOrigin | 60 | origin := k.DisplayOrigin | ||
| 54 | if origin == "" { | 61 | if origin == "" { | ||
| 55 | - | origin = RemoteDisplayOrigin(k.Host) | 62 | + | origin = k.Host |
| 56 | } | 63 | } | ||
| 57 | - | return k.Name + "@" + origin | 64 | + | return RemoteSessionDisplay(k.Name, origin) |
| 58 | } | 65 | } | ||
M internal/domain/remote_session_test.go +6 -4
| @@ -90,19 +90,21 @@ func TestRemoteSessionKeyValidate(t *testing.T) { | |||
| 90 | 90 | ||
| 91 | 91 | func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) { | |
| 92 | 92 | tests := []struct { | |
| 93 | - | host string | |
| 94 | - | want string | |
| 93 | + | host string | |
| 94 | + | displayOrigin string | |
| 95 | + | want string | |
| 95 | 96 | }{ | |
| 96 | 97 | {host: "arch", want: "hello@arch"}, | |
| 98 | + | {host: "route", displayOrigin: "user@arch", want: "hello@arch"}, | |
| 97 | 99 | {host: "test@arch", want: "hello@arch"}, | |
| 98 | 100 | {host: "test@arch:2222", want: "hello@arch:2222"}, | |
| 99 | 101 | {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"}, | |
| 100 | 102 | {host: "test@", want: "hello@test@"}, | |
| 101 | - | {host: "@arch", want: "hello@@arch"}, | |
| 103 | + | {host: "@arch", want: "hello@arch"}, | |
| 102 | 104 | } | |
| 103 | 105 | for _, test := range tests { | |
| 104 | 106 | t.Run(test.host, func(t *testing.T) { | |
| 105 | - | key := RemoteSessionKey{Host: test.host, Name: "hello"} | |
| 107 | + | key := RemoteSessionKey{Host: test.host, Name: "hello", DisplayOrigin: test.displayOrigin} | |
| 106 | 108 | if got := key.Display(); got != test.want { | |
| 107 | 109 | t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want) | |
| 108 | 110 | } | |
| @@ -90,19 +90,21 @@ func TestRemoteSessionKeyValidate(t *testing.T) { | |||||
| 90 | 90 | ||||
| 91 | func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) { | 91 | func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) { | ||
| 92 | tests := []struct { | 92 | tests := []struct { | ||
| 93 | - | host string | 93 | + | host string |
| 94 | - | want string | 94 | + | displayOrigin string |
| 95 | + | want string | |||
| 95 | }{ | 96 | }{ | ||
| 96 | {host: "arch", want: "hello@arch"}, | 97 | {host: "arch", want: "hello@arch"}, | ||
| 98 | + | {host: "route", displayOrigin: "user@arch", want: "hello@arch"}, | |||
| 97 | {host: "test@arch", want: "hello@arch"}, | 99 | {host: "test@arch", want: "hello@arch"}, | ||
| 98 | {host: "test@arch:2222", want: "hello@arch:2222"}, | 100 | {host: "test@arch:2222", want: "hello@arch:2222"}, | ||
| 99 | {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"}, | 101 | {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"}, | ||
| 100 | {host: "test@", want: "hello@test@"}, | 102 | {host: "test@", want: "hello@test@"}, | ||
| 101 | - | {host: "@arch", want: "hello@@arch"}, | 103 | + | {host: "@arch", want: "hello@arch"}, |
| 102 | } | 104 | } | ||
| 103 | for _, test := range tests { | 105 | for _, test := range tests { | ||
| 104 | t.Run(test.host, func(t *testing.T) { | 106 | t.Run(test.host, func(t *testing.T) { | ||
| 105 | - | key := RemoteSessionKey{Host: test.host, Name: "hello"} | 107 | + | key := RemoteSessionKey{Host: test.host, Name: "hello", DisplayOrigin: test.displayOrigin} |
| 106 | if got := key.Display(); got != test.want { | 108 | if got := key.Display(); got != test.want { | ||
| 107 | t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want) | 109 | t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want) | ||
| 108 | } | 110 | } | ||
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 = 31 | |
| 15 | + | const ProtocolVersion uint16 = 32 | |
| 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 = 31 | 15 | + | const ProtocolVersion uint16 = 32 |
| 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 != 31 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 32 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 32", 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 != 31 { | 6 | + | if ProtocolVersion != 32 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 32", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
M internal/ports/navigation_test.go +1 -1
| @@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||
| 62 | 62 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | |
| 63 | 63 | {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}, | |
| 64 | 64 | {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}, | |
| 65 | - | {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: "001f02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"}, | |
| 65 | + | {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: "002002" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"}, | |
| 66 | 66 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | |
| 67 | 67 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | |
| 68 | 68 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | |
| @@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||||
| 62 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | 62 | {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false}, | ||
| 63 | {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}, | 63 | {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}, | ||
| 64 | {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}, | 64 | {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}, | ||
| 65 | - | {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: "001f02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"}, | 65 | + | {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: "002002" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"}, |
| 66 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | 66 | {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false}, | ||
| 67 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | 67 | {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | ||
| 68 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | 68 | {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false}, | ||
M internal/ports/routes.go +12 -7
| @@ -193,12 +193,13 @@ func (r RouteRef) Validate() error { | |||
| 193 | 193 | return nil | |
| 194 | 194 | } | |
| 195 | 195 | ||
| 196 | - | // RecentRouteEntry is the complete daemon-neutral display view of one route. | |
| 197 | - | // It contains no dialer, credential, endpoint, live session pointer, or attach | |
| 198 | - | // target. The client ledger keeps those private beside this value. | |
| 196 | + | // RecentRouteEntry is the complete daemon-neutral identity and display view of | |
| 197 | + | // one route. Target carries only the committed lifecycle UUID and name; it | |
| 198 | + | // contains no dialer, credential, endpoint, or live session pointer. | |
| 199 | 199 | type RecentRouteEntry struct { | |
| 200 | 200 | Key uint64 | |
| 201 | 201 | Generation uint64 | |
| 202 | + | Target ExactSessionTarget | |
| 202 | 203 | Name string | |
| 203 | 204 | HostLabel string | |
| 204 | 205 | Kind RouteKind | |
| @@ -212,9 +213,13 @@ type RecentRouteEntry struct { | |||
| 212 | 213 | type RecentRouteSnapshot struct { | |
| 213 | 214 | Generation uint64 | |
| 214 | 215 | Active RouteRef | |
| 215 | - | Previous RouteRef | |
| 216 | - | Home RouteRef | |
| 217 | - | Entries []RecentRouteEntry | |
| 216 | + | // ActiveEntry maps Active's committed lifecycle UUID to its presentation. | |
| 217 | + | // It is not a navigation candidate and therefore remains separate from | |
| 218 | + | // Entries, which are ordered from most recent to least recent. | |
| 219 | + | ActiveEntry RecentRouteEntry | |
| 220 | + | Previous RouteRef | |
| 221 | + | Home RouteRef | |
| 222 | + | Entries []RecentRouteEntry | |
| 218 | 223 | } | |
| 219 | 224 | ||
| 220 | 225 | // RouteNavigationAction asks the client to resolve one entry from a specific | |
| @@ -257,5 +262,5 @@ const ( | |||
| 257 | 262 | // of work a receiver performs before returning to its transport loop. The | |
| 258 | 263 | // private client history may use a smaller product cap. | |
| 259 | 264 | RouteSnapshotMaxEntries = 32 | |
| 260 | - | RouteLabelMaxBytes = 128 | |
| 265 | + | RouteLabelMaxBytes = 256 | |
| 261 | 266 | ) | |
| @@ -193,12 +193,13 @@ func (r RouteRef) Validate() error { | |||||
| 193 | return nil | 193 | return nil | ||
| 194 | } | 194 | } | ||
| 195 | 195 | ||||
| 196 | - | // RecentRouteEntry is the complete daemon-neutral display view of one route. | 196 | + | // RecentRouteEntry is the complete daemon-neutral identity and display view of |
| 197 | - | // It contains no dialer, credential, endpoint, live session pointer, or attach | 197 | + | // one route. Target carries only the committed lifecycle UUID and name; it |
| 198 | - | // target. The client ledger keeps those private beside this value. | 198 | + | // contains no dialer, credential, endpoint, or live session pointer. |
| 199 | type RecentRouteEntry struct { | 199 | type RecentRouteEntry struct { | ||
| 200 | Key uint64 | 200 | Key uint64 | ||
| 201 | Generation uint64 | 201 | Generation uint64 | ||
| 202 | + | Target ExactSessionTarget | |||
| 202 | Name string | 203 | Name string | ||
| 203 | HostLabel string | 204 | HostLabel string | ||
| 204 | Kind RouteKind | 205 | Kind RouteKind | ||
| @@ -212,9 +213,13 @@ type RecentRouteEntry struct { | |||||
| 212 | type RecentRouteSnapshot struct { | 213 | type RecentRouteSnapshot struct { | ||
| 213 | Generation uint64 | 214 | Generation uint64 | ||
| 214 | Active RouteRef | 215 | Active RouteRef | ||
| 215 | - | Previous RouteRef | 216 | + | // ActiveEntry maps Active's committed lifecycle UUID to its presentation. |
| 216 | - | Home RouteRef | 217 | + | // It is not a navigation candidate and therefore remains separate from |
| 217 | - | Entries []RecentRouteEntry | 218 | + | // Entries, which are ordered from most recent to least recent. |
| 219 | + | ActiveEntry RecentRouteEntry | |||
| 220 | + | Previous RouteRef | |||
| 221 | + | Home RouteRef | |||
| 222 | + | Entries []RecentRouteEntry | |||
| 218 | } | 223 | } | ||
| 219 | 224 | ||||
| 220 | // RouteNavigationAction asks the client to resolve one entry from a specific | 225 | // RouteNavigationAction asks the client to resolve one entry from a specific | ||
| @@ -257,5 +262,5 @@ const ( | |||||
| 257 | // of work a receiver performs before returning to its transport loop. The | 262 | // of work a receiver performs before returning to its transport loop. The | ||
| 258 | // private client history may use a smaller product cap. | 263 | // private client history may use a smaller product cap. | ||
| 259 | RouteSnapshotMaxEntries = 32 | 264 | RouteSnapshotMaxEntries = 32 | ||
| 260 | - | RouteLabelMaxBytes = 128 | 265 | + | RouteLabelMaxBytes = 256 |
| 261 | ) | 266 | ) | ||
M internal/ports/routes_test.go +38 -10
| @@ -2,6 +2,7 @@ package ports | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/hex" | |
| 5 | + | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| @@ -111,13 +112,16 @@ func TestCommittedRouteIdentityCodec(t *testing.T) { | |||
| 111 | 112 | } | |
| 112 | 113 | ||
| 113 | 114 | func testRouteSnapshot() RecentRouteSnapshot { | |
| 115 | + | activeTarget := testExactTarget() | |
| 116 | + | logsTarget := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{4, 5, 6}, SessionName: "logs"} | |
| 114 | 117 | return RecentRouteSnapshot{ | |
| 115 | - | Generation: 9, | |
| 116 | - | Active: RouteRef{Key: 11, Generation: 3}, | |
| 117 | - | Previous: RouteRef{Key: 12, Generation: 4}, | |
| 118 | - | Home: RouteRef{Key: 11, Generation: 3}, | |
| 118 | + | Generation: 9, | |
| 119 | + | Active: RouteRef{Key: 11, Generation: 3}, | |
| 120 | + | ActiveEntry: RecentRouteEntry{Key: 11, Generation: 3, Target: activeTarget, Name: "work", Kind: RouteKindLocal, Reachability: RouteReachabilityUnknown}, | |
| 121 | + | Previous: RouteRef{Key: 12, Generation: 4}, | |
| 122 | + | Home: RouteRef{Key: 11, Generation: 3}, | |
| 119 | 123 | Entries: []RecentRouteEntry{ | |
| 120 | - | {Key: 12, Generation: 4, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown}, | |
| 124 | + | {Key: 12, Generation: 4, Target: logsTarget, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown}, | |
| 121 | 125 | }, | |
| 122 | 126 | } | |
| 123 | 127 | } | |
| @@ -131,7 +135,7 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) { | |||
| 131 | 135 | require.NoError(t, err) | |
| 132 | 136 | require.Equal(t, want, got) | |
| 133 | 137 | ||
| 134 | - | require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c000000000000000400046c6f677300046564676502000100", hex.EncodeToString(encoded)) | |
| 138 | + | require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000b0000000000000003010203000000000000000000000000000004776f726b0004776f726b000001000000000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c00000000000000040405060000000000000000000000000000046c6f677300046c6f677300046564676502000100", hex.EncodeToString(encoded)) | |
| 135 | 139 | assertAllPrefixesFail(t, encoded, UnmarshalRecentRouteSnapshot) | |
| 136 | 140 | _, err = UnmarshalRecentRouteSnapshot(append(append([]byte(nil), encoded...), 0)) | |
| 137 | 141 | require.Error(t, err) | |
| @@ -141,14 +145,17 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) { | |||
| 141 | 145 | mutate func(*RecentRouteSnapshot) | |
| 142 | 146 | }{ | |
| 143 | 147 | {name: "missing previous reference", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Previous = RouteRef{Key: 99, Generation: 99} }}, | |
| 148 | + | {name: "missing active presentation", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.ActiveEntry = RecentRouteEntry{} }}, | |
| 149 | + | {name: "route name differs from lifecycle target", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries[0].Name = "other" }}, | |
| 144 | 150 | {name: "entry is active", mutate: func(snapshot *RecentRouteSnapshot) { | |
| 145 | - | snapshot.Entries = append(snapshot.Entries, RecentRouteEntry{Key: 11, Generation: 3, Name: "work", Kind: RouteKindLocal}) | |
| 151 | + | snapshot.Entries = append(snapshot.Entries, snapshot.ActiveEntry) | |
| 146 | 152 | }}, | |
| 147 | 153 | {name: "duplicate entry", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries = append(snapshot.Entries, snapshot.Entries[0]) }}, | |
| 148 | 154 | {name: "too many entries", mutate: func(snapshot *RecentRouteSnapshot) { | |
| 149 | 155 | snapshot.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1) | |
| 150 | 156 | for i := range snapshot.Entries { | |
| 151 | - | snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Name: "x", Kind: RouteKindLocal} | |
| 157 | + | target := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{byte(i + 1)}, SessionName: "x"} | |
| 158 | + | snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Target: target, Name: "x", Kind: RouteKindLocal} | |
| 152 | 159 | } | |
| 153 | 160 | }}, | |
| 154 | 161 | } | |
| @@ -189,20 +196,41 @@ func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) { | |||
| 189 | 196 | require.ErrorIs(t, err, ErrInvalidRouteWire) | |
| 190 | 197 | } | |
| 191 | 198 | ||
| 199 | + | func TestRouteLabelBoundsMatchRemoteDisplayOrigin(t *testing.T) { | |
| 200 | + | tests := []struct { | |
| 201 | + | name string | |
| 202 | + | length int | |
| 203 | + | wantErr bool | |
| 204 | + | }{ | |
| 205 | + | {name: "maximum", length: 256}, | |
| 206 | + | {name: "over maximum", length: 257, wantErr: true}, | |
| 207 | + | } | |
| 208 | + | for _, tt := range tests { | |
| 209 | + | t.Run(tt.name, func(t *testing.T) { | |
| 210 | + | err := ValidateRouteLabel(strings.Repeat("a", tt.length), false) | |
| 211 | + | if tt.wantErr { | |
| 212 | + | require.Error(t, err) | |
| 213 | + | return | |
| 214 | + | } | |
| 215 | + | require.NoError(t, err) | |
| 216 | + | }) | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 192 | 220 | func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) { | |
| 193 | 221 | values := []string{"line\nfeed", "\u2028line-separator", "\u2029paragraph-separator", "\x1b[31mred", "\u202eoverride", string([]byte{0xff, 0xfe})} | |
| 194 | 222 | for _, value := range values { | |
| 195 | 223 | t.Run("name/"+value, func(t *testing.T) { | |
| 196 | 224 | _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{ | |
| 197 | 225 | Generation: 1, | |
| 198 | - | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: value, Kind: RouteKindLocal}}, | |
| 226 | + | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: value}, Name: value, Kind: RouteKindLocal}}, | |
| 199 | 227 | }) | |
| 200 | 228 | require.Error(t, err) | |
| 201 | 229 | }) | |
| 202 | 230 | t.Run("host/"+value, func(t *testing.T) { | |
| 203 | 231 | _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{ | |
| 204 | 232 | Generation: 1, | |
| 205 | - | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: "work", HostLabel: value, Kind: RouteKindRemote}}, | |
| 233 | + | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: testExactTarget(), Name: "work", HostLabel: value, Kind: RouteKindRemote}}, | |
| 206 | 234 | }) | |
| 207 | 235 | require.Error(t, err) | |
| 208 | 236 | }) | |
| @@ -2,6 +2,7 @@ package ports | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "encoding/hex" | 4 | "encoding/hex" | ||
| 5 | + | "strings" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| @@ -111,13 +112,16 @@ func TestCommittedRouteIdentityCodec(t *testing.T) { | |||||
| 111 | } | 112 | } | ||
| 112 | 113 | ||||
| 113 | func testRouteSnapshot() RecentRouteSnapshot { | 114 | func testRouteSnapshot() RecentRouteSnapshot { | ||
| 115 | + | activeTarget := testExactTarget() | |||
| 116 | + | logsTarget := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{4, 5, 6}, SessionName: "logs"} | |||
| 114 | return RecentRouteSnapshot{ | 117 | return RecentRouteSnapshot{ | ||
| 115 | - | Generation: 9, | 118 | + | Generation: 9, |
| 116 | - | Active: RouteRef{Key: 11, Generation: 3}, | 119 | + | Active: RouteRef{Key: 11, Generation: 3}, |
| 117 | - | Previous: RouteRef{Key: 12, Generation: 4}, | 120 | + | ActiveEntry: RecentRouteEntry{Key: 11, Generation: 3, Target: activeTarget, Name: "work", Kind: RouteKindLocal, Reachability: RouteReachabilityUnknown}, |
| 118 | - | Home: RouteRef{Key: 11, Generation: 3}, | 121 | + | Previous: RouteRef{Key: 12, Generation: 4}, |
| 122 | + | Home: RouteRef{Key: 11, Generation: 3}, | |||
| 119 | Entries: []RecentRouteEntry{ | 123 | Entries: []RecentRouteEntry{ | ||
| 120 | - | {Key: 12, Generation: 4, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown}, | 124 | + | {Key: 12, Generation: 4, Target: logsTarget, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown}, |
| 121 | }, | 125 | }, | ||
| 122 | } | 126 | } | ||
| 123 | } | 127 | } | ||
| @@ -131,7 +135,7 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) { | |||||
| 131 | require.NoError(t, err) | 135 | require.NoError(t, err) | ||
| 132 | require.Equal(t, want, got) | 136 | require.Equal(t, want, got) | ||
| 133 | 137 | ||||
| 134 | - | require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c000000000000000400046c6f677300046564676502000100", hex.EncodeToString(encoded)) | 138 | + | require.Equal(t, "0000000000000009000000000000000b0000000000000003000000000000000b0000000000000003010203000000000000000000000000000004776f726b0004776f726b000001000000000000000000000c0000000000000004000000000000000b000000000000000301000000000000000c00000000000000040405060000000000000000000000000000046c6f677300046c6f677300046564676502000100", hex.EncodeToString(encoded)) |
| 135 | assertAllPrefixesFail(t, encoded, UnmarshalRecentRouteSnapshot) | 139 | assertAllPrefixesFail(t, encoded, UnmarshalRecentRouteSnapshot) | ||
| 136 | _, err = UnmarshalRecentRouteSnapshot(append(append([]byte(nil), encoded...), 0)) | 140 | _, err = UnmarshalRecentRouteSnapshot(append(append([]byte(nil), encoded...), 0)) | ||
| 137 | require.Error(t, err) | 141 | require.Error(t, err) | ||
| @@ -141,14 +145,17 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) { | |||||
| 141 | mutate func(*RecentRouteSnapshot) | 145 | mutate func(*RecentRouteSnapshot) | ||
| 142 | }{ | 146 | }{ | ||
| 143 | {name: "missing previous reference", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Previous = RouteRef{Key: 99, Generation: 99} }}, | 147 | {name: "missing previous reference", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Previous = RouteRef{Key: 99, Generation: 99} }}, | ||
| 148 | + | {name: "missing active presentation", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.ActiveEntry = RecentRouteEntry{} }}, | |||
| 149 | + | {name: "route name differs from lifecycle target", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries[0].Name = "other" }}, | |||
| 144 | {name: "entry is active", mutate: func(snapshot *RecentRouteSnapshot) { | 150 | {name: "entry is active", mutate: func(snapshot *RecentRouteSnapshot) { | ||
| 145 | - | snapshot.Entries = append(snapshot.Entries, RecentRouteEntry{Key: 11, Generation: 3, Name: "work", Kind: RouteKindLocal}) | 151 | + | snapshot.Entries = append(snapshot.Entries, snapshot.ActiveEntry) |
| 146 | }}, | 152 | }}, | ||
| 147 | {name: "duplicate entry", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries = append(snapshot.Entries, snapshot.Entries[0]) }}, | 153 | {name: "duplicate entry", mutate: func(snapshot *RecentRouteSnapshot) { snapshot.Entries = append(snapshot.Entries, snapshot.Entries[0]) }}, | ||
| 148 | {name: "too many entries", mutate: func(snapshot *RecentRouteSnapshot) { | 154 | {name: "too many entries", mutate: func(snapshot *RecentRouteSnapshot) { | ||
| 149 | snapshot.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1) | 155 | snapshot.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1) | ||
| 150 | for i := range snapshot.Entries { | 156 | for i := range snapshot.Entries { | ||
| 151 | - | snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Name: "x", Kind: RouteKindLocal} | 157 | + | target := ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{byte(i + 1)}, SessionName: "x"} |
| 158 | + | snapshot.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Target: target, Name: "x", Kind: RouteKindLocal} | |||
| 152 | } | 159 | } | ||
| 153 | }}, | 160 | }}, | ||
| 154 | } | 161 | } | ||
| @@ -189,20 +196,41 @@ func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) { | |||||
| 189 | require.ErrorIs(t, err, ErrInvalidRouteWire) | 196 | require.ErrorIs(t, err, ErrInvalidRouteWire) | ||
| 190 | } | 197 | } | ||
| 191 | 198 | ||||
| 199 | + | func TestRouteLabelBoundsMatchRemoteDisplayOrigin(t *testing.T) { | |||
| 200 | + | tests := []struct { | |||
| 201 | + | name string | |||
| 202 | + | length int | |||
| 203 | + | wantErr bool | |||
| 204 | + | }{ | |||
| 205 | + | {name: "maximum", length: 256}, | |||
| 206 | + | {name: "over maximum", length: 257, wantErr: true}, | |||
| 207 | + | } | |||
| 208 | + | for _, tt := range tests { | |||
| 209 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 210 | + | err := ValidateRouteLabel(strings.Repeat("a", tt.length), false) | |||
| 211 | + | if tt.wantErr { | |||
| 212 | + | require.Error(t, err) | |||
| 213 | + | return | |||
| 214 | + | } | |||
| 215 | + | require.NoError(t, err) | |||
| 216 | + | }) | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 192 | func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) { | 220 | func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) { | ||
| 193 | values := []string{"line\nfeed", "\u2028line-separator", "\u2029paragraph-separator", "\x1b[31mred", "\u202eoverride", string([]byte{0xff, 0xfe})} | 221 | values := []string{"line\nfeed", "\u2028line-separator", "\u2029paragraph-separator", "\x1b[31mred", "\u202eoverride", string([]byte{0xff, 0xfe})} | ||
| 194 | for _, value := range values { | 222 | for _, value := range values { | ||
| 195 | t.Run("name/"+value, func(t *testing.T) { | 223 | t.Run("name/"+value, func(t *testing.T) { | ||
| 196 | _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{ | 224 | _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{ | ||
| 197 | Generation: 1, | 225 | Generation: 1, | ||
| 198 | - | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: value, Kind: RouteKindLocal}}, | 226 | + | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: value}, Name: value, Kind: RouteKindLocal}}, |
| 199 | }) | 227 | }) | ||
| 200 | require.Error(t, err) | 228 | require.Error(t, err) | ||
| 201 | }) | 229 | }) | ||
| 202 | t.Run("host/"+value, func(t *testing.T) { | 230 | t.Run("host/"+value, func(t *testing.T) { | ||
| 203 | _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{ | 231 | _, err := MarshalRecentRouteSnapshot(RecentRouteSnapshot{ | ||
| 204 | Generation: 1, | 232 | Generation: 1, | ||
| 205 | - | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Name: "work", HostLabel: value, Kind: RouteKindRemote}}, | 233 | + | Entries: []RecentRouteEntry{{Key: 1, Generation: 1, Target: testExactTarget(), Name: "work", HostLabel: value, Kind: RouteKindRemote}}, |
| 206 | }) | 234 | }) | ||
| 207 | require.Error(t, err) | 235 | require.Error(t, err) | ||
| 208 | }) | 236 | }) | ||
M internal/ports/routes_wire.go +78 -35
| @@ -243,6 +243,12 @@ func validateRecentRouteEntry(entry RecentRouteEntry) error { | |||
| 243 | 243 | if entry.Key == 0 || entry.Generation == 0 { | |
| 244 | 244 | return fmt.Errorf("%w: route entry identity is zero", ErrInvalidRouteWire) | |
| 245 | 245 | } | |
| 246 | + | if err := entry.Target.Validate(); err != nil { | |
| 247 | + | return fmt.Errorf("%w: invalid route lifecycle target: %v", ErrInvalidRouteWire, err) | |
| 248 | + | } | |
| 249 | + | if entry.Name != entry.Target.SessionName { | |
| 250 | + | return fmt.Errorf("%w: route name does not match lifecycle target", ErrInvalidRouteWire) | |
| 251 | + | } | |
| 246 | 252 | if err := validateRouteString(entry.Name, "route name", false); err != nil { | |
| 247 | 253 | return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err) | |
| 248 | 254 | } | |
| @@ -262,7 +268,7 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error { | |||
| 262 | 268 | if len(snapshot.Entries) > RouteSnapshotMaxEntries { | |
| 263 | 269 | return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire) | |
| 264 | 270 | } | |
| 265 | - | if snapshot.Generation == 0 && len(snapshot.Entries) != 0 { | |
| 271 | + | if snapshot.Generation == 0 && (len(snapshot.Entries) != 0 || snapshot.ActiveEntry != (RecentRouteEntry{})) { | |
| 266 | 272 | return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire) | |
| 267 | 273 | } | |
| 268 | 274 | if err := validateRouteRef(snapshot.Active); err != nil { | |
| @@ -274,6 +280,18 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error { | |||
| 274 | 280 | if err := validateRouteRef(snapshot.Home); err != nil { | |
| 275 | 281 | return err | |
| 276 | 282 | } | |
| 283 | + | if snapshot.Active.empty() { | |
| 284 | + | if snapshot.ActiveEntry != (RecentRouteEntry{}) { | |
| 285 | + | return fmt.Errorf("%w: active presentation has no active route", ErrInvalidRouteWire) | |
| 286 | + | } | |
| 287 | + | } else { | |
| 288 | + | if err := validateRecentRouteEntry(snapshot.ActiveEntry); err != nil { | |
| 289 | + | return err | |
| 290 | + | } | |
| 291 | + | if snapshot.Active != (RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation}) { | |
| 292 | + | return fmt.Errorf("%w: active presentation does not match active route", ErrInvalidRouteWire) | |
| 293 | + | } | |
| 294 | + | } | |
| 277 | 295 | if !snapshot.Active.empty() && snapshot.Active == snapshot.Previous { | |
| 278 | 296 | return fmt.Errorf("%w: active and previous routes are identical", ErrInvalidRouteWire) | |
| 279 | 297 | } | |
| @@ -310,6 +328,55 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error { | |||
| 310 | 328 | return nil | |
| 311 | 329 | } | |
| 312 | 330 | ||
| 331 | + | func marshalRecentRouteEntry(w *payloadWriter, entry RecentRouteEntry) { | |
| 332 | + | w.putUint64(entry.Key) | |
| 333 | + | w.putUint64(entry.Generation) | |
| 334 | + | marshalExactSessionTarget(w, entry.Target) | |
| 335 | + | w.putString(entry.Name) | |
| 336 | + | w.putString(entry.HostLabel) | |
| 337 | + | w.putUint8(uint8(entry.Kind)) | |
| 338 | + | w.putBool(entry.Ephemeral) | |
| 339 | + | w.putBool(entry.Attention) | |
| 340 | + | w.putUint8(uint8(entry.Reachability)) | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | func unmarshalRecentRouteEntry(r *payloadReader) (RecentRouteEntry, error) { | |
| 344 | + | var entry RecentRouteEntry | |
| 345 | + | var err error | |
| 346 | + | if entry.Key, err = r.getUint64(); err != nil { | |
| 347 | + | return RecentRouteEntry{}, err | |
| 348 | + | } | |
| 349 | + | if entry.Generation, err = r.getUint64(); err != nil { | |
| 350 | + | return RecentRouteEntry{}, err | |
| 351 | + | } | |
| 352 | + | if entry.Target, err = unmarshalExactSessionTarget(r); err != nil { | |
| 353 | + | return RecentRouteEntry{}, err | |
| 354 | + | } | |
| 355 | + | if entry.Name, err = r.getString(); err != nil { | |
| 356 | + | return RecentRouteEntry{}, err | |
| 357 | + | } | |
| 358 | + | if entry.HostLabel, err = r.getString(); err != nil { | |
| 359 | + | return RecentRouteEntry{}, err | |
| 360 | + | } | |
| 361 | + | kind, err := r.getUint8() | |
| 362 | + | if err != nil { | |
| 363 | + | return RecentRouteEntry{}, err | |
| 364 | + | } | |
| 365 | + | entry.Kind = RouteKind(kind) | |
| 366 | + | if entry.Ephemeral, err = r.getBool(); err != nil { | |
| 367 | + | return RecentRouteEntry{}, err | |
| 368 | + | } | |
| 369 | + | if entry.Attention, err = r.getBool(); err != nil { | |
| 370 | + | return RecentRouteEntry{}, err | |
| 371 | + | } | |
| 372 | + | reachability, err := r.getUint8() | |
| 373 | + | if err != nil { | |
| 374 | + | return RecentRouteEntry{}, err | |
| 375 | + | } | |
| 376 | + | entry.Reachability = RouteReachability(reachability) | |
| 377 | + | return entry, nil | |
| 378 | + | } | |
| 379 | + | ||
| 313 | 380 | // MarshalRecentRouteSnapshot encodes a complete bounded client route view. | |
| 314 | 381 | func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) { | |
| 315 | 382 | if err := validateRecentRouteSnapshot(snapshot); err != nil { | |
| @@ -318,18 +385,14 @@ func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) { | |||
| 318 | 385 | w := payloadWriter{} | |
| 319 | 386 | w.putUint64(snapshot.Generation) | |
| 320 | 387 | marshalRouteRef(&w, snapshot.Active) | |
| 388 | + | if !snapshot.Active.empty() { | |
| 389 | + | marshalRecentRouteEntry(&w, snapshot.ActiveEntry) | |
| 390 | + | } | |
| 321 | 391 | marshalRouteRef(&w, snapshot.Previous) | |
| 322 | 392 | marshalRouteRef(&w, snapshot.Home) | |
| 323 | 393 | w.putUint8(uint8(len(snapshot.Entries))) | |
| 324 | 394 | for _, entry := range snapshot.Entries { | |
| 325 | - | w.putUint64(entry.Key) | |
| 326 | - | w.putUint64(entry.Generation) | |
| 327 | - | w.putString(entry.Name) | |
| 328 | - | w.putString(entry.HostLabel) | |
| 329 | - | w.putUint8(uint8(entry.Kind)) | |
| 330 | - | w.putBool(entry.Ephemeral) | |
| 331 | - | w.putBool(entry.Attention) | |
| 332 | - | w.putUint8(uint8(entry.Reachability)) | |
| 395 | + | marshalRecentRouteEntry(&w, entry) | |
| 333 | 396 | } | |
| 334 | 397 | // Keep the frame bound defensive even though current entry/label bounds | |
| 335 | 398 | // make this unreachable; it protects the wire contract if those bounds | |
| @@ -422,6 +485,11 @@ func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) { | |||
| 422 | 485 | if snapshot.Active, err = unmarshalRouteRef(&r); err != nil { | |
| 423 | 486 | return RecentRouteSnapshot{}, err | |
| 424 | 487 | } | |
| 488 | + | if !snapshot.Active.empty() { | |
| 489 | + | if snapshot.ActiveEntry, err = unmarshalRecentRouteEntry(&r); err != nil { | |
| 490 | + | return RecentRouteSnapshot{}, err | |
| 491 | + | } | |
| 492 | + | } | |
| 425 | 493 | if snapshot.Previous, err = unmarshalRouteRef(&r); err != nil { | |
| 426 | 494 | return RecentRouteSnapshot{}, err | |
| 427 | 495 | } | |
| @@ -439,35 +507,10 @@ func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) { | |||
| 439 | 507 | snapshot.Entries = make([]RecentRouteEntry, 0, int(count)) | |
| 440 | 508 | } | |
| 441 | 509 | for range int(count) { | |
| 442 | - | var entry RecentRouteEntry | |
| 443 | - | if entry.Key, err = r.getUint64(); err != nil { | |
| 444 | - | return RecentRouteSnapshot{}, err | |
| 445 | - | } | |
| 446 | - | if entry.Generation, err = r.getUint64(); err != nil { | |
| 447 | - | return RecentRouteSnapshot{}, err | |
| 448 | - | } | |
| 449 | - | if entry.Name, err = r.getString(); err != nil { | |
| 450 | - | return RecentRouteSnapshot{}, err | |
| 451 | - | } | |
| 452 | - | if entry.HostLabel, err = r.getString(); err != nil { | |
| 453 | - | return RecentRouteSnapshot{}, err | |
| 454 | - | } | |
| 455 | - | kind, err := r.getUint8() | |
| 456 | - | if err != nil { | |
| 457 | - | return RecentRouteSnapshot{}, err | |
| 458 | - | } | |
| 459 | - | entry.Kind = RouteKind(kind) | |
| 460 | - | if entry.Ephemeral, err = r.getBool(); err != nil { | |
| 461 | - | return RecentRouteSnapshot{}, err | |
| 462 | - | } | |
| 463 | - | if entry.Attention, err = r.getBool(); err != nil { | |
| 464 | - | return RecentRouteSnapshot{}, err | |
| 465 | - | } | |
| 466 | - | reachability, err := r.getUint8() | |
| 510 | + | entry, err := unmarshalRecentRouteEntry(&r) | |
| 467 | 511 | if err != nil { | |
| 468 | 512 | return RecentRouteSnapshot{}, err | |
| 469 | 513 | } | |
| 470 | - | entry.Reachability = RouteReachability(reachability) | |
| 471 | 514 | snapshot.Entries = append(snapshot.Entries, entry) | |
| 472 | 515 | } | |
| 473 | 516 | if err := r.done(); err != nil { | |
| @@ -243,6 +243,12 @@ func validateRecentRouteEntry(entry RecentRouteEntry) error { | |||||
| 243 | if entry.Key == 0 || entry.Generation == 0 { | 243 | if entry.Key == 0 || entry.Generation == 0 { | ||
| 244 | return fmt.Errorf("%w: route entry identity is zero", ErrInvalidRouteWire) | 244 | return fmt.Errorf("%w: route entry identity is zero", ErrInvalidRouteWire) | ||
| 245 | } | 245 | } | ||
| 246 | + | if err := entry.Target.Validate(); err != nil { | |||
| 247 | + | return fmt.Errorf("%w: invalid route lifecycle target: %v", ErrInvalidRouteWire, err) | |||
| 248 | + | } | |||
| 249 | + | if entry.Name != entry.Target.SessionName { | |||
| 250 | + | return fmt.Errorf("%w: route name does not match lifecycle target", ErrInvalidRouteWire) | |||
| 251 | + | } | |||
| 246 | if err := validateRouteString(entry.Name, "route name", false); err != nil { | 252 | if err := validateRouteString(entry.Name, "route name", false); err != nil { | ||
| 247 | return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err) | 253 | return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err) | ||
| 248 | } | 254 | } | ||
| @@ -262,7 +268,7 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error { | |||||
| 262 | if len(snapshot.Entries) > RouteSnapshotMaxEntries { | 268 | if len(snapshot.Entries) > RouteSnapshotMaxEntries { | ||
| 263 | return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire) | 269 | return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire) | ||
| 264 | } | 270 | } | ||
| 265 | - | if snapshot.Generation == 0 && len(snapshot.Entries) != 0 { | 271 | + | if snapshot.Generation == 0 && (len(snapshot.Entries) != 0 || snapshot.ActiveEntry != (RecentRouteEntry{})) { |
| 266 | return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire) | 272 | return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire) | ||
| 267 | } | 273 | } | ||
| 268 | if err := validateRouteRef(snapshot.Active); err != nil { | 274 | if err := validateRouteRef(snapshot.Active); err != nil { | ||
| @@ -274,6 +280,18 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error { | |||||
| 274 | if err := validateRouteRef(snapshot.Home); err != nil { | 280 | if err := validateRouteRef(snapshot.Home); err != nil { | ||
| 275 | return err | 281 | return err | ||
| 276 | } | 282 | } | ||
| 283 | + | if snapshot.Active.empty() { | |||
| 284 | + | if snapshot.ActiveEntry != (RecentRouteEntry{}) { | |||
| 285 | + | return fmt.Errorf("%w: active presentation has no active route", ErrInvalidRouteWire) | |||
| 286 | + | } | |||
| 287 | + | } else { | |||
| 288 | + | if err := validateRecentRouteEntry(snapshot.ActiveEntry); err != nil { | |||
| 289 | + | return err | |||
| 290 | + | } | |||
| 291 | + | if snapshot.Active != (RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation}) { | |||
| 292 | + | return fmt.Errorf("%w: active presentation does not match active route", ErrInvalidRouteWire) | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 277 | if !snapshot.Active.empty() && snapshot.Active == snapshot.Previous { | 295 | if !snapshot.Active.empty() && snapshot.Active == snapshot.Previous { | ||
| 278 | return fmt.Errorf("%w: active and previous routes are identical", ErrInvalidRouteWire) | 296 | return fmt.Errorf("%w: active and previous routes are identical", ErrInvalidRouteWire) | ||
| 279 | } | 297 | } | ||
| @@ -310,6 +328,55 @@ func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error { | |||||
| 310 | return nil | 328 | return nil | ||
| 311 | } | 329 | } | ||
| 312 | 330 | ||||
| 331 | + | func marshalRecentRouteEntry(w *payloadWriter, entry RecentRouteEntry) { | |||
| 332 | + | w.putUint64(entry.Key) | |||
| 333 | + | w.putUint64(entry.Generation) | |||
| 334 | + | marshalExactSessionTarget(w, entry.Target) | |||
| 335 | + | w.putString(entry.Name) | |||
| 336 | + | w.putString(entry.HostLabel) | |||
| 337 | + | w.putUint8(uint8(entry.Kind)) | |||
| 338 | + | w.putBool(entry.Ephemeral) | |||
| 339 | + | w.putBool(entry.Attention) | |||
| 340 | + | w.putUint8(uint8(entry.Reachability)) | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | func unmarshalRecentRouteEntry(r *payloadReader) (RecentRouteEntry, error) { | |||
| 344 | + | var entry RecentRouteEntry | |||
| 345 | + | var err error | |||
| 346 | + | if entry.Key, err = r.getUint64(); err != nil { | |||
| 347 | + | return RecentRouteEntry{}, err | |||
| 348 | + | } | |||
| 349 | + | if entry.Generation, err = r.getUint64(); err != nil { | |||
| 350 | + | return RecentRouteEntry{}, err | |||
| 351 | + | } | |||
| 352 | + | if entry.Target, err = unmarshalExactSessionTarget(r); err != nil { | |||
| 353 | + | return RecentRouteEntry{}, err | |||
| 354 | + | } | |||
| 355 | + | if entry.Name, err = r.getString(); err != nil { | |||
| 356 | + | return RecentRouteEntry{}, err | |||
| 357 | + | } | |||
| 358 | + | if entry.HostLabel, err = r.getString(); err != nil { | |||
| 359 | + | return RecentRouteEntry{}, err | |||
| 360 | + | } | |||
| 361 | + | kind, err := r.getUint8() | |||
| 362 | + | if err != nil { | |||
| 363 | + | return RecentRouteEntry{}, err | |||
| 364 | + | } | |||
| 365 | + | entry.Kind = RouteKind(kind) | |||
| 366 | + | if entry.Ephemeral, err = r.getBool(); err != nil { | |||
| 367 | + | return RecentRouteEntry{}, err | |||
| 368 | + | } | |||
| 369 | + | if entry.Attention, err = r.getBool(); err != nil { | |||
| 370 | + | return RecentRouteEntry{}, err | |||
| 371 | + | } | |||
| 372 | + | reachability, err := r.getUint8() | |||
| 373 | + | if err != nil { | |||
| 374 | + | return RecentRouteEntry{}, err | |||
| 375 | + | } | |||
| 376 | + | entry.Reachability = RouteReachability(reachability) | |||
| 377 | + | return entry, nil | |||
| 378 | + | } | |||
| 379 | + | ||||
| 313 | // MarshalRecentRouteSnapshot encodes a complete bounded client route view. | 380 | // MarshalRecentRouteSnapshot encodes a complete bounded client route view. | ||
| 314 | func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) { | 381 | func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) { | ||
| 315 | if err := validateRecentRouteSnapshot(snapshot); err != nil { | 382 | if err := validateRecentRouteSnapshot(snapshot); err != nil { | ||
| @@ -318,18 +385,14 @@ func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) { | |||||
| 318 | w := payloadWriter{} | 385 | w := payloadWriter{} | ||
| 319 | w.putUint64(snapshot.Generation) | 386 | w.putUint64(snapshot.Generation) | ||
| 320 | marshalRouteRef(&w, snapshot.Active) | 387 | marshalRouteRef(&w, snapshot.Active) | ||
| 388 | + | if !snapshot.Active.empty() { | |||
| 389 | + | marshalRecentRouteEntry(&w, snapshot.ActiveEntry) | |||
| 390 | + | } | |||
| 321 | marshalRouteRef(&w, snapshot.Previous) | 391 | marshalRouteRef(&w, snapshot.Previous) | ||
| 322 | marshalRouteRef(&w, snapshot.Home) | 392 | marshalRouteRef(&w, snapshot.Home) | ||
| 323 | w.putUint8(uint8(len(snapshot.Entries))) | 393 | w.putUint8(uint8(len(snapshot.Entries))) | ||
| 324 | for _, entry := range snapshot.Entries { | 394 | for _, entry := range snapshot.Entries { | ||
| 325 | - | w.putUint64(entry.Key) | 395 | + | marshalRecentRouteEntry(&w, entry) |
| 326 | - | w.putUint64(entry.Generation) | |||
| 327 | - | w.putString(entry.Name) | |||
| 328 | - | w.putString(entry.HostLabel) | |||
| 329 | - | w.putUint8(uint8(entry.Kind)) | |||
| 330 | - | w.putBool(entry.Ephemeral) | |||
| 331 | - | w.putBool(entry.Attention) | |||
| 332 | - | w.putUint8(uint8(entry.Reachability)) | |||
| 333 | } | 396 | } | ||
| 334 | // Keep the frame bound defensive even though current entry/label bounds | 397 | // Keep the frame bound defensive even though current entry/label bounds | ||
| 335 | // make this unreachable; it protects the wire contract if those bounds | 398 | // make this unreachable; it protects the wire contract if those bounds | ||
| @@ -422,6 +485,11 @@ func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) { | |||||
| 422 | if snapshot.Active, err = unmarshalRouteRef(&r); err != nil { | 485 | if snapshot.Active, err = unmarshalRouteRef(&r); err != nil { | ||
| 423 | return RecentRouteSnapshot{}, err | 486 | return RecentRouteSnapshot{}, err | ||
| 424 | } | 487 | } | ||
| 488 | + | if !snapshot.Active.empty() { | |||
| 489 | + | if snapshot.ActiveEntry, err = unmarshalRecentRouteEntry(&r); err != nil { | |||
| 490 | + | return RecentRouteSnapshot{}, err | |||
| 491 | + | } | |||
| 492 | + | } | |||
| 425 | if snapshot.Previous, err = unmarshalRouteRef(&r); err != nil { | 493 | if snapshot.Previous, err = unmarshalRouteRef(&r); err != nil { | ||
| 426 | return RecentRouteSnapshot{}, err | 494 | return RecentRouteSnapshot{}, err | ||
| 427 | } | 495 | } | ||
| @@ -439,35 +507,10 @@ func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) { | |||||
| 439 | snapshot.Entries = make([]RecentRouteEntry, 0, int(count)) | 507 | snapshot.Entries = make([]RecentRouteEntry, 0, int(count)) | ||
| 440 | } | 508 | } | ||
| 441 | for range int(count) { | 509 | for range int(count) { | ||
| 442 | - | var entry RecentRouteEntry | 510 | + | entry, err := unmarshalRecentRouteEntry(&r) |
| 443 | - | if entry.Key, err = r.getUint64(); err != nil { | |||
| 444 | - | return RecentRouteSnapshot{}, err | |||
| 445 | - | } | |||
| 446 | - | if entry.Generation, err = r.getUint64(); err != nil { | |||
| 447 | - | return RecentRouteSnapshot{}, err | |||
| 448 | - | } | |||
| 449 | - | if entry.Name, err = r.getString(); err != nil { | |||
| 450 | - | return RecentRouteSnapshot{}, err | |||
| 451 | - | } | |||
| 452 | - | if entry.HostLabel, err = r.getString(); err != nil { | |||
| 453 | - | return RecentRouteSnapshot{}, err | |||
| 454 | - | } | |||
| 455 | - | kind, err := r.getUint8() | |||
| 456 | - | if err != nil { | |||
| 457 | - | return RecentRouteSnapshot{}, err | |||
| 458 | - | } | |||
| 459 | - | entry.Kind = RouteKind(kind) | |||
| 460 | - | if entry.Ephemeral, err = r.getBool(); err != nil { | |||
| 461 | - | return RecentRouteSnapshot{}, err | |||
| 462 | - | } | |||
| 463 | - | if entry.Attention, err = r.getBool(); err != nil { | |||
| 464 | - | return RecentRouteSnapshot{}, err | |||
| 465 | - | } | |||
| 466 | - | reachability, err := r.getUint8() | |||
| 467 | if err != nil { | 511 | if err != nil { | ||
| 468 | return RecentRouteSnapshot{}, err | 512 | return RecentRouteSnapshot{}, err | ||
| 469 | } | 513 | } | ||
| 470 | - | entry.Reachability = RouteReachability(reachability) | |||
| 471 | snapshot.Entries = append(snapshot.Entries, entry) | 514 | snapshot.Entries = append(snapshot.Entries, entry) | ||
| 472 | } | 515 | } | ||
| 473 | if err := r.done(); err != nil { | 516 | if err := r.done(); err != nil { | ||
M internal/ports/wire.go +0 -13
| @@ -169,7 +169,6 @@ type Hello struct { | |||
| 169 | 169 | // PreferredTabID is a client-owned route cursor. The daemon treats it as an | |
| 170 | 170 | // attachment-local hint and falls back when the stable tab no longer exists. | |
| 171 | 171 | PreferredTabID domain.TabStableID | |
| 172 | - | RemoteOrigin string | |
| 173 | 172 | EnvironmentPolicy EnvironmentPolicy | |
| 174 | 173 | NavigationCapabilities NavigationCapabilities | |
| 175 | 174 | StartupOverlay StartupOverlay | |
| @@ -730,11 +729,6 @@ func ValidateHello(h Hello) error { | |||
| 730 | 729 | return ErrInvalidHello | |
| 731 | 730 | } | |
| 732 | 731 | } | |
| 733 | - | if h.RemoteOrigin != "" { | |
| 734 | - | if err := domain.ValidateRemoteDisplayOrigin(h.RemoteOrigin); err != nil { | |
| 735 | - | return fmt.Errorf("%w: remote origin: %v", ErrInvalidHello, err) | |
| 736 | - | } | |
| 737 | - | } | |
| 738 | 732 | if h.PreferredTabID != "" { | |
| 739 | 733 | if h.Intent != IntentAttach && h.Intent != IntentResume { | |
| 740 | 734 | return ErrInvalidHello | |
| @@ -878,7 +872,6 @@ func MarshalHello(h Hello) []byte { | |||
| 878 | 872 | marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy) | |
| 879 | 873 | marshalExactTargetSection(&w, h.ExactTarget) | |
| 880 | 874 | w.putString(string(h.PreferredTabID)) | |
| 881 | - | w.putString(h.RemoteOrigin) | |
| 882 | 875 | w.putUint8(uint8(h.NavigationCapabilities)) | |
| 883 | 876 | w.putUint8(uint8(h.StartupOverlay)) | |
| 884 | 877 | return w.b | |
| @@ -945,9 +938,6 @@ func preflightHello(b []byte) error { | |||
| 945 | 938 | if err := r.skipString(); err != nil { | |
| 946 | 939 | return err | |
| 947 | 940 | } | |
| 948 | - | if err := r.skipString(); err != nil { | |
| 949 | - | return err | |
| 950 | - | } | |
| 951 | 941 | if _, err := r.getUint8(); err != nil { | |
| 952 | 942 | return err | |
| 953 | 943 | } | |
| @@ -1037,9 +1027,6 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 1037 | 1027 | return Hello{}, err | |
| 1038 | 1028 | } | |
| 1039 | 1029 | h.PreferredTabID = domain.TabStableID(preferredTabID) | |
| 1040 | - | if h.RemoteOrigin, err = r.getString(); err != nil { | |
| 1041 | - | return Hello{}, err | |
| 1042 | - | } | |
| 1043 | 1030 | capabilities, err := r.getUint8() | |
| 1044 | 1031 | if err != nil { | |
| 1045 | 1032 | return Hello{}, err | |
| @@ -169,7 +169,6 @@ type Hello struct { | |||||
| 169 | // PreferredTabID is a client-owned route cursor. The daemon treats it as an | 169 | // PreferredTabID is a client-owned route cursor. The daemon treats it as an | ||
| 170 | // attachment-local hint and falls back when the stable tab no longer exists. | 170 | // attachment-local hint and falls back when the stable tab no longer exists. | ||
| 171 | PreferredTabID domain.TabStableID | 171 | PreferredTabID domain.TabStableID | ||
| 172 | - | RemoteOrigin string | |||
| 173 | EnvironmentPolicy EnvironmentPolicy | 172 | EnvironmentPolicy EnvironmentPolicy | ||
| 174 | NavigationCapabilities NavigationCapabilities | 173 | NavigationCapabilities NavigationCapabilities | ||
| 175 | StartupOverlay StartupOverlay | 174 | StartupOverlay StartupOverlay | ||
| @@ -730,11 +729,6 @@ func ValidateHello(h Hello) error { | |||||
| 730 | return ErrInvalidHello | 729 | return ErrInvalidHello | ||
| 731 | } | 730 | } | ||
| 732 | } | 731 | } | ||
| 733 | - | if h.RemoteOrigin != "" { | |||
| 734 | - | if err := domain.ValidateRemoteDisplayOrigin(h.RemoteOrigin); err != nil { | |||
| 735 | - | return fmt.Errorf("%w: remote origin: %v", ErrInvalidHello, err) | |||
| 736 | - | } | |||
| 737 | - | } | |||
| 738 | if h.PreferredTabID != "" { | 732 | if h.PreferredTabID != "" { | ||
| 739 | if h.Intent != IntentAttach && h.Intent != IntentResume { | 733 | if h.Intent != IntentAttach && h.Intent != IntentResume { | ||
| 740 | return ErrInvalidHello | 734 | return ErrInvalidHello | ||
| @@ -878,7 +872,6 @@ func MarshalHello(h Hello) []byte { | |||||
| 878 | marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy) | 872 | marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy) | ||
| 879 | marshalExactTargetSection(&w, h.ExactTarget) | 873 | marshalExactTargetSection(&w, h.ExactTarget) | ||
| 880 | w.putString(string(h.PreferredTabID)) | 874 | w.putString(string(h.PreferredTabID)) | ||
| 881 | - | w.putString(h.RemoteOrigin) | |||
| 882 | w.putUint8(uint8(h.NavigationCapabilities)) | 875 | w.putUint8(uint8(h.NavigationCapabilities)) | ||
| 883 | w.putUint8(uint8(h.StartupOverlay)) | 876 | w.putUint8(uint8(h.StartupOverlay)) | ||
| 884 | return w.b | 877 | return w.b | ||
| @@ -945,9 +938,6 @@ func preflightHello(b []byte) error { | |||||
| 945 | if err := r.skipString(); err != nil { | 938 | if err := r.skipString(); err != nil { | ||
| 946 | return err | 939 | return err | ||
| 947 | } | 940 | } | ||
| 948 | - | if err := r.skipString(); err != nil { | |||
| 949 | - | return err | |||
| 950 | - | } | |||
| 951 | if _, err := r.getUint8(); err != nil { | 941 | if _, err := r.getUint8(); err != nil { | ||
| 952 | return err | 942 | return err | ||
| 953 | } | 943 | } | ||
| @@ -1037,9 +1027,6 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 1037 | return Hello{}, err | 1027 | return Hello{}, err | ||
| 1038 | } | 1028 | } | ||
| 1039 | h.PreferredTabID = domain.TabStableID(preferredTabID) | 1029 | h.PreferredTabID = domain.TabStableID(preferredTabID) | ||
| 1040 | - | if h.RemoteOrigin, err = r.getString(); err != nil { | |||
| 1041 | - | return Hello{}, err | |||
| 1042 | - | } | |||
| 1043 | capabilities, err := r.getUint8() | 1030 | capabilities, err := r.getUint8() | ||
| 1044 | if err != nil { | 1031 | if err != nil { | ||
| 1045 | return Hello{}, err | 1032 | return Hello{}, err | ||
M internal/ports/wire_final_test.go +4 -4
| @@ -11,8 +11,8 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |
| 14 | - | if ProtocolVersion != 31 { | |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion) | |
| 14 | + | if ProtocolVersion != 32 { | |
| 15 | + | t.Fatalf("ProtocolVersion = %d, want 32", 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,8 +26,8 @@ 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, 31, 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) | |
| 29 | + | want := append([]byte{0, 32, 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) | |
| 31 | 31 | got := MarshalHello(msg) | |
| 32 | 32 | if !bytes.Equal(got, want) { | |
| 33 | 33 | t.Fatalf("Hello bytes = %x, want %x", got, want) | |
| @@ -11,8 +11,8 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | ||
| 14 | - | if ProtocolVersion != 31 { | 14 | + | if ProtocolVersion != 32 { |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 31", ProtocolVersion) | 15 | + | t.Fatalf("ProtocolVersion = %d, want 32", 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,8 +26,8 @@ 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, 31, 2}, make([]byte, 16+8)...) | 29 | + | want := append([]byte{0, 32, 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) | 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) |
| 31 | got := MarshalHello(msg) | 31 | got := MarshalHello(msg) | ||
| 32 | if !bytes.Equal(got, want) { | 32 | if !bytes.Equal(got, want) { | ||
| 33 | t.Fatalf("Hello bytes = %x, want %x", got, want) | 33 | t.Fatalf("Hello bytes = %x, want %x", got, want) | ||
M internal/ports/wire_test.go +14 -14
| @@ -54,9 +54,9 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func | |||
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | 56 | func appendNoNavigationTail(payload []byte) []byte { | |
| 57 | - | // exact-target absent, preferred-tab absent, remote-origin absent, | |
| 58 | - | // navigation capabilities none, startup overlay none. | |
| 59 | - | return append(payload, 0, 0, 0, 0, 0, 0, 0) | |
| 57 | + | // exact-target absent, preferred-tab absent, navigation capabilities none, | |
| 58 | + | // startup overlay none. | |
| 59 | + | return append(payload, 0, 0, 0, 0, 0) | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 62 | func TestHelloGoldenAndRoundTrip(t *testing.T) { | |
| @@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 131 | 131 | } | |
| 132 | 132 | got := MarshalHello(msg) | |
| 133 | 133 | want := []byte{ | |
| 134 | - | 0x00, 0x1f, 0x00, // version, intent, first client ID byte | |
| 134 | + | 0x00, 0x20, 0x00, // version, intent, first client ID byte | |
| 135 | 135 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 136 | 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 137 | 137 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -173,15 +173,15 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 173 | 173 | base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}}) | |
| 174 | 174 | withCount := func(count byte) []byte { | |
| 175 | 175 | payload := append([]byte(nil), base...) | |
| 176 | - | // The uint32 environment count occupies bytes len-13 through len-10. | |
| 177 | - | payload[len(payload)-10] = count | |
| 176 | + | // The uint32 environment count occupies bytes len-11 through len-8. | |
| 177 | + | payload[len(payload)-8] = count | |
| 178 | 178 | return payload | |
| 179 | 179 | } | |
| 180 | 180 | withEntries := func(count byte, entries ...byte) []byte { | |
| 181 | 181 | payload := withCount(count) | |
| 182 | - | // The nine-byte tail follows the environment-count field. | |
| 183 | - | tail := append([]byte(nil), payload[len(payload)-9:]...) | |
| 184 | - | payload = append(payload[:len(payload)-9], entries...) | |
| 182 | + | // The seven-byte tail follows the environment-count field. | |
| 183 | + | tail := append([]byte(nil), payload[len(payload)-7:]...) | |
| 184 | + | payload = append(payload[:len(payload)-7], entries...) | |
| 185 | 185 | return append(payload, tail...) | |
| 186 | 186 | } | |
| 187 | 187 | tests := []struct { | |
| @@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 361 | 361 | } | |
| 362 | 362 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 363 | 363 | require.Equal(t, want, MarshalTheme(cleared)) | |
| 364 | - | require.Equal(t, uint16(31), ProtocolVersion) | |
| 364 | + | require.Equal(t, uint16(32), ProtocolVersion) | |
| 365 | 365 | } | |
| 366 | 366 | ||
| 367 | 367 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 522 | 522 | name: "minimal", | |
| 523 | 523 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 524 | 524 | want: []byte{ | |
| 525 | - | 0x00, 0x1f, | |
| 525 | + | 0x00, 0x20, | |
| 526 | 526 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 527 | 527 | 0x00, // attached | |
| 528 | 528 | 0x00, // self | |
| @@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 545 | 545 | JSON: true, | |
| 546 | 546 | }, | |
| 547 | 547 | want: []byte{ | |
| 548 | - | 0x00, 0x1f, | |
| 548 | + | 0x00, 0x20, | |
| 549 | 549 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 550 | 550 | 0x00, // attached | |
| 551 | 551 | 0x01, // self | |
| @@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 974 | 974 | name: "attached", | |
| 975 | 975 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 976 | 976 | want: []byte{ | |
| 977 | - | 0x00, 0x1f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 977 | + | 0x00, 0x20, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 978 | 978 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 979 | 979 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 980 | 980 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 984 | 984 | name: "control", | |
| 985 | 985 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | |
| 986 | 986 | want: []byte{ | |
| 987 | - | 0x00, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 987 | + | 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 988 | 988 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 989 | 989 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 990 | 990 | }, | |
| @@ -54,9 +54,9 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func | |||||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | func appendNoNavigationTail(payload []byte) []byte { | 56 | func appendNoNavigationTail(payload []byte) []byte { | ||
| 57 | - | // exact-target absent, preferred-tab absent, remote-origin absent, | 57 | + | // exact-target absent, preferred-tab absent, navigation capabilities none, |
| 58 | - | // navigation capabilities none, startup overlay none. | 58 | + | // startup overlay none. |
| 59 | - | return append(payload, 0, 0, 0, 0, 0, 0, 0) | 59 | + | return append(payload, 0, 0, 0, 0, 0) |
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | func TestHelloGoldenAndRoundTrip(t *testing.T) { | 62 | func TestHelloGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 131 | } | 131 | } | ||
| 132 | got := MarshalHello(msg) | 132 | got := MarshalHello(msg) | ||
| 133 | want := []byte{ | 133 | want := []byte{ | ||
| 134 | - | 0x00, 0x1f, 0x00, // version, intent, first client ID byte | 134 | + | 0x00, 0x20, 0x00, // version, intent, first client ID byte |
| 135 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 135 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | 136 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 137 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 137 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -173,15 +173,15 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 173 | base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}}) | 173 | base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}}) | ||
| 174 | withCount := func(count byte) []byte { | 174 | withCount := func(count byte) []byte { | ||
| 175 | payload := append([]byte(nil), base...) | 175 | payload := append([]byte(nil), base...) | ||
| 176 | - | // The uint32 environment count occupies bytes len-13 through len-10. | 176 | + | // The uint32 environment count occupies bytes len-11 through len-8. |
| 177 | - | payload[len(payload)-10] = count | 177 | + | payload[len(payload)-8] = count |
| 178 | return payload | 178 | return payload | ||
| 179 | } | 179 | } | ||
| 180 | withEntries := func(count byte, entries ...byte) []byte { | 180 | withEntries := func(count byte, entries ...byte) []byte { | ||
| 181 | payload := withCount(count) | 181 | payload := withCount(count) | ||
| 182 | - | // The nine-byte tail follows the environment-count field. | 182 | + | // The seven-byte tail follows the environment-count field. |
| 183 | - | tail := append([]byte(nil), payload[len(payload)-9:]...) | 183 | + | tail := append([]byte(nil), payload[len(payload)-7:]...) |
| 184 | - | payload = append(payload[:len(payload)-9], entries...) | 184 | + | payload = append(payload[:len(payload)-7], entries...) |
| 185 | return append(payload, tail...) | 185 | return append(payload, tail...) | ||
| 186 | } | 186 | } | ||
| 187 | tests := []struct { | 187 | tests := []struct { | ||
| @@ -361,7 +361,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 361 | } | 361 | } | ||
| 362 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 362 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 363 | require.Equal(t, want, MarshalTheme(cleared)) | 363 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 364 | - | require.Equal(t, uint16(31), ProtocolVersion) | 364 | + | require.Equal(t, uint16(32), ProtocolVersion) |
| 365 | } | 365 | } | ||
| 366 | 366 | ||||
| 367 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 367 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -522,7 +522,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 522 | name: "minimal", | 522 | name: "minimal", | ||
| 523 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | 523 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | ||
| 524 | want: []byte{ | 524 | want: []byte{ | ||
| 525 | - | 0x00, 0x1f, | 525 | + | 0x00, 0x20, |
| 526 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 526 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 527 | 0x00, // attached | 527 | 0x00, // attached | ||
| 528 | 0x00, // self | 528 | 0x00, // self | ||
| @@ -545,7 +545,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 545 | JSON: true, | 545 | JSON: true, | ||
| 546 | }, | 546 | }, | ||
| 547 | want: []byte{ | 547 | want: []byte{ | ||
| 548 | - | 0x00, 0x1f, | 548 | + | 0x00, 0x20, |
| 549 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 549 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 550 | 0x00, // attached | 550 | 0x00, // attached | ||
| 551 | 0x01, // self | 551 | 0x01, // self | ||
| @@ -974,7 +974,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 974 | name: "attached", | 974 | name: "attached", | ||
| 975 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | 975 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | ||
| 976 | want: []byte{ | 976 | want: []byte{ | ||
| 977 | - | 0x00, 0x1f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | 977 | + | 0x00, 0x20, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, |
| 978 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | 978 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | ||
| 979 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | 979 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | ||
| 980 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 980 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| @@ -984,7 +984,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 984 | name: "control", | 984 | name: "control", | ||
| 985 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | 985 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | ||
| 986 | want: []byte{ | 986 | want: []byte{ | ||
| 987 | - | 0x00, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 987 | + | 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, |
| 988 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | 988 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | ||
| 989 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 989 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 990 | }, | 990 | }, | ||
M internal/usecase/client/client.go +1 -2
| @@ -181,7 +181,7 @@ type AttachRequest struct { | |||
| 181 | 181 | RemoteTarget *domain.RemoteSessionTarget | |
| 182 | 182 | ExactTarget *ports.ExactSessionTarget | |
| 183 | 183 | PreferredTabID domain.TabStableID | |
| 184 | - | RemoteOrigin string | |
| 184 | + | HostLabel string | |
| 185 | 185 | EnvironmentPolicy ports.EnvironmentPolicy | |
| 186 | 186 | NavigationCapabilities ports.NavigationCapabilities | |
| 187 | 187 | StartupOverlay ports.StartupOverlay | |
| @@ -1054,7 +1054,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1054 | 1054 | RemoteTarget: request.RemoteTarget, | |
| 1055 | 1055 | ExactTarget: exactTarget, | |
| 1056 | 1056 | PreferredTabID: request.PreferredTabID, | |
| 1057 | - | RemoteOrigin: request.RemoteOrigin, | |
| 1058 | 1057 | EnvironmentPolicy: request.EnvironmentPolicy, | |
| 1059 | 1058 | NavigationCapabilities: request.NavigationCapabilities, | |
| 1060 | 1059 | StartupOverlay: request.StartupOverlay, | |
| @@ -181,7 +181,7 @@ type AttachRequest struct { | |||||
| 181 | RemoteTarget *domain.RemoteSessionTarget | 181 | RemoteTarget *domain.RemoteSessionTarget | ||
| 182 | ExactTarget *ports.ExactSessionTarget | 182 | ExactTarget *ports.ExactSessionTarget | ||
| 183 | PreferredTabID domain.TabStableID | 183 | PreferredTabID domain.TabStableID | ||
| 184 | - | RemoteOrigin string | 184 | + | HostLabel string |
| 185 | EnvironmentPolicy ports.EnvironmentPolicy | 185 | EnvironmentPolicy ports.EnvironmentPolicy | ||
| 186 | NavigationCapabilities ports.NavigationCapabilities | 186 | NavigationCapabilities ports.NavigationCapabilities | ||
| 187 | StartupOverlay ports.StartupOverlay | 187 | StartupOverlay ports.StartupOverlay | ||
| @@ -1054,7 +1054,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1054 | RemoteTarget: request.RemoteTarget, | 1054 | RemoteTarget: request.RemoteTarget, | ||
| 1055 | ExactTarget: exactTarget, | 1055 | ExactTarget: exactTarget, | ||
| 1056 | PreferredTabID: request.PreferredTabID, | 1056 | PreferredTabID: request.PreferredTabID, | ||
| 1057 | - | RemoteOrigin: request.RemoteOrigin, | |||
| 1058 | EnvironmentPolicy: request.EnvironmentPolicy, | 1057 | EnvironmentPolicy: request.EnvironmentPolicy, | ||
| 1059 | NavigationCapabilities: request.NavigationCapabilities, | 1058 | NavigationCapabilities: request.NavigationCapabilities, | ||
| 1060 | StartupOverlay: request.StartupOverlay, | 1059 | StartupOverlay: request.StartupOverlay, | ||
M internal/usecase/client/routes.go +24 -14
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "sync" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -23,7 +24,7 @@ var ( | |||
| 23 | 24 | // routeKey is process-local identity. It is never derived from a daemon | |
| 24 | 25 | // lifecycle ID and is not exposed outside the client ledger. originKey keeps | |
| 25 | 26 | // distinct daemon endpoints separate even when their lifecycle/name pairs | |
| 26 | - | // happen to match; neither is serialized into the route snapshot. | |
| 27 | + | // happen to match; it is not serialized into the route snapshot. | |
| 27 | 28 | type routeKey uint64 | |
| 28 | 29 | ||
| 29 | 30 | type routeGeneration uint64 | |
| @@ -121,10 +122,13 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout | |||
| 121 | 122 | if request.Remote || request.RemoteTarget != nil || origin != ports.RouteOriginLocal { | |
| 122 | 123 | kind = ports.RouteKindRemote | |
| 123 | 124 | if request.RemoteTarget != nil { | |
| 124 | - | hostLabel = request.RemoteTarget.DisplayOrigin | |
| 125 | + | hostLabel = domain.RemoteDisplayOrigin(request.RemoteTarget.DisplayOrigin) | |
| 125 | 126 | } | |
| 126 | 127 | if hostLabel == "" { | |
| 127 | - | hostLabel = originKey | |
| 128 | + | hostLabel = domain.RemoteDisplayOrigin(request.HostLabel) | |
| 129 | + | } | |
| 130 | + | if hostLabel == "" { | |
| 131 | + | hostLabel = domain.RemoteDisplayOrigin(originKey) | |
| 128 | 132 | } | |
| 129 | 133 | } | |
| 130 | 134 | request = cloneAttachRequest(request) | |
| @@ -132,7 +136,7 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout | |||
| 132 | 136 | request.OriginKey = originKey | |
| 133 | 137 | request.SessionName = identity.Target.SessionName | |
| 134 | 138 | request.ExactTarget = &identity.Target | |
| 135 | - | request.RemoteOrigin = hostLabel | |
| 139 | + | request.HostLabel = hostLabel | |
| 136 | 140 | // Discovery targets are point-in-time attach authority, including a live | |
| 137 | 141 | // tab selector. Once Welcome commits an exact route, mutable tab memory is | |
| 138 | 142 | // carried independently by PreferredTabID. | |
| @@ -499,6 +503,20 @@ func (l *routeLedger) attentionSubscription() ports.RouteAttentionSubscription { | |||
| 499 | 503 | return subscription | |
| 500 | 504 | } | |
| 501 | 505 | ||
| 506 | + | func (r routeRecord) snapshotEntry() ports.RecentRouteEntry { | |
| 507 | + | return ports.RecentRouteEntry{ | |
| 508 | + | Key: uint64(r.identity.key), | |
| 509 | + | Generation: uint64(r.identity.generation), | |
| 510 | + | Target: r.target, | |
| 511 | + | Name: r.presentation.name, | |
| 512 | + | HostLabel: r.presentation.hostLabel, | |
| 513 | + | Kind: r.presentation.kind, | |
| 514 | + | Ephemeral: r.presentation.ephemeral, | |
| 515 | + | Attention: r.presentation.attention, | |
| 516 | + | Reachability: r.presentation.reachability, | |
| 517 | + | } | |
| 518 | + | } | |
| 519 | + | ||
| 502 | 520 | func (l *routeLedger) snapshot() ports.RecentRouteSnapshot { | |
| 503 | 521 | l.mu.RLock() | |
| 504 | 522 | defer l.mu.RUnlock() | |
| @@ -515,18 +533,10 @@ func (l *routeLedger) snapshot() ports.RecentRouteSnapshot { | |||
| 515 | 533 | snapshot.Entries = make([]ports.RecentRouteEntry, 0, len(l.entries)) | |
| 516 | 534 | for _, entry := range l.entries { | |
| 517 | 535 | if entry.identity == l.active { | |
| 536 | + | snapshot.ActiveEntry = entry.snapshotEntry() | |
| 518 | 537 | continue | |
| 519 | 538 | } | |
| 520 | - | snapshot.Entries = append(snapshot.Entries, ports.RecentRouteEntry{ | |
| 521 | - | Key: uint64(entry.identity.key), | |
| 522 | - | Generation: uint64(entry.identity.generation), | |
| 523 | - | Name: entry.presentation.name, | |
| 524 | - | HostLabel: entry.presentation.hostLabel, | |
| 525 | - | Kind: entry.presentation.kind, | |
| 526 | - | Ephemeral: entry.presentation.ephemeral, | |
| 527 | - | Attention: entry.presentation.attention, | |
| 528 | - | Reachability: entry.presentation.reachability, | |
| 529 | - | }) | |
| 539 | + | snapshot.Entries = append(snapshot.Entries, entry.snapshotEntry()) | |
| 530 | 540 | } | |
| 531 | 541 | return snapshot | |
| 532 | 542 | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "sync" | 7 | "sync" | ||
| 8 | 8 | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| @@ -23,7 +24,7 @@ var ( | |||||
| 23 | // routeKey is process-local identity. It is never derived from a daemon | 24 | // routeKey is process-local identity. It is never derived from a daemon | ||
| 24 | // lifecycle ID and is not exposed outside the client ledger. originKey keeps | 25 | // lifecycle ID and is not exposed outside the client ledger. originKey keeps | ||
| 25 | // distinct daemon endpoints separate even when their lifecycle/name pairs | 26 | // distinct daemon endpoints separate even when their lifecycle/name pairs | ||
| 26 | - | // happen to match; neither is serialized into the route snapshot. | 27 | + | // happen to match; it is not serialized into the route snapshot. |
| 27 | type routeKey uint64 | 28 | type routeKey uint64 | ||
| 28 | 29 | ||||
| 29 | type routeGeneration uint64 | 30 | type routeGeneration uint64 | ||
| @@ -121,10 +122,13 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout | |||||
| 121 | if request.Remote || request.RemoteTarget != nil || origin != ports.RouteOriginLocal { | 122 | if request.Remote || request.RemoteTarget != nil || origin != ports.RouteOriginLocal { | ||
| 122 | kind = ports.RouteKindRemote | 123 | kind = ports.RouteKindRemote | ||
| 123 | if request.RemoteTarget != nil { | 124 | if request.RemoteTarget != nil { | ||
| 124 | - | hostLabel = request.RemoteTarget.DisplayOrigin | 125 | + | hostLabel = domain.RemoteDisplayOrigin(request.RemoteTarget.DisplayOrigin) |
| 125 | } | 126 | } | ||
| 126 | if hostLabel == "" { | 127 | if hostLabel == "" { | ||
| 127 | - | hostLabel = originKey | 128 | + | hostLabel = domain.RemoteDisplayOrigin(request.HostLabel) |
| 129 | + | } | |||
| 130 | + | if hostLabel == "" { | |||
| 131 | + | hostLabel = domain.RemoteDisplayOrigin(originKey) | |||
| 128 | } | 132 | } | ||
| 129 | } | 133 | } | ||
| 130 | request = cloneAttachRequest(request) | 134 | request = cloneAttachRequest(request) | ||
| @@ -132,7 +136,7 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout | |||||
| 132 | request.OriginKey = originKey | 136 | request.OriginKey = originKey | ||
| 133 | request.SessionName = identity.Target.SessionName | 137 | request.SessionName = identity.Target.SessionName | ||
| 134 | request.ExactTarget = &identity.Target | 138 | request.ExactTarget = &identity.Target | ||
| 135 | - | request.RemoteOrigin = hostLabel | 139 | + | request.HostLabel = hostLabel |
| 136 | // Discovery targets are point-in-time attach authority, including a live | 140 | // Discovery targets are point-in-time attach authority, including a live | ||
| 137 | // tab selector. Once Welcome commits an exact route, mutable tab memory is | 141 | // tab selector. Once Welcome commits an exact route, mutable tab memory is | ||
| 138 | // carried independently by PreferredTabID. | 142 | // carried independently by PreferredTabID. | ||
| @@ -499,6 +503,20 @@ func (l *routeLedger) attentionSubscription() ports.RouteAttentionSubscription { | |||||
| 499 | return subscription | 503 | return subscription | ||
| 500 | } | 504 | } | ||
| 501 | 505 | ||||
| 506 | + | func (r routeRecord) snapshotEntry() ports.RecentRouteEntry { | |||
| 507 | + | return ports.RecentRouteEntry{ | |||
| 508 | + | Key: uint64(r.identity.key), | |||
| 509 | + | Generation: uint64(r.identity.generation), | |||
| 510 | + | Target: r.target, | |||
| 511 | + | Name: r.presentation.name, | |||
| 512 | + | HostLabel: r.presentation.hostLabel, | |||
| 513 | + | Kind: r.presentation.kind, | |||
| 514 | + | Ephemeral: r.presentation.ephemeral, | |||
| 515 | + | Attention: r.presentation.attention, | |||
| 516 | + | Reachability: r.presentation.reachability, | |||
| 517 | + | } | |||
| 518 | + | } | |||
| 519 | + | ||||
| 502 | func (l *routeLedger) snapshot() ports.RecentRouteSnapshot { | 520 | func (l *routeLedger) snapshot() ports.RecentRouteSnapshot { | ||
| 503 | l.mu.RLock() | 521 | l.mu.RLock() | ||
| 504 | defer l.mu.RUnlock() | 522 | defer l.mu.RUnlock() | ||
| @@ -515,18 +533,10 @@ func (l *routeLedger) snapshot() ports.RecentRouteSnapshot { | |||||
| 515 | snapshot.Entries = make([]ports.RecentRouteEntry, 0, len(l.entries)) | 533 | snapshot.Entries = make([]ports.RecentRouteEntry, 0, len(l.entries)) | ||
| 516 | for _, entry := range l.entries { | 534 | for _, entry := range l.entries { | ||
| 517 | if entry.identity == l.active { | 535 | if entry.identity == l.active { | ||
| 536 | + | snapshot.ActiveEntry = entry.snapshotEntry() | |||
| 518 | continue | 537 | continue | ||
| 519 | } | 538 | } | ||
| 520 | - | snapshot.Entries = append(snapshot.Entries, ports.RecentRouteEntry{ | 539 | + | snapshot.Entries = append(snapshot.Entries, entry.snapshotEntry()) |
| 521 | - | Key: uint64(entry.identity.key), | |||
| 522 | - | Generation: uint64(entry.identity.generation), | |||
| 523 | - | Name: entry.presentation.name, | |||
| 524 | - | HostLabel: entry.presentation.hostLabel, | |||
| 525 | - | Kind: entry.presentation.kind, | |||
| 526 | - | Ephemeral: entry.presentation.ephemeral, | |||
| 527 | - | Attention: entry.presentation.attention, | |||
| 528 | - | Reachability: entry.presentation.reachability, | |||
| 529 | - | }) | |||
| 530 | } | 540 | } | ||
| 531 | return snapshot | 541 | return snapshot | ||
| 532 | } | 542 | } | ||
M internal/usecase/client/routes_test.go +7 -3
| @@ -33,15 +33,15 @@ func routeTestCandidate(index byte, origin ports.RouteOrigin) routeCandidate { | |||
| 33 | 33 | } | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | - | func TestRouteCandidateRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) { | |
| 36 | + | func TestRouteCandidateRetainsHostLabelWithoutDiscoveryTarget(t *testing.T) { | |
| 37 | 37 | target := routeTestTarget(1) | |
| 38 | 38 | candidate := routeCandidateForAttach(AttachRequest{ | |
| 39 | 39 | Intent: ports.IntentAttach, SessionName: target.SessionName, Remote: true, | |
| 40 | - | Origin: ports.RouteOriginRemote, OriginKey: "remote", | |
| 40 | + | Origin: ports.RouteOriginRemote, OriginKey: "user@remote", HostLabel: "remote", | |
| 41 | 41 | }, ports.CommittedRouteIdentity{Target: target}, nil, 0) | |
| 42 | 42 | ||
| 43 | 43 | require.Nil(t, candidate.request.RemoteTarget) | |
| 44 | - | require.Equal(t, "remote", candidate.request.RemoteOrigin) | |
| 44 | + | require.Equal(t, "remote", candidate.request.HostLabel) | |
| 45 | 45 | require.Equal(t, "remote", candidate.presentation.hostLabel) | |
| 46 | 46 | } | |
| 47 | 47 | ||
| @@ -60,7 +60,11 @@ func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) { | |||
| 60 | 60 | require.Len(t, ledger.entries, maxRouteLedgerEntries) | |
| 61 | 61 | require.Len(t, snapshot.Entries, maxRouteLedgerEntries-1) | |
| 62 | 62 | require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key) | |
| 63 | + | require.Equal(t, routeTestTarget(maxRouteLedgerEntries+7), snapshot.ActiveEntry.Target) | |
| 64 | + | require.Equal(t, snapshot.Active, ports.RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation}) | |
| 63 | 65 | for _, entry := range snapshot.Entries { | |
| 66 | + | require.NoError(t, entry.Target.Validate()) | |
| 67 | + | require.Equal(t, entry.Name, entry.Target.SessionName) | |
| 64 | 68 | require.NotEqual(t, snapshot.Active, ports.RouteRef{Key: entry.Key, Generation: entry.Generation}) | |
| 65 | 69 | } | |
| 66 | 70 | require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key) | |
| @@ -33,15 +33,15 @@ func routeTestCandidate(index byte, origin ports.RouteOrigin) routeCandidate { | |||||
| 33 | } | 33 | } | ||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | - | func TestRouteCandidateRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) { | 36 | + | func TestRouteCandidateRetainsHostLabelWithoutDiscoveryTarget(t *testing.T) { |
| 37 | target := routeTestTarget(1) | 37 | target := routeTestTarget(1) | ||
| 38 | candidate := routeCandidateForAttach(AttachRequest{ | 38 | candidate := routeCandidateForAttach(AttachRequest{ | ||
| 39 | Intent: ports.IntentAttach, SessionName: target.SessionName, Remote: true, | 39 | Intent: ports.IntentAttach, SessionName: target.SessionName, Remote: true, | ||
| 40 | - | Origin: ports.RouteOriginRemote, OriginKey: "remote", | 40 | + | Origin: ports.RouteOriginRemote, OriginKey: "user@remote", HostLabel: "remote", |
| 41 | }, ports.CommittedRouteIdentity{Target: target}, nil, 0) | 41 | }, ports.CommittedRouteIdentity{Target: target}, nil, 0) | ||
| 42 | 42 | ||||
| 43 | require.Nil(t, candidate.request.RemoteTarget) | 43 | require.Nil(t, candidate.request.RemoteTarget) | ||
| 44 | - | require.Equal(t, "remote", candidate.request.RemoteOrigin) | 44 | + | require.Equal(t, "remote", candidate.request.HostLabel) |
| 45 | require.Equal(t, "remote", candidate.presentation.hostLabel) | 45 | require.Equal(t, "remote", candidate.presentation.hostLabel) | ||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| @@ -60,7 +60,11 @@ func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) { | |||||
| 60 | require.Len(t, ledger.entries, maxRouteLedgerEntries) | 60 | require.Len(t, ledger.entries, maxRouteLedgerEntries) | ||
| 61 | require.Len(t, snapshot.Entries, maxRouteLedgerEntries-1) | 61 | require.Len(t, snapshot.Entries, maxRouteLedgerEntries-1) | ||
| 62 | require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key) | 62 | require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key) | ||
| 63 | + | require.Equal(t, routeTestTarget(maxRouteLedgerEntries+7), snapshot.ActiveEntry.Target) | |||
| 64 | + | require.Equal(t, snapshot.Active, ports.RouteRef{Key: snapshot.ActiveEntry.Key, Generation: snapshot.ActiveEntry.Generation}) | |||
| 63 | for _, entry := range snapshot.Entries { | 65 | for _, entry := range snapshot.Entries { | ||
| 66 | + | require.NoError(t, entry.Target.Validate()) | |||
| 67 | + | require.Equal(t, entry.Name, entry.Target.SessionName) | |||
| 64 | require.NotEqual(t, snapshot.Active, ports.RouteRef{Key: entry.Key, Generation: entry.Generation}) | 68 | require.NotEqual(t, snapshot.Active, ports.RouteRef{Key: entry.Key, Generation: entry.Generation}) | ||
| 65 | } | 69 | } | ||
| 66 | require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key) | 70 | require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key) | ||
M internal/usecase/daemon/attachment_effect_gate_test.go +12 -10
| @@ -676,13 +676,14 @@ func TestAttachedRouteSnapshotIsAcceptedAsAnAttachmentValue(t *testing.T) { | |||
| 676 | 676 | token.lease = rc.attachmentLease(ac) | |
| 677 | 677 | ac.publishAttachmentCapability(token) | |
| 678 | 678 | ||
| 679 | + | activeRef := ports.RouteRef{Key: 8, Generation: 4} | |
| 680 | + | activeTarget := testRouteTarget(source.name, 8) | |
| 679 | 681 | snapshot := ports.RecentRouteSnapshot{ | |
| 680 | - | Generation: 4, | |
| 681 | - | Active: ports.RouteRef{Key: 8, Generation: 4}, | |
| 682 | - | Home: ports.RouteRef{Key: 8, Generation: 4}, | |
| 683 | - | Entries: []ports.RecentRouteEntry{{ | |
| 684 | - | Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal, | |
| 685 | - | }}, | |
| 682 | + | Generation: 4, | |
| 683 | + | Active: activeRef, | |
| 684 | + | ActiveEntry: ports.RecentRouteEntry{Key: 8, Generation: 4, Target: activeTarget, Name: source.name, Kind: ports.RouteKindLocal}, | |
| 685 | + | Home: activeRef, | |
| 686 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(7, 3, "previous", 7, ports.RouteKindLocal)}, | |
| 686 | 687 | } | |
| 687 | 688 | payload, err := ports.MarshalRecentRouteSnapshot(snapshot) | |
| 688 | 689 | require.NoError(t, err) | |
| @@ -700,10 +701,11 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) | |||
| 700 | 701 | d.sessions[target.id] = target | |
| 701 | 702 | d.mu.Unlock() | |
| 702 | 703 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 703 | - | Generation: 2, | |
| 704 | - | Active: ports.RouteRef{Key: 2, Generation: 2}, | |
| 705 | - | Previous: ports.RouteRef{Key: 1, Generation: 1}, | |
| 706 | - | Entries: []ports.RecentRouteEntry{{Key: 1, Generation: 1, Name: "target", Kind: ports.RouteKindLocal}}, | |
| 704 | + | Generation: 2, | |
| 705 | + | Active: ports.RouteRef{Key: 2, Generation: 2}, | |
| 706 | + | ActiveEntry: testRouteEntry(2, 2, source.name, 2, ports.RouteKindLocal), | |
| 707 | + | Previous: ports.RouteRef{Key: 1, Generation: 1}, | |
| 708 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(1, 1, "target", 1, ports.RouteKindLocal)}, | |
| 707 | 709 | }) | |
| 708 | 710 | ||
| 709 | 711 | transport := &closeTrackingTransport{} | |
| @@ -676,13 +676,14 @@ func TestAttachedRouteSnapshotIsAcceptedAsAnAttachmentValue(t *testing.T) { | |||||
| 676 | token.lease = rc.attachmentLease(ac) | 676 | token.lease = rc.attachmentLease(ac) | ||
| 677 | ac.publishAttachmentCapability(token) | 677 | ac.publishAttachmentCapability(token) | ||
| 678 | 678 | ||||
| 679 | + | activeRef := ports.RouteRef{Key: 8, Generation: 4} | |||
| 680 | + | activeTarget := testRouteTarget(source.name, 8) | |||
| 679 | snapshot := ports.RecentRouteSnapshot{ | 681 | snapshot := ports.RecentRouteSnapshot{ | ||
| 680 | - | Generation: 4, | 682 | + | Generation: 4, |
| 681 | - | Active: ports.RouteRef{Key: 8, Generation: 4}, | 683 | + | Active: activeRef, |
| 682 | - | Home: ports.RouteRef{Key: 8, Generation: 4}, | 684 | + | ActiveEntry: ports.RecentRouteEntry{Key: 8, Generation: 4, Target: activeTarget, Name: source.name, Kind: ports.RouteKindLocal}, |
| 683 | - | Entries: []ports.RecentRouteEntry{{ | 685 | + | Home: activeRef, |
| 684 | - | Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal, | 686 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(7, 3, "previous", 7, ports.RouteKindLocal)}, |
| 685 | - | }}, | |||
| 686 | } | 687 | } | ||
| 687 | payload, err := ports.MarshalRecentRouteSnapshot(snapshot) | 688 | payload, err := ports.MarshalRecentRouteSnapshot(snapshot) | ||
| 688 | require.NoError(t, err) | 689 | require.NoError(t, err) | ||
| @@ -700,10 +701,11 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) | |||||
| 700 | d.sessions[target.id] = target | 701 | d.sessions[target.id] = target | ||
| 701 | d.mu.Unlock() | 702 | d.mu.Unlock() | ||
| 702 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | 703 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | ||
| 703 | - | Generation: 2, | 704 | + | Generation: 2, |
| 704 | - | Active: ports.RouteRef{Key: 2, Generation: 2}, | 705 | + | Active: ports.RouteRef{Key: 2, Generation: 2}, |
| 705 | - | Previous: ports.RouteRef{Key: 1, Generation: 1}, | 706 | + | ActiveEntry: testRouteEntry(2, 2, source.name, 2, ports.RouteKindLocal), |
| 706 | - | Entries: []ports.RecentRouteEntry{{Key: 1, Generation: 1, Name: "target", Kind: ports.RouteKindLocal}}, | 707 | + | Previous: ports.RouteRef{Key: 1, Generation: 1}, |
| 708 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(1, 1, "target", 1, ports.RouteKindLocal)}, | |||
| 707 | }) | 709 | }) | ||
| 708 | 710 | ||||
| 709 | transport := &closeTrackingTransport{} | 711 | transport := &closeTrackingTransport{} | ||
M internal/usecase/daemon/client.go +6 -11
| @@ -33,15 +33,12 @@ type appliedTheme struct { | |||
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | type attachedClient struct { | |
| 36 | - | tr ports.Transport | |
| 37 | - | transportIncarnation uint64 | |
| 38 | - | output *outputStateStream | |
| 39 | - | overlays *overlayRuntime | |
| 40 | - | overlayOnce sync.Once | |
| 41 | - | clientID [16]byte | |
| 42 | - | // remoteOrigin is attachment-specific presentation metadata supplied by a | |
| 43 | - | // validated picker handoff; it never participates in routing. | |
| 44 | - | remoteOrigin string | |
| 36 | + | tr ports.Transport | |
| 37 | + | transportIncarnation uint64 | |
| 38 | + | output *outputStateStream | |
| 39 | + | overlays *overlayRuntime | |
| 40 | + | overlayOnce sync.Once | |
| 41 | + | clientID [16]byte | |
| 45 | 42 | terminalCapabilities ports.TerminalCapabilities | |
| 46 | 43 | navigationCapabilities ports.NavigationCapabilities | |
| 47 | 44 | startupOverlay ports.StartupOverlay | |
| @@ -628,7 +625,6 @@ type attachClientOptions struct { | |||
| 628 | 625 | clientID [16]byte | |
| 629 | 626 | resumeCapable bool | |
| 630 | 627 | maxOutputInFlight uint8 | |
| 631 | - | remoteOrigin string | |
| 632 | 628 | terminalCapabilities ports.TerminalCapabilities | |
| 633 | 629 | capabilitiesSet bool | |
| 634 | 630 | navigationCapabilities ports.NavigationCapabilities | |
| @@ -691,7 +687,6 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, | |||
| 691 | 687 | size: sz, | |
| 692 | 688 | view: attachmentView{windowRows: sz.Rows, windowSet: true}, | |
| 693 | 689 | clientID: opts.clientID, | |
| 694 | - | remoteOrigin: opts.remoteOrigin, | |
| 695 | 690 | terminalCapabilities: opts.terminalCapabilities, | |
| 696 | 691 | navigationCapabilities: opts.navigationCapabilities, | |
| 697 | 692 | startupOverlay: opts.startupOverlay, | |
| @@ -33,15 +33,12 @@ type appliedTheme struct { | |||||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | type attachedClient struct { | 35 | type attachedClient struct { | ||
| 36 | - | tr ports.Transport | 36 | + | tr ports.Transport |
| 37 | - | transportIncarnation uint64 | 37 | + | transportIncarnation uint64 |
| 38 | - | output *outputStateStream | 38 | + | output *outputStateStream |
| 39 | - | overlays *overlayRuntime | 39 | + | overlays *overlayRuntime |
| 40 | - | overlayOnce sync.Once | 40 | + | overlayOnce sync.Once |
| 41 | - | clientID [16]byte | 41 | + | clientID [16]byte |
| 42 | - | // remoteOrigin is attachment-specific presentation metadata supplied by a | |||
| 43 | - | // validated picker handoff; it never participates in routing. | |||
| 44 | - | remoteOrigin string | |||
| 45 | terminalCapabilities ports.TerminalCapabilities | 42 | terminalCapabilities ports.TerminalCapabilities | ||
| 46 | navigationCapabilities ports.NavigationCapabilities | 43 | navigationCapabilities ports.NavigationCapabilities | ||
| 47 | startupOverlay ports.StartupOverlay | 44 | startupOverlay ports.StartupOverlay | ||
| @@ -628,7 +625,6 @@ type attachClientOptions struct { | |||||
| 628 | clientID [16]byte | 625 | clientID [16]byte | ||
| 629 | resumeCapable bool | 626 | resumeCapable bool | ||
| 630 | maxOutputInFlight uint8 | 627 | maxOutputInFlight uint8 | ||
| 631 | - | remoteOrigin string | |||
| 632 | terminalCapabilities ports.TerminalCapabilities | 628 | terminalCapabilities ports.TerminalCapabilities | ||
| 633 | capabilitiesSet bool | 629 | capabilitiesSet bool | ||
| 634 | navigationCapabilities ports.NavigationCapabilities | 630 | navigationCapabilities ports.NavigationCapabilities | ||
| @@ -691,7 +687,6 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, | |||||
| 691 | size: sz, | 687 | size: sz, | ||
| 692 | view: attachmentView{windowRows: sz.Rows, windowSet: true}, | 688 | view: attachmentView{windowRows: sz.Rows, windowSet: true}, | ||
| 693 | clientID: opts.clientID, | 689 | clientID: opts.clientID, | ||
| 694 | - | remoteOrigin: opts.remoteOrigin, | |||
| 695 | terminalCapabilities: opts.terminalCapabilities, | 690 | terminalCapabilities: opts.terminalCapabilities, | ||
| 696 | navigationCapabilities: opts.navigationCapabilities, | 691 | navigationCapabilities: opts.navigationCapabilities, | ||
| 697 | startupOverlay: opts.startupOverlay, | 692 | startupOverlay: opts.startupOverlay, | ||
M internal/usecase/daemon/coderabbit_regression_test.go +63 -0
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | 15 | "github.com/bnema/vev/internal/ports" | |
| 16 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 16 | 17 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 17 | 18 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 18 | 19 | ) | |
| @@ -243,6 +244,68 @@ func TestCreateSessionRechecksShutdownAfterCatalogueRead(t *testing.T) { | |||
| 243 | 244 | require.Empty(t, d.sessions) | |
| 244 | 245 | } | |
| 245 | 246 | ||
| 247 | + | type blockingResumeCatalogue struct { | |
| 248 | + | *durableRecoveryCatalogue | |
| 249 | + | entered chan struct{} | |
| 250 | + | release chan struct{} | |
| 251 | + | once sync.Once | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (c *blockingResumeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) { | |
| 255 | + | c.once.Do(func() { close(c.entered) }) | |
| 256 | + | <-c.release | |
| 257 | + | return c.durableRecoveryCatalogue.Record(name) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func TestResumeStoppedSessionRejectsReplacementDuringCatalogueRead(t *testing.T) { | |
| 261 | + | sourcePTY, releaseSource := newBlockingPTY(t) | |
| 262 | + | defer releaseSource() | |
| 263 | + | targetPTY, releaseTarget := newBlockingPTY(t) | |
| 264 | + | defer releaseTarget() | |
| 265 | + | d, from, ac, _ := newManualSessionWithPTYs(t, sourcePTY) | |
| 266 | + | d.ptys = newFactorySeq(t, targetPTY) | |
| 267 | + | ||
| 268 | + | record := durableRecoveryRecord(1) | |
| 269 | + | record.Name = "stopped" | |
| 270 | + | catalogue := &blockingResumeCatalogue{ | |
| 271 | + | durableRecoveryCatalogue: newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), | |
| 272 | + | entered: make(chan struct{}), | |
| 273 | + | release: make(chan struct{}), | |
| 274 | + | } | |
| 275 | + | d.catalogue = catalogue | |
| 276 | + | d.persistEnabled = true | |
| 277 | + | d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil) | |
| 278 | + | d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionDown, nil) | |
| 279 | + | ||
| 280 | + | target := picker.Target{ | |
| 281 | + | Name: record.Name, | |
| 282 | + | Stopped: true, | |
| 283 | + | Incarnation: record.IncarnationID, | |
| 284 | + | ExpectedCreatedAt: &record.CreatedAt, | |
| 285 | + | } | |
| 286 | + | result := make(chan bool, 1) | |
| 287 | + | go func() { result <- d.resumeStoppedAndSwitch(from, ac, target) }() | |
| 288 | + | ||
| 289 | + | <-catalogue.entered | |
| 290 | + | replacement := record | |
| 291 | + | replacement.IncarnationID = domain.IncarnationID{9} | |
| 292 | + | replacement.CreatedAt++ | |
| 293 | + | replacement.UpdatedAt = replacement.CreatedAt | |
| 294 | + | require.NoError(t, catalogue.Replace(replacement.Name, replacement)) | |
| 295 | + | d.mu.Lock() | |
| 296 | + | d.stopped[replacement.Name] = stoppedSessionFromRecord(replacement, ports.SessionDown, nil) | |
| 297 | + | d.mu.Unlock() | |
| 298 | + | close(catalogue.release) | |
| 299 | + | ||
| 300 | + | require.False(t, <-result) | |
| 301 | + | require.Same(t, from, ac.currentSession()) | |
| 302 | + | require.Empty(t, catalogue.MetadataUpdates()) | |
| 303 | + | d.mu.Lock() | |
| 304 | + | defer d.mu.Unlock() | |
| 305 | + | require.Nil(t, d.findByNameLocked(record.Name)) | |
| 306 | + | require.Equal(t, replacement.IncarnationID, d.stopped[record.Name].incarnation) | |
| 307 | + | } | |
| 308 | + | ||
| 246 | 309 | func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) { | |
| 247 | 310 | deadline := &snapshotShutdownDeadline{done: make(chan struct{})} | |
| 248 | 311 | _, cancel := snapshotStopContext(deadline) | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 15 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 16 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 16 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | 17 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | ||
| 17 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 18 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 18 | ) | 19 | ) | ||
| @@ -243,6 +244,68 @@ func TestCreateSessionRechecksShutdownAfterCatalogueRead(t *testing.T) { | |||||
| 243 | require.Empty(t, d.sessions) | 244 | require.Empty(t, d.sessions) | ||
| 244 | } | 245 | } | ||
| 245 | 246 | ||||
| 247 | + | type blockingResumeCatalogue struct { | |||
| 248 | + | *durableRecoveryCatalogue | |||
| 249 | + | entered chan struct{} | |||
| 250 | + | release chan struct{} | |||
| 251 | + | once sync.Once | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (c *blockingResumeCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) { | |||
| 255 | + | c.once.Do(func() { close(c.entered) }) | |||
| 256 | + | <-c.release | |||
| 257 | + | return c.durableRecoveryCatalogue.Record(name) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func TestResumeStoppedSessionRejectsReplacementDuringCatalogueRead(t *testing.T) { | |||
| 261 | + | sourcePTY, releaseSource := newBlockingPTY(t) | |||
| 262 | + | defer releaseSource() | |||
| 263 | + | targetPTY, releaseTarget := newBlockingPTY(t) | |||
| 264 | + | defer releaseTarget() | |||
| 265 | + | d, from, ac, _ := newManualSessionWithPTYs(t, sourcePTY) | |||
| 266 | + | d.ptys = newFactorySeq(t, targetPTY) | |||
| 267 | + | ||||
| 268 | + | record := durableRecoveryRecord(1) | |||
| 269 | + | record.Name = "stopped" | |||
| 270 | + | catalogue := &blockingResumeCatalogue{ | |||
| 271 | + | durableRecoveryCatalogue: newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), | |||
| 272 | + | entered: make(chan struct{}), | |||
| 273 | + | release: make(chan struct{}), | |||
| 274 | + | } | |||
| 275 | + | d.catalogue = catalogue | |||
| 276 | + | d.persistEnabled = true | |||
| 277 | + | d.recovery = recoveryusecase.NewCoordinator(catalogue, noOpSnapshotRepository{}, nil) | |||
| 278 | + | d.stopped[record.Name] = stoppedSessionFromRecord(record, ports.SessionDown, nil) | |||
| 279 | + | ||||
| 280 | + | target := picker.Target{ | |||
| 281 | + | Name: record.Name, | |||
| 282 | + | Stopped: true, | |||
| 283 | + | Incarnation: record.IncarnationID, | |||
| 284 | + | ExpectedCreatedAt: &record.CreatedAt, | |||
| 285 | + | } | |||
| 286 | + | result := make(chan bool, 1) | |||
| 287 | + | go func() { result <- d.resumeStoppedAndSwitch(from, ac, target) }() | |||
| 288 | + | ||||
| 289 | + | <-catalogue.entered | |||
| 290 | + | replacement := record | |||
| 291 | + | replacement.IncarnationID = domain.IncarnationID{9} | |||
| 292 | + | replacement.CreatedAt++ | |||
| 293 | + | replacement.UpdatedAt = replacement.CreatedAt | |||
| 294 | + | require.NoError(t, catalogue.Replace(replacement.Name, replacement)) | |||
| 295 | + | d.mu.Lock() | |||
| 296 | + | d.stopped[replacement.Name] = stoppedSessionFromRecord(replacement, ports.SessionDown, nil) | |||
| 297 | + | d.mu.Unlock() | |||
| 298 | + | close(catalogue.release) | |||
| 299 | + | ||||
| 300 | + | require.False(t, <-result) | |||
| 301 | + | require.Same(t, from, ac.currentSession()) | |||
| 302 | + | require.Empty(t, catalogue.MetadataUpdates()) | |||
| 303 | + | d.mu.Lock() | |||
| 304 | + | defer d.mu.Unlock() | |||
| 305 | + | require.Nil(t, d.findByNameLocked(record.Name)) | |||
| 306 | + | require.Equal(t, replacement.IncarnationID, d.stopped[record.Name].incarnation) | |||
| 307 | + | } | |||
| 308 | + | ||||
| 246 | func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) { | 309 | func TestSnapshotStopContextCancelIsIdempotent(t *testing.T) { | ||
| 247 | deadline := &snapshotShutdownDeadline{done: make(chan struct{})} | 310 | deadline := &snapshotShutdownDeadline{done: make(chan struct{})} | ||
| 248 | _, cancel := snapshotStopContext(deadline) | 311 | _, cancel := snapshotStopContext(deadline) | ||
M internal/usecase/daemon/daemon.go +0 -4
| @@ -1288,13 +1288,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||
| 1288 | 1288 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | |
| 1289 | 1289 | navigationCapabilities: h.NavigationCapabilities, | |
| 1290 | 1290 | startupOverlay: h.StartupOverlay, | |
| 1291 | - | remoteOrigin: h.RemoteOrigin, | |
| 1292 | 1291 | terminalCapabilities: terminalCapabilities, | |
| 1293 | 1292 | capabilitiesSet: true, | |
| 1294 | 1293 | } | |
| 1295 | - | if opts.remoteOrigin == "" && h.RemoteTarget != nil { | |
| 1296 | - | opts.remoteOrigin = h.RemoteTarget.DisplayOrigin | |
| 1297 | - | } | |
| 1298 | 1294 | ac := d.prepareAttachedClientLocked(tr, sz, opts) | |
| 1299 | 1295 | d.mu.Unlock() | |
| 1300 | 1296 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| @@ -1288,13 +1288,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 1288 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | 1288 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | ||
| 1289 | navigationCapabilities: h.NavigationCapabilities, | 1289 | navigationCapabilities: h.NavigationCapabilities, | ||
| 1290 | startupOverlay: h.StartupOverlay, | 1290 | startupOverlay: h.StartupOverlay, | ||
| 1291 | - | remoteOrigin: h.RemoteOrigin, | |||
| 1292 | terminalCapabilities: terminalCapabilities, | 1291 | terminalCapabilities: terminalCapabilities, | ||
| 1293 | capabilitiesSet: true, | 1292 | capabilitiesSet: true, | ||
| 1294 | } | 1293 | } | ||
| 1295 | - | if opts.remoteOrigin == "" && h.RemoteTarget != nil { | |||
| 1296 | - | opts.remoteOrigin = h.RemoteTarget.DisplayOrigin | |||
| 1297 | - | } | |||
| 1298 | ac := d.prepareAttachedClientLocked(tr, sz, opts) | 1294 | ac := d.prepareAttachedClientLocked(tr, sz, opts) | ||
| 1299 | d.mu.Unlock() | 1295 | d.mu.Unlock() | ||
| 1300 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | 1296 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | ||
M internal/usecase/daemon/input_test.go +1 -1
| @@ -794,7 +794,7 @@ func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) { | |||
| 794 | 794 | Generation: 3, | |
| 795 | 795 | Active: ports.RouteRef{Key: 3, Generation: 3}, | |
| 796 | 796 | Previous: ports.RouteRef{Key: 2, Generation: 2}, | |
| 797 | - | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 2, Name: "recent", Kind: ports.RouteKindLocal}}, | |
| 797 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(2, 2, "recent", 2, ports.RouteKindLocal)}, | |
| 798 | 798 | }) | |
| 799 | 799 | ||
| 800 | 800 | require.NoError(t, d.backSessionForAttachment(token)) | |
| @@ -794,7 +794,7 @@ func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) { | |||||
| 794 | Generation: 3, | 794 | Generation: 3, | ||
| 795 | Active: ports.RouteRef{Key: 3, Generation: 3}, | 795 | Active: ports.RouteRef{Key: 3, Generation: 3}, | ||
| 796 | Previous: ports.RouteRef{Key: 2, Generation: 2}, | 796 | Previous: ports.RouteRef{Key: 2, Generation: 2}, | ||
| 797 | - | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 2, Name: "recent", Kind: ports.RouteKindLocal}}, | 797 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(2, 2, "recent", 2, ports.RouteKindLocal)}, |
| 798 | }) | 798 | }) | ||
| 799 | 799 | ||||
| 800 | require.NoError(t, d.backSessionForAttachment(token)) | 800 | require.NoError(t, d.backSessionForAttachment(token)) | ||
M internal/usecase/daemon/move_picker.go +1 -1
| @@ -128,7 +128,7 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess | |||
| 128 | 128 | sess.mu.Lock() | |
| 129 | 129 | d.mu.Unlock() | |
| 130 | 130 | defer sess.mu.Unlock() | |
| 131 | - | if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) { | |
| 131 | + | if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) { | |
| 132 | 132 | return nil, nil | |
| 133 | 133 | } | |
| 134 | 134 | if intent == pickerMoveTab { | |
| @@ -128,7 +128,7 @@ func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*sess | |||||
| 128 | sess.mu.Lock() | 128 | sess.mu.Lock() | ||
| 129 | d.mu.Unlock() | 129 | d.mu.Unlock() | ||
| 130 | defer sess.mu.Unlock() | 130 | defer sess.mu.Unlock() | ||
| 131 | - | if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) { | 131 | + | if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt, sess.incarnation) { |
| 132 | return nil, nil | 132 | return nil, nil | ||
| 133 | } | 133 | } | ||
| 134 | if intent == pickerMoveTab { | 134 | if intent == pickerMoveTab { | ||
M internal/usecase/daemon/navigation_attachment_incarnation_test.go +40 -0
| @@ -8,6 +8,46 @@ import ( | |||
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | + | func TestNavigationHandoffRejectsReplacedTargetLifecycleAtPublication(t *testing.T) { | |
| 12 | + | d, source, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 13 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target", incarnation: domain.SessionLifecycleID{1}}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 14 | + | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 15 | + | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 16 | + | }} | |
| 17 | + | d.mu.Lock() | |
| 18 | + | d.sessions[target.id] = target | |
| 19 | + | d.mu.Unlock() | |
| 20 | + | ||
| 21 | + | rc := d.attachCoordinator(source, nil, ac, true) | |
| 22 | + | token := source.attachmentToken(ac, ac.transport()) | |
| 23 | + | token.lease = rc.attachmentLease(ac) | |
| 24 | + | ac.publishAttachmentCapability(token) | |
| 25 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 26 | + | require.True(t, admitted) | |
| 27 | + | ||
| 28 | + | d.afterAttachmentTransitionCoordinatorsLocked = func() { | |
| 29 | + | // This hook runs with the target session locked, immediately before the | |
| 30 | + | // transition's lifecycle fence is validated. | |
| 31 | + | target.incarnation = domain.SessionLifecycleID{2} | |
| 32 | + | } | |
| 33 | + | err := d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{ | |
| 34 | + | Session: target.id, | |
| 35 | + | Incarnation: domain.SessionLifecycleID{1}, | |
| 36 | + | TabID: domain.TabStableID(target.tabs[1].stableID), | |
| 37 | + | TabIndex: 1, | |
| 38 | + | }, sessionHandoffGuard{}, "test-handoff") | |
| 39 | + | ||
| 40 | + | require.Error(t, err) | |
| 41 | + | var userErr *domain.UserError | |
| 42 | + | require.ErrorAs(t, err, &userErr) | |
| 43 | + | require.Equal(t, domain.NoticeSessionUnavailable, userErr.Code) | |
| 44 | + | require.Same(t, source, ac.currentSession()) | |
| 45 | + | target.mu.Lock() | |
| 46 | + | require.Empty(t, target.snapshotAttachmentsLocked()) | |
| 47 | + | require.Zero(t, testAttachmentTabIndexLocked(target)) | |
| 48 | + | target.mu.Unlock() | |
| 49 | + | } | |
| 50 | + | ||
| 11 | 51 | func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testing.T) { | |
| 12 | 52 | tests := []struct { | |
| 13 | 53 | name string | |
| @@ -8,6 +8,46 @@ import ( | |||||
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | + | func TestNavigationHandoffRejectsReplacedTargetLifecycleAtPublication(t *testing.T) { | |||
| 12 | + | d, source, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 13 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target", incarnation: domain.SessionLifecycleID{1}}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |||
| 14 | + | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |||
| 15 | + | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |||
| 16 | + | }} | |||
| 17 | + | d.mu.Lock() | |||
| 18 | + | d.sessions[target.id] = target | |||
| 19 | + | d.mu.Unlock() | |||
| 20 | + | ||||
| 21 | + | rc := d.attachCoordinator(source, nil, ac, true) | |||
| 22 | + | token := source.attachmentToken(ac, ac.transport()) | |||
| 23 | + | token.lease = rc.attachmentLease(ac) | |||
| 24 | + | ac.publishAttachmentCapability(token) | |||
| 25 | + | effect, admitted := ac.beginAttachmentEffect(token) | |||
| 26 | + | require.True(t, admitted) | |||
| 27 | + | ||||
| 28 | + | d.afterAttachmentTransitionCoordinatorsLocked = func() { | |||
| 29 | + | // This hook runs with the target session locked, immediately before the | |||
| 30 | + | // transition's lifecycle fence is validated. | |||
| 31 | + | target.incarnation = domain.SessionLifecycleID{2} | |||
| 32 | + | } | |||
| 33 | + | err := d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{ | |||
| 34 | + | Session: target.id, | |||
| 35 | + | Incarnation: domain.SessionLifecycleID{1}, | |||
| 36 | + | TabID: domain.TabStableID(target.tabs[1].stableID), | |||
| 37 | + | TabIndex: 1, | |||
| 38 | + | }, sessionHandoffGuard{}, "test-handoff") | |||
| 39 | + | ||||
| 40 | + | require.Error(t, err) | |||
| 41 | + | var userErr *domain.UserError | |||
| 42 | + | require.ErrorAs(t, err, &userErr) | |||
| 43 | + | require.Equal(t, domain.NoticeSessionUnavailable, userErr.Code) | |||
| 44 | + | require.Same(t, source, ac.currentSession()) | |||
| 45 | + | target.mu.Lock() | |||
| 46 | + | require.Empty(t, target.snapshotAttachmentsLocked()) | |||
| 47 | + | require.Zero(t, testAttachmentTabIndexLocked(target)) | |||
| 48 | + | target.mu.Unlock() | |||
| 49 | + | } | |||
| 50 | + | ||||
| 11 | func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testing.T) { | 51 | func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testing.T) { | ||
| 12 | tests := []struct { | 52 | tests := []struct { | ||
| 13 | name string | 53 | name string | ||
M internal/usecase/daemon/palette.go +20 -32
| @@ -42,7 +42,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 42 | 42 | // consults its own session history for recent-route commands. | |
| 43 | 43 | routeSnapshot := ac.routeSnapshotCopy() | |
| 44 | 44 | commands := d.paletteCommands() | |
| 45 | - | results := d.paletteResults(sess, commands, routeSnapshot, ac.remoteOrigin) | |
| 45 | + | results := d.paletteResults(sess, commands, routeSnapshot) | |
| 46 | 46 | ac.overlays.paletteMu.Lock() | |
| 47 | 47 | ac.overlays.paletteGeneration++ | |
| 48 | 48 | ac.overlays.palette = palette.New(results) | |
| @@ -57,7 +57,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 57 | 57 | ||
| 58 | 58 | // paletteResults captures eligible named sessions before paletteMu so the | |
| 59 | 59 | // palette has an immutable lifecycle target without violating lock ordering. | |
| 60 | - | func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot, currentRemoteOrigin string) []palette.Result { | |
| 60 | + | func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot) []palette.Result { | |
| 61 | 61 | d.mu.Lock() | |
| 62 | 62 | sessions := d.sessionsSnapshotLocked() | |
| 63 | 63 | stopped := make([]stoppedSession, 0, len(d.stopped)) | |
| @@ -73,14 +73,18 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 73 | 73 | results = append(results, palette.NewCommandResult(cmd)) | |
| 74 | 74 | } | |
| 75 | 75 | active := make([]palette.Result, 0, len(sessions)) | |
| 76 | + | localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(sessions)+len(stopped)) | |
| 76 | 77 | for _, candidate := range sessions { | |
| 77 | - | if candidate == current { | |
| 78 | + | snap := candidate.snapshotView(viewOptions{}) | |
| 79 | + | if snap.name == "" || snap.ephemeral { | |
| 78 | 80 | continue | |
| 79 | 81 | } | |
| 80 | - | snap := candidate.snapshotView(viewOptions{}) | |
| 81 | - | if snap.name != "" && !snap.ephemeral { | |
| 82 | - | active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), snap.id)) | |
| 82 | + | localLifecycles[snap.incarnation] = struct{}{} | |
| 83 | + | if candidate == current { | |
| 84 | + | continue | |
| 83 | 85 | } | |
| 86 | + | target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name} | |
| 87 | + | active = append(active, palette.NewActiveSessionResult(target, time.Unix(0, snap.createdAt))) | |
| 84 | 88 | } | |
| 85 | 89 | sort.Slice(active, func(i, j int) bool { | |
| 86 | 90 | left, _ := active[i].SessionName() | |
| @@ -89,14 +93,10 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 89 | 93 | }) | |
| 90 | 94 | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | |
| 91 | 95 | results = append(results, active...) | |
| 92 | - | localNames := make(map[string]struct{}, len(active)+len(stopped)) | |
| 93 | - | for _, candidate := range active { | |
| 94 | - | name, _ := candidate.SessionName() | |
| 95 | - | localNames[name] = struct{}{} | |
| 96 | - | } | |
| 97 | 96 | for _, candidate := range stopped { | |
| 98 | - | results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt))) | |
| 99 | - | localNames[candidate.name] = struct{}{} | |
| 97 | + | target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name} | |
| 98 | + | results = append(results, palette.NewStoppedSessionResult(target, time.Unix(0, candidate.createdAt))) | |
| 99 | + | localLifecycles[target.LifecycleID] = struct{}{} | |
| 100 | 100 | } | |
| 101 | 101 | formattedRoutes := formatRecentRouteSnapshot(routeSnapshot) | |
| 102 | 102 | for i, entry := range routeSnapshot.Entries { | |
| @@ -107,19 +107,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 107 | 107 | if label == "" { | |
| 108 | 108 | continue | |
| 109 | 109 | } | |
| 110 | - | if _, represented := localNames[entry.Name]; represented { | |
| 111 | - | sameOrigin := (entry.Kind == ports.RouteKindLocal && currentRemoteOrigin == "") || | |
| 112 | - | (entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin) | |
| 113 | - | if sameOrigin { | |
| 114 | - | continue | |
| 115 | - | } | |
| 116 | - | if entry.Kind == ports.RouteKindLocal && currentRemoteOrigin != "" { | |
| 117 | - | label = formatRecentRouteName(recentRoutePresentation{ | |
| 118 | - | name: entry.Name, | |
| 119 | - | kind: recentRouteLocal, | |
| 120 | - | ephemeral: entry.Ephemeral, | |
| 121 | - | }, true) | |
| 122 | - | } | |
| 110 | + | if _, represented := localLifecycles[entry.Target.LifecycleID]; represented { | |
| 111 | + | continue | |
| 123 | 112 | } | |
| 124 | 113 | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | |
| 125 | 114 | SnapshotGeneration: routeSnapshot.Generation, | |
| @@ -283,7 +272,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 283 | 272 | } else if action, ok := selected.RouteNavigationAction(); ok { | |
| 284 | 273 | routeTarget = action | |
| 285 | 274 | hasRouteTarget = true | |
| 286 | - | } else if _, ok := selected.SessionName(); ok { | |
| 275 | + | } else if _, ok := selected.SessionTarget(); ok { | |
| 287 | 276 | sessionTarget = selected | |
| 288 | 277 | hasSessionTarget = true | |
| 289 | 278 | } else { | |
| @@ -347,11 +336,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 347 | 336 | createdAt, _ := sessionTarget.SessionCreatedAt() | |
| 348 | 337 | expectedCreatedAt := createdAt.UnixNano() | |
| 349 | 338 | name, _ := sessionTarget.SessionName() | |
| 350 | - | target := picker.Target{Name: name, TabIndex: -1, ExpectedCreatedAt: &expectedCreatedAt} | |
| 351 | - | if id, active := sessionTarget.SessionID(); active { | |
| 352 | - | target.Session = id | |
| 353 | - | } else { | |
| 354 | - | target.Stopped = true | |
| 339 | + | exactTarget, _ := sessionTarget.SessionTarget() | |
| 340 | + | target := picker.Target{ | |
| 341 | + | Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1, | |
| 342 | + | ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession, | |
| 355 | 343 | } | |
| 356 | 344 | var err error | |
| 357 | 345 | if effect != nil { | |
| @@ -42,7 +42,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||||
| 42 | // consults its own session history for recent-route commands. | 42 | // consults its own session history for recent-route commands. | ||
| 43 | routeSnapshot := ac.routeSnapshotCopy() | 43 | routeSnapshot := ac.routeSnapshotCopy() | ||
| 44 | commands := d.paletteCommands() | 44 | commands := d.paletteCommands() | ||
| 45 | - | results := d.paletteResults(sess, commands, routeSnapshot, ac.remoteOrigin) | 45 | + | results := d.paletteResults(sess, commands, routeSnapshot) |
| 46 | ac.overlays.paletteMu.Lock() | 46 | ac.overlays.paletteMu.Lock() | ||
| 47 | ac.overlays.paletteGeneration++ | 47 | ac.overlays.paletteGeneration++ | ||
| 48 | ac.overlays.palette = palette.New(results) | 48 | ac.overlays.palette = palette.New(results) | ||
| @@ -57,7 +57,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||||
| 57 | 57 | ||||
| 58 | // paletteResults captures eligible named sessions before paletteMu so the | 58 | // paletteResults captures eligible named sessions before paletteMu so the | ||
| 59 | // palette has an immutable lifecycle target without violating lock ordering. | 59 | // palette has an immutable lifecycle target without violating lock ordering. | ||
| 60 | - | func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot, currentRemoteOrigin string) []palette.Result { | 60 | + | func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot) []palette.Result { |
| 61 | d.mu.Lock() | 61 | d.mu.Lock() | ||
| 62 | sessions := d.sessionsSnapshotLocked() | 62 | sessions := d.sessionsSnapshotLocked() | ||
| 63 | stopped := make([]stoppedSession, 0, len(d.stopped)) | 63 | stopped := make([]stoppedSession, 0, len(d.stopped)) | ||
| @@ -73,14 +73,18 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 73 | results = append(results, palette.NewCommandResult(cmd)) | 73 | results = append(results, palette.NewCommandResult(cmd)) | ||
| 74 | } | 74 | } | ||
| 75 | active := make([]palette.Result, 0, len(sessions)) | 75 | active := make([]palette.Result, 0, len(sessions)) | ||
| 76 | + | localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(sessions)+len(stopped)) | |||
| 76 | for _, candidate := range sessions { | 77 | for _, candidate := range sessions { | ||
| 77 | - | if candidate == current { | 78 | + | snap := candidate.snapshotView(viewOptions{}) |
| 79 | + | if snap.name == "" || snap.ephemeral { | |||
| 78 | continue | 80 | continue | ||
| 79 | } | 81 | } | ||
| 80 | - | snap := candidate.snapshotView(viewOptions{}) | 82 | + | localLifecycles[snap.incarnation] = struct{}{} |
| 81 | - | if snap.name != "" && !snap.ephemeral { | 83 | + | if candidate == current { |
| 82 | - | active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), snap.id)) | 84 | + | continue |
| 83 | } | 85 | } | ||
| 86 | + | target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name} | |||
| 87 | + | active = append(active, palette.NewActiveSessionResult(target, time.Unix(0, snap.createdAt))) | |||
| 84 | } | 88 | } | ||
| 85 | sort.Slice(active, func(i, j int) bool { | 89 | sort.Slice(active, func(i, j int) bool { | ||
| 86 | left, _ := active[i].SessionName() | 90 | left, _ := active[i].SessionName() | ||
| @@ -89,14 +93,10 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 89 | }) | 93 | }) | ||
| 90 | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | 94 | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | ||
| 91 | results = append(results, active...) | 95 | results = append(results, active...) | ||
| 92 | - | localNames := make(map[string]struct{}, len(active)+len(stopped)) | |||
| 93 | - | for _, candidate := range active { | |||
| 94 | - | name, _ := candidate.SessionName() | |||
| 95 | - | localNames[name] = struct{}{} | |||
| 96 | - | } | |||
| 97 | for _, candidate := range stopped { | 96 | for _, candidate := range stopped { | ||
| 98 | - | results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt))) | 97 | + | target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name} |
| 99 | - | localNames[candidate.name] = struct{}{} | 98 | + | results = append(results, palette.NewStoppedSessionResult(target, time.Unix(0, candidate.createdAt))) |
| 99 | + | localLifecycles[target.LifecycleID] = struct{}{} | |||
| 100 | } | 100 | } | ||
| 101 | formattedRoutes := formatRecentRouteSnapshot(routeSnapshot) | 101 | formattedRoutes := formatRecentRouteSnapshot(routeSnapshot) | ||
| 102 | for i, entry := range routeSnapshot.Entries { | 102 | for i, entry := range routeSnapshot.Entries { | ||
| @@ -107,19 +107,8 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 107 | if label == "" { | 107 | if label == "" { | ||
| 108 | continue | 108 | continue | ||
| 109 | } | 109 | } | ||
| 110 | - | if _, represented := localNames[entry.Name]; represented { | 110 | + | if _, represented := localLifecycles[entry.Target.LifecycleID]; represented { |
| 111 | - | sameOrigin := (entry.Kind == ports.RouteKindLocal && currentRemoteOrigin == "") || | 111 | + | continue |
| 112 | - | (entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin) | |||
| 113 | - | if sameOrigin { | |||
| 114 | - | continue | |||
| 115 | - | } | |||
| 116 | - | if entry.Kind == ports.RouteKindLocal && currentRemoteOrigin != "" { | |||
| 117 | - | label = formatRecentRouteName(recentRoutePresentation{ | |||
| 118 | - | name: entry.Name, | |||
| 119 | - | kind: recentRouteLocal, | |||
| 120 | - | ephemeral: entry.Ephemeral, | |||
| 121 | - | }, true) | |||
| 122 | - | } | |||
| 123 | } | 112 | } | ||
| 124 | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | 113 | results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{ | ||
| 125 | SnapshotGeneration: routeSnapshot.Generation, | 114 | SnapshotGeneration: routeSnapshot.Generation, | ||
| @@ -283,7 +272,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 283 | } else if action, ok := selected.RouteNavigationAction(); ok { | 272 | } else if action, ok := selected.RouteNavigationAction(); ok { | ||
| 284 | routeTarget = action | 273 | routeTarget = action | ||
| 285 | hasRouteTarget = true | 274 | hasRouteTarget = true | ||
| 286 | - | } else if _, ok := selected.SessionName(); ok { | 275 | + | } else if _, ok := selected.SessionTarget(); ok { |
| 287 | sessionTarget = selected | 276 | sessionTarget = selected | ||
| 288 | hasSessionTarget = true | 277 | hasSessionTarget = true | ||
| 289 | } else { | 278 | } else { | ||
| @@ -347,11 +336,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 347 | createdAt, _ := sessionTarget.SessionCreatedAt() | 336 | createdAt, _ := sessionTarget.SessionCreatedAt() | ||
| 348 | expectedCreatedAt := createdAt.UnixNano() | 337 | expectedCreatedAt := createdAt.UnixNano() | ||
| 349 | name, _ := sessionTarget.SessionName() | 338 | name, _ := sessionTarget.SessionName() | ||
| 350 | - | target := picker.Target{Name: name, TabIndex: -1, ExpectedCreatedAt: &expectedCreatedAt} | 339 | + | exactTarget, _ := sessionTarget.SessionTarget() |
| 351 | - | if id, active := sessionTarget.SessionID(); active { | 340 | + | target := picker.Target{ |
| 352 | - | target.Session = id | 341 | + | Incarnation: exactTarget.LifecycleID, Name: name, TabIndex: -1, |
| 353 | - | } else { | 342 | + | ExpectedCreatedAt: &expectedCreatedAt, Stopped: sessionTarget.Kind() == palette.ResultKindStoppedSession, |
| 354 | - | target.Stopped = true | |||
| 355 | } | 343 | } | ||
| 356 | var err error | 344 | var err error | ||
| 357 | if effect != nil { | 345 | if effect != nil { | ||
M internal/usecase/daemon/palette_test.go +71 -40
| @@ -14,17 +14,19 @@ import ( | |||
| 14 | 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | 15 | "github.com/bnema/vev/internal/usecase/command" | |
| 16 | 16 | "github.com/bnema/vev/internal/usecase/palette" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 17 | 18 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 18 | 19 | "github.com/bnema/vev/internal/usecase/ui" | |
| 19 | 20 | ) | |
| 20 | 21 | ||
| 21 | 22 | func testRecentRouteSnapshot() ports.RecentRouteSnapshot { | |
| 22 | 23 | return ports.RecentRouteSnapshot{ | |
| 23 | - | Generation: 1, | |
| 24 | - | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 24 | + | Generation: 1, | |
| 25 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 26 | + | ActiveEntry: testRouteEntry(1, 1, "current", 1, ports.RouteKindLocal), | |
| 25 | 27 | Entries: []ports.RecentRouteEntry{ | |
| 26 | - | {Key: 2, Generation: 1, Name: "recent", Kind: ports.RouteKindLocal}, | |
| 27 | - | {Key: 3, Generation: 1, Name: "older", Kind: ports.RouteKindLocal}, | |
| 28 | + | testRouteEntry(2, 1, "recent", 2, ports.RouteKindLocal), | |
| 29 | + | testRouteEntry(3, 1, "older", 3, ports.RouteKindLocal), | |
| 28 | 30 | }, | |
| 29 | 31 | } | |
| 30 | 32 | } | |
| @@ -419,42 +421,69 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) { | |||
| 419 | 421 | require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command") | |
| 420 | 422 | } | |
| 421 | 423 | ||
| 422 | - | func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) { | |
| 424 | + | func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { | |
| 423 | 425 | d := newTestDaemon(t, nil, stubClock{}) | |
| 424 | 426 | current := addControlSession(d, "hi", "tab-1", "pane-1") | |
| 425 | - | represented := addControlSession(d, "remote-manual", "tab-2", "pane-2") | |
| 426 | - | represented.ephemeral = false | |
| 427 | + | current.ephemeral = false | |
| 428 | + | current.incarnation = domain.SessionLifecycleID{1} | |
| 429 | + | local := addControlSession(d, "vev", "tab-2", "pane-2") | |
| 430 | + | local.ephemeral = false | |
| 431 | + | local.incarnation = domain.SessionLifecycleID{2} | |
| 432 | + | remoteOne := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"} | |
| 433 | + | remoteTwo := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"} | |
| 427 | 434 | snapshot := ports.RecentRouteSnapshot{ | |
| 428 | 435 | Generation: 2, | |
| 429 | - | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 430 | - | Entries: []ports.RecentRouteEntry{{ | |
| 431 | - | Key: 2, Generation: 1, Name: "remote-manual", HostLabel: "remote", Kind: ports.RouteKindRemote, | |
| 432 | - | }}, | |
| 436 | + | Entries: []ports.RecentRouteEntry{ | |
| 437 | + | {Key: 1, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: current.incarnation, SessionName: "hi"}, Name: "hi", Kind: ports.RouteKindLocal}, | |
| 438 | + | {Key: 2, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: ports.RouteKindLocal}, | |
| 439 | + | {Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote}, | |
| 440 | + | {Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote}, | |
| 441 | + | }, | |
| 433 | 442 | } | |
| 434 | 443 | ||
| 435 | - | for _, test := range []struct { | |
| 436 | - | name string | |
| 437 | - | currentOrigin string | |
| 438 | - | want []string | |
| 439 | - | }{ | |
| 440 | - | {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}}, | |
| 441 | - | {name: "remote daemon keeps distinct local route", currentOrigin: "remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@local"}}, | |
| 442 | - | {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}}, | |
| 443 | - | } { | |
| 444 | - | t.Run(test.name, func(t *testing.T) { | |
| 445 | - | testSnapshot := snapshot | |
| 446 | - | if test.name == "remote daemon keeps distinct local route" { | |
| 447 | - | testSnapshot.Entries = append(testSnapshot.Entries, ports.RecentRouteEntry{Key: 3, Generation: 1, Name: "remote-manual", Kind: ports.RouteKindLocal}) | |
| 448 | - | } | |
| 449 | - | results := d.paletteResults(current, nil, testSnapshot, test.currentOrigin) | |
| 450 | - | var matching []string | |
| 451 | - | for _, result := range results { | |
| 452 | - | if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@local" || result.DisplayText() == "Switch to session remote-manual@remote" { | |
| 453 | - | matching = append(matching, result.DisplayText()) | |
| 454 | - | } | |
| 455 | - | } | |
| 456 | - | require.Equal(t, test.want, matching) | |
| 457 | - | }) | |
| 444 | + | results := d.paletteResults(current, nil, snapshot) | |
| 445 | + | var sessionTargets []ports.ExactSessionTarget | |
| 446 | + | var routeActions []ports.RouteNavigationAction | |
| 447 | + | for _, result := range results { | |
| 448 | + | if target, ok := result.SessionTarget(); ok { | |
| 449 | + | sessionTargets = append(sessionTargets, target) | |
| 450 | + | } | |
| 451 | + | if action, ok := result.RouteNavigationAction(); ok { | |
| 452 | + | routeActions = append(routeActions, action) | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | require.Equal(t, []ports.ExactSessionTarget{{LifecycleID: local.incarnation, SessionName: "vev"}}, sessionTargets) | |
| 456 | + | require.Equal(t, []ports.RouteNavigationAction{ | |
| 457 | + | {SnapshotGeneration: 2, Key: 3, Generation: 1}, | |
| 458 | + | {SnapshotGeneration: 2, Key: 4, Generation: 1}, | |
| 459 | + | }, routeActions) | |
| 460 | + | require.Equal(t, "Switch to session vev@arch", results[len(results)-2].DisplayText()) | |
| 461 | + | require.Equal(t, "Switch to session vev@arch", results[len(results)-1].DisplayText()) | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | func TestPaletteLifecycleTargetRejectsSameNameReplacement(t *testing.T) { | |
| 465 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 466 | + | defer releaseAll(releases) | |
| 467 | + | target := d.sessions["recent"] | |
| 468 | + | target.incarnation = domain.SessionLifecycleID{2} | |
| 469 | + | target.createdAt = 7 | |
| 470 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 471 | + | expectedCreatedAt := target.createdAt | |
| 472 | + | ||
| 473 | + | err := d.switchToTargetForAttachment(token, picker.Target{ | |
| 474 | + | Name: "recent", Incarnation: domain.SessionLifecycleID{9}, | |
| 475 | + | ExpectedCreatedAt: &expectedCreatedAt, TabIndex: -1, | |
| 476 | + | }, sessionHandoffGuard{}, "palette-session") | |
| 477 | + | ||
| 478 | + | require.ErrorIs(t, err, errAttachmentTransition) | |
| 479 | + | require.Same(t, current, ac.currentSession()) | |
| 480 | + | for { | |
| 481 | + | select { | |
| 482 | + | case frame := <-sends: | |
| 483 | + | require.NotEqual(t, ports.MsgAttachTarget, frame.Type) | |
| 484 | + | default: | |
| 485 | + | return | |
| 486 | + | } | |
| 458 | 487 | } | |
| 459 | 488 | } | |
| 460 | 489 | ||
| @@ -462,10 +491,11 @@ func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) { | |||
| 462 | 491 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 463 | 492 | defer releaseAll(releases) | |
| 464 | 493 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 465 | - | Generation: 9, | |
| 466 | - | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 494 | + | Generation: 9, | |
| 495 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 496 | + | ActiveEntry: testRouteEntry(1, 1, current.name, 1, ports.RouteKindLocal), | |
| 467 | 497 | Entries: []ports.RecentRouteEntry{{ | |
| 468 | - | Key: 8, Generation: 4, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, | |
| 498 | + | Key: 8, Generation: 4, Target: testRouteTarget("logs", 8), Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, | |
| 469 | 499 | }}, | |
| 470 | 500 | }) | |
| 471 | 501 | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| @@ -541,9 +571,10 @@ func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) { | |||
| 541 | 571 | defer release() | |
| 542 | 572 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 543 | 573 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 544 | - | Generation: 7, | |
| 545 | - | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 546 | - | Entries: []ports.RecentRouteEntry{{Key: 9, Generation: 4, Name: "captured", Kind: ports.RouteKindLocal}}, | |
| 574 | + | Generation: 7, | |
| 575 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 576 | + | ActiveEntry: testRouteEntry(1, 1, sess.name, 1, ports.RouteKindLocal), | |
| 577 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(9, 4, "captured", 9, ports.RouteKindLocal)}, | |
| 547 | 578 | }) | |
| 548 | 579 | ||
| 549 | 580 | validated := make(chan struct{}) | |
| @@ -14,17 +14,19 @@ import ( | |||||
| 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 15 | "github.com/bnema/vev/internal/usecase/command" | 15 | "github.com/bnema/vev/internal/usecase/command" | ||
| 16 | "github.com/bnema/vev/internal/usecase/palette" | 16 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 17 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 17 | themeui "github.com/bnema/vev/internal/usecase/theme" | 18 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 18 | "github.com/bnema/vev/internal/usecase/ui" | 19 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 19 | ) | 20 | ) | ||
| 20 | 21 | ||||
| 21 | func testRecentRouteSnapshot() ports.RecentRouteSnapshot { | 22 | func testRecentRouteSnapshot() ports.RecentRouteSnapshot { | ||
| 22 | return ports.RecentRouteSnapshot{ | 23 | return ports.RecentRouteSnapshot{ | ||
| 23 | - | Generation: 1, | 24 | + | Generation: 1, |
| 24 | - | Active: ports.RouteRef{Key: 1, Generation: 1}, | 25 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, |
| 26 | + | ActiveEntry: testRouteEntry(1, 1, "current", 1, ports.RouteKindLocal), | |||
| 25 | Entries: []ports.RecentRouteEntry{ | 27 | Entries: []ports.RecentRouteEntry{ | ||
| 26 | - | {Key: 2, Generation: 1, Name: "recent", Kind: ports.RouteKindLocal}, | 28 | + | testRouteEntry(2, 1, "recent", 2, ports.RouteKindLocal), |
| 27 | - | {Key: 3, Generation: 1, Name: "older", Kind: ports.RouteKindLocal}, | 29 | + | testRouteEntry(3, 1, "older", 3, ports.RouteKindLocal), |
| 28 | }, | 30 | }, | ||
| 29 | } | 31 | } | ||
| 30 | } | 32 | } | ||
| @@ -419,42 +421,69 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) { | |||||
| 419 | require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command") | 421 | require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command") | ||
| 420 | } | 422 | } | ||
| 421 | 423 | ||||
| 422 | - | func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) { | 424 | + | func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { |
| 423 | d := newTestDaemon(t, nil, stubClock{}) | 425 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 424 | current := addControlSession(d, "hi", "tab-1", "pane-1") | 426 | current := addControlSession(d, "hi", "tab-1", "pane-1") | ||
| 425 | - | represented := addControlSession(d, "remote-manual", "tab-2", "pane-2") | 427 | + | current.ephemeral = false |
| 426 | - | represented.ephemeral = false | 428 | + | current.incarnation = domain.SessionLifecycleID{1} |
| 429 | + | local := addControlSession(d, "vev", "tab-2", "pane-2") | |||
| 430 | + | local.ephemeral = false | |||
| 431 | + | local.incarnation = domain.SessionLifecycleID{2} | |||
| 432 | + | remoteOne := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"} | |||
| 433 | + | remoteTwo := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"} | |||
| 427 | snapshot := ports.RecentRouteSnapshot{ | 434 | snapshot := ports.RecentRouteSnapshot{ | ||
| 428 | Generation: 2, | 435 | Generation: 2, | ||
| 429 | - | Active: ports.RouteRef{Key: 1, Generation: 1}, | 436 | + | Entries: []ports.RecentRouteEntry{ |
| 430 | - | Entries: []ports.RecentRouteEntry{{ | 437 | + | {Key: 1, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: current.incarnation, SessionName: "hi"}, Name: "hi", Kind: ports.RouteKindLocal}, |
| 431 | - | Key: 2, Generation: 1, Name: "remote-manual", HostLabel: "remote", Kind: ports.RouteKindRemote, | 438 | + | {Key: 2, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: ports.RouteKindLocal}, |
| 432 | - | }}, | 439 | + | {Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote}, |
| 440 | + | {Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote}, | |||
| 441 | + | }, | |||
| 433 | } | 442 | } | ||
| 434 | 443 | ||||
| 435 | - | for _, test := range []struct { | 444 | + | results := d.paletteResults(current, nil, snapshot) |
| 436 | - | name string | 445 | + | var sessionTargets []ports.ExactSessionTarget |
| 437 | - | currentOrigin string | 446 | + | var routeActions []ports.RouteNavigationAction |
| 438 | - | want []string | 447 | + | for _, result := range results { |
| 439 | - | }{ | 448 | + | if target, ok := result.SessionTarget(); ok { |
| 440 | - | {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}}, | 449 | + | sessionTargets = append(sessionTargets, target) |
| 441 | - | {name: "remote daemon keeps distinct local route", currentOrigin: "remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@local"}}, | 450 | + | } |
| 442 | - | {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}}, | 451 | + | if action, ok := result.RouteNavigationAction(); ok { |
| 443 | - | } { | 452 | + | routeActions = append(routeActions, action) |
| 444 | - | t.Run(test.name, func(t *testing.T) { | 453 | + | } |
| 445 | - | testSnapshot := snapshot | 454 | + | } |
| 446 | - | if test.name == "remote daemon keeps distinct local route" { | 455 | + | require.Equal(t, []ports.ExactSessionTarget{{LifecycleID: local.incarnation, SessionName: "vev"}}, sessionTargets) |
| 447 | - | testSnapshot.Entries = append(testSnapshot.Entries, ports.RecentRouteEntry{Key: 3, Generation: 1, Name: "remote-manual", Kind: ports.RouteKindLocal}) | 456 | + | require.Equal(t, []ports.RouteNavigationAction{ |
| 448 | - | } | 457 | + | {SnapshotGeneration: 2, Key: 3, Generation: 1}, |
| 449 | - | results := d.paletteResults(current, nil, testSnapshot, test.currentOrigin) | 458 | + | {SnapshotGeneration: 2, Key: 4, Generation: 1}, |
| 450 | - | var matching []string | 459 | + | }, routeActions) |
| 451 | - | for _, result := range results { | 460 | + | require.Equal(t, "Switch to session vev@arch", results[len(results)-2].DisplayText()) |
| 452 | - | if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@local" || result.DisplayText() == "Switch to session remote-manual@remote" { | 461 | + | require.Equal(t, "Switch to session vev@arch", results[len(results)-1].DisplayText()) |
| 453 | - | matching = append(matching, result.DisplayText()) | 462 | + | } |
| 454 | - | } | 463 | + | |
| 455 | - | } | 464 | + | func TestPaletteLifecycleTargetRejectsSameNameReplacement(t *testing.T) { |
| 456 | - | require.Equal(t, test.want, matching) | 465 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) |
| 457 | - | }) | 466 | + | defer releaseAll(releases) |
| 467 | + | target := d.sessions["recent"] | |||
| 468 | + | target.incarnation = domain.SessionLifecycleID{2} | |||
| 469 | + | target.createdAt = 7 | |||
| 470 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |||
| 471 | + | expectedCreatedAt := target.createdAt | |||
| 472 | + | ||||
| 473 | + | err := d.switchToTargetForAttachment(token, picker.Target{ | |||
| 474 | + | Name: "recent", Incarnation: domain.SessionLifecycleID{9}, | |||
| 475 | + | ExpectedCreatedAt: &expectedCreatedAt, TabIndex: -1, | |||
| 476 | + | }, sessionHandoffGuard{}, "palette-session") | |||
| 477 | + | ||||
| 478 | + | require.ErrorIs(t, err, errAttachmentTransition) | |||
| 479 | + | require.Same(t, current, ac.currentSession()) | |||
| 480 | + | for { | |||
| 481 | + | select { | |||
| 482 | + | case frame := <-sends: | |||
| 483 | + | require.NotEqual(t, ports.MsgAttachTarget, frame.Type) | |||
| 484 | + | default: | |||
| 485 | + | return | |||
| 486 | + | } | |||
| 458 | } | 487 | } | ||
| 459 | } | 488 | } | ||
| 460 | 489 | ||||
| @@ -462,10 +491,11 @@ func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) { | |||||
| 462 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | 491 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | ||
| 463 | defer releaseAll(releases) | 492 | defer releaseAll(releases) | ||
| 464 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | 493 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | ||
| 465 | - | Generation: 9, | 494 | + | Generation: 9, |
| 466 | - | Active: ports.RouteRef{Key: 1, Generation: 1}, | 495 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, |
| 496 | + | ActiveEntry: testRouteEntry(1, 1, current.name, 1, ports.RouteKindLocal), | |||
| 467 | Entries: []ports.RecentRouteEntry{{ | 497 | Entries: []ports.RecentRouteEntry{{ | ||
| 468 | - | Key: 8, Generation: 4, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, | 498 | + | Key: 8, Generation: 4, Target: testRouteTarget("logs", 8), Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, |
| 469 | }}, | 499 | }}, | ||
| 470 | }) | 500 | }) | ||
| 471 | token := beginRecentRoutePaletteEffect(t, d, current, ac) | 501 | token := beginRecentRoutePaletteEffect(t, d, current, ac) | ||
| @@ -541,9 +571,10 @@ func TestPaletteJRSDoesNotRevalidateTargetInDaemon(t *testing.T) { | |||||
| 541 | defer release() | 571 | defer release() | ||
| 542 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 572 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 543 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | 573 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | ||
| 544 | - | Generation: 7, | 574 | + | Generation: 7, |
| 545 | - | Active: ports.RouteRef{Key: 1, Generation: 1}, | 575 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, |
| 546 | - | Entries: []ports.RecentRouteEntry{{Key: 9, Generation: 4, Name: "captured", Kind: ports.RouteKindLocal}}, | 576 | + | ActiveEntry: testRouteEntry(1, 1, sess.name, 1, ports.RouteKindLocal), |
| 577 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(9, 4, "captured", 9, ports.RouteKindLocal)}, | |||
| 547 | }) | 578 | }) | ||
| 548 | 579 | ||||
| 549 | validated := make(chan struct{}) | 580 | validated := make(chan struct{}) | ||
M internal/usecase/daemon/picker.go +34 -21
| @@ -238,7 +238,7 @@ func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Targ | |||
| 238 | 238 | } | |
| 239 | 239 | message := "Remote session unavailable" | |
| 240 | 240 | if target.RemoteTarget != nil { | |
| 241 | - | message += ": " + target.RemoteTarget.SessionName + "@" + target.RemoteTarget.DisplayOrigin | |
| 241 | + | message += ": " + domain.RemoteSessionDisplay(target.RemoteTarget.SessionName, target.RemoteTarget.DisplayOrigin) | |
| 242 | 242 | } else if target.RemoteHost != "" { | |
| 243 | 243 | message += ": " + target.RemoteHost | |
| 244 | 244 | } | |
| @@ -1169,12 +1169,12 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi | |||
| 1169 | 1169 | } | |
| 1170 | 1170 | if active := d.findByNameLocked(target.Name); active != nil { | |
| 1171 | 1171 | active.mu.Lock() | |
| 1172 | - | matches := targetMatchesLifecycle(target, active.name, active.createdAt) | |
| 1172 | + | matches := targetMatchesLifecycle(target, active.name, active.createdAt, active.incarnation) | |
| 1173 | 1173 | active.mu.Unlock() | |
| 1174 | 1174 | return active, stoppedSession{}, false, matches | |
| 1175 | 1175 | } | |
| 1176 | 1176 | stopped, ok := d.stopped[target.Name] | |
| 1177 | - | if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |
| 1177 | + | if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) { | |
| 1178 | 1178 | return nil, stoppedSession{}, false, false | |
| 1179 | 1179 | } | |
| 1180 | 1180 | return nil, stopped, true, true | |
| @@ -1191,7 +1191,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 1191 | 1191 | targetSess.mu.Lock() | |
| 1192 | 1192 | defer targetSess.mu.Unlock() | |
| 1193 | 1193 | _, attached := targetSess.attachments[ac] | |
| 1194 | - | if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { | |
| 1194 | + | if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { | |
| 1195 | 1195 | return nil, attachmentTransitionResult{}, false | |
| 1196 | 1196 | } | |
| 1197 | 1197 | if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) { | |
| @@ -1203,8 +1203,13 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 1203 | 1203 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1204 | 1204 | source: from, target: targetSess, next: ac, | |
| 1205 | 1205 | ||
| 1206 | - | expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action, | |
| 1207 | - | activateTargetTab: true, targetTabIndex: target.TabIndex, preserveAttachment: true, | |
| 1206 | + | expectedTransport: sourceToken.transport, | |
| 1207 | + | sourceToken: sourceToken, | |
| 1208 | + | action: action, | |
| 1209 | + | expectedTargetLifecycle: pickerTargetLifecycleFence(target), | |
| 1210 | + | activateTargetTab: true, | |
| 1211 | + | targetTabIndex: target.TabIndex, | |
| 1212 | + | preserveAttachment: true, | |
| 1208 | 1213 | }) | |
| 1209 | 1214 | d.mu.Lock() | |
| 1210 | 1215 | return targetSess, transition, err == nil | |
| @@ -1212,7 +1217,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 1212 | 1217 | ||
| 1213 | 1218 | unlock := lockAttachmentSessions(from, targetSess) | |
| 1214 | 1219 | _, sourceAttached := from.attachments[ac] | |
| 1215 | - | if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) { | |
| 1220 | + | if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) { | |
| 1216 | 1221 | unlock() | |
| 1217 | 1222 | return nil, attachmentTransitionResult{}, false | |
| 1218 | 1223 | } | |
| @@ -1244,14 +1249,15 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 1244 | 1249 | target: targetSess, | |
| 1245 | 1250 | next: ac, | |
| 1246 | 1251 | ||
| 1247 | - | expectedTransport: expectedTransport, | |
| 1248 | - | sourceToken: sourceToken, | |
| 1249 | - | action: action, | |
| 1250 | - | expectedSourceTab: guard.expectedSource, | |
| 1251 | - | activateTargetTab: target.TabIndex >= 0, | |
| 1252 | - | targetTabIndex: target.TabIndex, | |
| 1253 | - | copySourceEnvironment: true, | |
| 1254 | - | ready: true, | |
| 1252 | + | expectedTransport: expectedTransport, | |
| 1253 | + | sourceToken: sourceToken, | |
| 1254 | + | action: action, | |
| 1255 | + | expectedTargetLifecycle: pickerTargetLifecycleFence(target), | |
| 1256 | + | expectedSourceTab: guard.expectedSource, | |
| 1257 | + | activateTargetTab: target.TabIndex >= 0, | |
| 1258 | + | targetTabIndex: target.TabIndex, | |
| 1259 | + | copySourceEnvironment: true, | |
| 1260 | + | ready: true, | |
| 1255 | 1261 | }) | |
| 1256 | 1262 | d.mu.Lock() | |
| 1257 | 1263 | if err != nil { | |
| @@ -1278,13 +1284,13 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 1278 | 1284 | expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true, | |
| 1279 | 1285 | createTargetLocked: func() (*session, error) { | |
| 1280 | 1286 | current, ok := d.stopped[target.Name] | |
| 1281 | - | if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) { | |
| 1287 | + | if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) { | |
| 1282 | 1288 | return nil, errAttachmentTransition | |
| 1283 | 1289 | } | |
| 1284 | 1290 | from.mu.Lock() | |
| 1285 | 1291 | cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env) | |
| 1286 | 1292 | from.mu.Unlock() | |
| 1287 | - | created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), env, current.tabNames) | |
| 1293 | + | created, createErr := d.createStoppedSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames) | |
| 1288 | 1294 | targetSess = created | |
| 1289 | 1295 | return created, createErr | |
| 1290 | 1296 | }, | |
| @@ -1310,8 +1316,12 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 1310 | 1316 | } | |
| 1311 | 1317 | env := copyEnvironment(from.env) | |
| 1312 | 1318 | from.mu.Unlock() | |
| 1313 | - | cwd := d.dirOrHome(stopped.cwd) | |
| 1314 | - | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), env, stopped.tabNames) | |
| 1319 | + | current, ok := d.stopped[target.Name] | |
| 1320 | + | if !ok || current.purging || !stoppedSessionMatchesLifecycle(current, stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) { | |
| 1321 | + | return nil, attachmentTransitionResult{}, false, errAttachmentTransition | |
| 1322 | + | } | |
| 1323 | + | cwd := d.dirOrHome(current.cwd) | |
| 1324 | + | targetSess, err := d.createStoppedSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames) | |
| 1315 | 1325 | if err != nil { | |
| 1316 | 1326 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | |
| 1317 | 1327 | return nil, attachmentTransitionResult{}, false, err | |
| @@ -1331,7 +1341,10 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 1331 | 1341 | return targetSess, transition, true, nil | |
| 1332 | 1342 | } | |
| 1333 | 1343 | ||
| 1334 | - | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | |
| 1344 | + | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64, incarnation domain.IncarnationID) bool { | |
| 1345 | + | if target.Incarnation != (domain.IncarnationID{}) && target.Incarnation != incarnation { | |
| 1346 | + | return false | |
| 1347 | + | } | |
| 1335 | 1348 | return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt) | |
| 1336 | 1349 | } | |
| 1337 | 1350 | ||
| @@ -1356,7 +1369,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 1356 | 1369 | transition attachmentTransitionResult | |
| 1357 | 1370 | switched bool | |
| 1358 | 1371 | ) | |
| 1359 | - | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |
| 1372 | + | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) { | |
| 1360 | 1373 | _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "") | |
| 1361 | 1374 | } | |
| 1362 | 1375 | d.mu.Unlock() | |
| @@ -238,7 +238,7 @@ func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Targ | |||||
| 238 | } | 238 | } | ||
| 239 | message := "Remote session unavailable" | 239 | message := "Remote session unavailable" | ||
| 240 | if target.RemoteTarget != nil { | 240 | if target.RemoteTarget != nil { | ||
| 241 | - | message += ": " + target.RemoteTarget.SessionName + "@" + target.RemoteTarget.DisplayOrigin | 241 | + | message += ": " + domain.RemoteSessionDisplay(target.RemoteTarget.SessionName, target.RemoteTarget.DisplayOrigin) |
| 242 | } else if target.RemoteHost != "" { | 242 | } else if target.RemoteHost != "" { | ||
| 243 | message += ": " + target.RemoteHost | 243 | message += ": " + target.RemoteHost | ||
| 244 | } | 244 | } | ||
| @@ -1169,12 +1169,12 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi | |||||
| 1169 | } | 1169 | } | ||
| 1170 | if active := d.findByNameLocked(target.Name); active != nil { | 1170 | if active := d.findByNameLocked(target.Name); active != nil { | ||
| 1171 | active.mu.Lock() | 1171 | active.mu.Lock() | ||
| 1172 | - | matches := targetMatchesLifecycle(target, active.name, active.createdAt) | 1172 | + | matches := targetMatchesLifecycle(target, active.name, active.createdAt, active.incarnation) |
| 1173 | active.mu.Unlock() | 1173 | active.mu.Unlock() | ||
| 1174 | return active, stoppedSession{}, false, matches | 1174 | return active, stoppedSession{}, false, matches | ||
| 1175 | } | 1175 | } | ||
| 1176 | stopped, ok := d.stopped[target.Name] | 1176 | stopped, ok := d.stopped[target.Name] | ||
| 1177 | - | if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | 1177 | + | if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) { |
| 1178 | return nil, stoppedSession{}, false, false | 1178 | return nil, stoppedSession{}, false, false | ||
| 1179 | } | 1179 | } | ||
| 1180 | return nil, stopped, true, true | 1180 | return nil, stopped, true, true | ||
| @@ -1191,7 +1191,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||||
| 1191 | targetSess.mu.Lock() | 1191 | targetSess.mu.Lock() | ||
| 1192 | defer targetSess.mu.Unlock() | 1192 | defer targetSess.mu.Unlock() | ||
| 1193 | _, attached := targetSess.attachments[ac] | 1193 | _, attached := targetSess.attachments[ac] | ||
| 1194 | - | if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { | 1194 | + | if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { |
| 1195 | return nil, attachmentTransitionResult{}, false | 1195 | return nil, attachmentTransitionResult{}, false | ||
| 1196 | } | 1196 | } | ||
| 1197 | if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) { | 1197 | if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) { | ||
| @@ -1203,8 +1203,13 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||||
| 1203 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | 1203 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | ||
| 1204 | source: from, target: targetSess, next: ac, | 1204 | source: from, target: targetSess, next: ac, | ||
| 1205 | 1205 | ||||
| 1206 | - | expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action, | 1206 | + | expectedTransport: sourceToken.transport, |
| 1207 | - | activateTargetTab: true, targetTabIndex: target.TabIndex, preserveAttachment: true, | 1207 | + | sourceToken: sourceToken, |
| 1208 | + | action: action, | |||
| 1209 | + | expectedTargetLifecycle: pickerTargetLifecycleFence(target), | |||
| 1210 | + | activateTargetTab: true, | |||
| 1211 | + | targetTabIndex: target.TabIndex, | |||
| 1212 | + | preserveAttachment: true, | |||
| 1208 | }) | 1213 | }) | ||
| 1209 | d.mu.Lock() | 1214 | d.mu.Lock() | ||
| 1210 | return targetSess, transition, err == nil | 1215 | return targetSess, transition, err == nil | ||
| @@ -1212,7 +1217,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||||
| 1212 | 1217 | ||||
| 1213 | unlock := lockAttachmentSessions(from, targetSess) | 1218 | unlock := lockAttachmentSessions(from, targetSess) | ||
| 1214 | _, sourceAttached := from.attachments[ac] | 1219 | _, sourceAttached := from.attachments[ac] | ||
| 1215 | - | if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) { | 1220 | + | if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) { |
| 1216 | unlock() | 1221 | unlock() | ||
| 1217 | return nil, attachmentTransitionResult{}, false | 1222 | return nil, attachmentTransitionResult{}, false | ||
| 1218 | } | 1223 | } | ||
| @@ -1244,14 +1249,15 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||||
| 1244 | target: targetSess, | 1249 | target: targetSess, | ||
| 1245 | next: ac, | 1250 | next: ac, | ||
| 1246 | 1251 | ||||
| 1247 | - | expectedTransport: expectedTransport, | 1252 | + | expectedTransport: expectedTransport, |
| 1248 | - | sourceToken: sourceToken, | 1253 | + | sourceToken: sourceToken, |
| 1249 | - | action: action, | 1254 | + | action: action, |
| 1250 | - | expectedSourceTab: guard.expectedSource, | 1255 | + | expectedTargetLifecycle: pickerTargetLifecycleFence(target), |
| 1251 | - | activateTargetTab: target.TabIndex >= 0, | 1256 | + | expectedSourceTab: guard.expectedSource, |
| 1252 | - | targetTabIndex: target.TabIndex, | 1257 | + | activateTargetTab: target.TabIndex >= 0, |
| 1253 | - | copySourceEnvironment: true, | 1258 | + | targetTabIndex: target.TabIndex, |
| 1254 | - | ready: true, | 1259 | + | copySourceEnvironment: true, |
| 1260 | + | ready: true, | |||
| 1255 | }) | 1261 | }) | ||
| 1256 | d.mu.Lock() | 1262 | d.mu.Lock() | ||
| 1257 | if err != nil { | 1263 | if err != nil { | ||
| @@ -1278,13 +1284,13 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||||
| 1278 | expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true, | 1284 | expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true, | ||
| 1279 | createTargetLocked: func() (*session, error) { | 1285 | createTargetLocked: func() (*session, error) { | ||
| 1280 | current, ok := d.stopped[target.Name] | 1286 | current, ok := d.stopped[target.Name] | ||
| 1281 | - | if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) { | 1287 | + | if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) { |
| 1282 | return nil, errAttachmentTransition | 1288 | return nil, errAttachmentTransition | ||
| 1283 | } | 1289 | } | ||
| 1284 | from.mu.Lock() | 1290 | from.mu.Lock() | ||
| 1285 | cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env) | 1291 | cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env) | ||
| 1286 | from.mu.Unlock() | 1292 | from.mu.Unlock() | ||
| 1287 | - | created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), env, current.tabNames) | 1293 | + | created, createErr := d.createStoppedSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames) |
| 1288 | targetSess = created | 1294 | targetSess = created | ||
| 1289 | return created, createErr | 1295 | return created, createErr | ||
| 1290 | }, | 1296 | }, | ||
| @@ -1310,8 +1316,12 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||||
| 1310 | } | 1316 | } | ||
| 1311 | env := copyEnvironment(from.env) | 1317 | env := copyEnvironment(from.env) | ||
| 1312 | from.mu.Unlock() | 1318 | from.mu.Unlock() | ||
| 1313 | - | cwd := d.dirOrHome(stopped.cwd) | 1319 | + | current, ok := d.stopped[target.Name] |
| 1314 | - | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), env, stopped.tabNames) | 1320 | + | if !ok || current.purging || !stoppedSessionMatchesLifecycle(current, stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) { |
| 1321 | + | return nil, attachmentTransitionResult{}, false, errAttachmentTransition | |||
| 1322 | + | } | |||
| 1323 | + | cwd := d.dirOrHome(current.cwd) | |||
| 1324 | + | targetSess, err := d.createStoppedSessionLocked(target.Name, cwd, ac.sizeSnapshot(), env, current, current.tabNames) | |||
| 1315 | if err != nil { | 1325 | if err != nil { | ||
| 1316 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | 1326 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | ||
| 1317 | return nil, attachmentTransitionResult{}, false, err | 1327 | return nil, attachmentTransitionResult{}, false, err | ||
| @@ -1331,7 +1341,10 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||||
| 1331 | return targetSess, transition, true, nil | 1341 | return targetSess, transition, true, nil | ||
| 1332 | } | 1342 | } | ||
| 1333 | 1343 | ||||
| 1334 | - | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | 1344 | + | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64, incarnation domain.IncarnationID) bool { |
| 1345 | + | if target.Incarnation != (domain.IncarnationID{}) && target.Incarnation != incarnation { | |||
| 1346 | + | return false | |||
| 1347 | + | } | |||
| 1335 | return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt) | 1348 | return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt) | ||
| 1336 | } | 1349 | } | ||
| 1337 | 1350 | ||||
| @@ -1356,7 +1369,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 1356 | transition attachmentTransitionResult | 1369 | transition attachmentTransitionResult | ||
| 1357 | switched bool | 1370 | switched bool | ||
| 1358 | ) | 1371 | ) | ||
| 1359 | - | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | 1372 | + | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) { |
| 1360 | _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "") | 1373 | _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "") | ||
| 1361 | } | 1374 | } | ||
| 1362 | d.mu.Unlock() | 1375 | d.mu.Unlock() | ||
M internal/usecase/daemon/recent_route_presentation.go +3 -1
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |
| 4 | + | ||
| 3 | 5 | // recentRouteKind identifies the origin represented by a presentation value. | |
| 4 | 6 | // It is intentionally daemon-local in this layer; ports route kinds are | |
| 5 | 7 | // projected into this render-only value at the daemon boundary. | |
| @@ -72,5 +74,5 @@ func formatRecentRouteName(entry recentRoutePresentation, ambiguous bool) string | |||
| 72 | 74 | host = "local" | |
| 73 | 75 | } | |
| 74 | 76 | } | |
| 75 | - | return name + "@" + host | |
| 77 | + | return domain.RemoteSessionDisplay(name, host) | |
| 76 | 78 | } | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |||
| 4 | + | ||||
| 3 | // recentRouteKind identifies the origin represented by a presentation value. | 5 | // recentRouteKind identifies the origin represented by a presentation value. | ||
| 4 | // It is intentionally daemon-local in this layer; ports route kinds are | 6 | // It is intentionally daemon-local in this layer; ports route kinds are | ||
| 5 | // projected into this render-only value at the daemon boundary. | 7 | // projected into this render-only value at the daemon boundary. | ||
| @@ -72,5 +74,5 @@ func formatRecentRouteName(entry recentRoutePresentation, ambiguous bool) string | |||||
| 72 | host = "local" | 74 | host = "local" | ||
| 73 | } | 75 | } | ||
| 74 | } | 76 | } | ||
| 75 | - | return name + "@" + host | 77 | + | return domain.RemoteSessionDisplay(name, host) |
| 76 | } | 78 | } | ||
M internal/usecase/daemon/recent_route_presentation_test.go +2 -2
| @@ -79,8 +79,8 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) | |||
| 79 | 79 | snapshot := ports.RecentRouteSnapshot{ | |
| 80 | 80 | Generation: 1, | |
| 81 | 81 | Entries: []ports.RecentRouteEntry{ | |
| 82 | - | {Key: 1, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal, Attention: true}, | |
| 83 | - | {Key: 2, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal}, | |
| 82 | + | {Key: 1, Generation: 1, Target: testRouteTarget("logs", 1), Name: "logs", Kind: ports.RouteKindLocal, Attention: true}, | |
| 83 | + | testRouteEntry(2, 1, "logs", 2, ports.RouteKindLocal), | |
| 84 | 84 | }, | |
| 85 | 85 | } | |
| 86 | 86 | ||
| @@ -79,8 +79,8 @@ func TestRecentSessionHintsAndRankedRenderingShareCanonicalLabels(t *testing.T) | |||||
| 79 | snapshot := ports.RecentRouteSnapshot{ | 79 | snapshot := ports.RecentRouteSnapshot{ | ||
| 80 | Generation: 1, | 80 | Generation: 1, | ||
| 81 | Entries: []ports.RecentRouteEntry{ | 81 | Entries: []ports.RecentRouteEntry{ | ||
| 82 | - | {Key: 1, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal, Attention: true}, | 82 | + | {Key: 1, Generation: 1, Target: testRouteTarget("logs", 1), Name: "logs", Kind: ports.RouteKindLocal, Attention: true}, |
| 83 | - | {Key: 2, Generation: 1, Name: "logs", Kind: ports.RouteKindLocal}, | 83 | + | testRouteEntry(2, 1, "logs", 2, ports.RouteKindLocal), |
| 84 | }, | 84 | }, | ||
| 85 | } | 85 | } | ||
| 86 | 86 | ||||
M internal/usecase/daemon/recent_route_snapshot.go +5 -2
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/internal/ports" | |
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/ports" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation { | |
| 6 | 9 | if snapshot.Entries == nil { | |
| @@ -14,7 +17,7 @@ func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) [] | |||
| 14 | 17 | } | |
| 15 | 18 | out[i] = recentRoutePresentation{ | |
| 16 | 19 | name: entry.Name, | |
| 17 | - | hostLabel: entry.HostLabel, | |
| 20 | + | hostLabel: domain.RemoteDisplayOrigin(entry.HostLabel), | |
| 18 | 21 | kind: kind, | |
| 19 | 22 | ephemeral: entry.Ephemeral, | |
| 20 | 23 | attention: entry.Attention, | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/vev/internal/ports" | 3 | + | import ( |
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/ports" | |||
| 6 | + | ) | |||
| 4 | 7 | ||||
| 5 | func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation { | 8 | func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) []recentRoutePresentation { | ||
| 6 | if snapshot.Entries == nil { | 9 | if snapshot.Entries == nil { | ||
| @@ -14,7 +17,7 @@ func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) [] | |||||
| 14 | } | 17 | } | ||
| 15 | out[i] = recentRoutePresentation{ | 18 | out[i] = recentRoutePresentation{ | ||
| 16 | name: entry.Name, | 19 | name: entry.Name, | ||
| 17 | - | hostLabel: entry.HostLabel, | 20 | + | hostLabel: domain.RemoteDisplayOrigin(entry.HostLabel), |
| 18 | kind: kind, | 21 | kind: kind, | ||
| 19 | ephemeral: entry.Ephemeral, | 22 | ephemeral: entry.Ephemeral, | ||
| 20 | attention: entry.Attention, | 23 | attention: entry.Attention, | ||
M internal/usecase/daemon/recent_route_snapshot_test.go +6 -6
| @@ -14,8 +14,8 @@ func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) { | |||
| 14 | 14 | Generation: 2, | |
| 15 | 15 | Active: ports.RouteRef{Key: 3, Generation: 2}, | |
| 16 | 16 | Entries: []ports.RecentRouteEntry{ | |
| 17 | - | {Key: 2, Generation: 1, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote}, | |
| 18 | - | {Key: 1, Generation: 1, Name: "work", Kind: ports.RouteKindLocal}, | |
| 17 | + | {Key: 2, Generation: 1, Target: testRouteTarget("logs", 2), Name: "logs", HostLabel: "user@edge", Kind: ports.RouteKindRemote}, | |
| 18 | + | testRouteEntry(1, 1, "work", 1, ports.RouteKindLocal), | |
| 19 | 19 | }, | |
| 20 | 20 | }) | |
| 21 | 21 | ||
| @@ -44,7 +44,7 @@ func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) { | |||
| 44 | 44 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 45 | 45 | Generation: 2, | |
| 46 | 46 | Active: ports.RouteRef{Key: 3, Generation: 2}, | |
| 47 | - | Entries: []ports.RecentRouteEntry{{Key: ref.Key, Generation: ref.Generation, Name: sess.name, Kind: ports.RouteKindLocal}}, | |
| 47 | + | Entries: []ports.RecentRouteEntry{{Key: ref.Key, Generation: ref.Generation, Target: ports.ExactSessionTarget{LifecycleID: tt.targetID, SessionName: sess.name}, Name: sess.name, Kind: ports.RouteKindLocal}}, | |
| 48 | 48 | }) | |
| 49 | 49 | ac.setRouteAttentionSubscription(ports.RouteAttentionSubscription{Targets: []ports.RouteAttentionTarget{{ | |
| 50 | 50 | Ref: ref, | |
| @@ -77,7 +77,7 @@ func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) { | |||
| 77 | 77 | ||
| 78 | 78 | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | |
| 79 | 79 | _, _, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 80 | - | entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}} | |
| 80 | + | entries := []ports.RecentRouteEntry{testRouteEntry(1, 2, "before", 1, ports.RouteKindLocal)} | |
| 81 | 81 | snapshot := ports.RecentRouteSnapshot{Generation: 3, Entries: entries} | |
| 82 | 82 | ac.setRouteSnapshot(snapshot) | |
| 83 | 83 | ||
| @@ -105,7 +105,7 @@ func TestPaletteRecentRouteSelectionSendsTypedClientAction(t *testing.T) { | |||
| 105 | 105 | d: d, sess: source, attachment: source, ac: ac, | |
| 106 | 106 | routeSnapshot: ports.RecentRouteSnapshot{ | |
| 107 | 107 | Generation: 4, | |
| 108 | - | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 3, Name: "remote", Kind: ports.RouteKindRemote}}, | |
| 108 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(2, 3, "remote", 2, ports.RouteKindRemote)}, | |
| 109 | 109 | }, | |
| 110 | 110 | effect: effect, | |
| 111 | 111 | } | |
| @@ -131,7 +131,7 @@ func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) { | |||
| 131 | 131 | snapshot := ports.RecentRouteSnapshot{ | |
| 132 | 132 | Generation: 8, | |
| 133 | 133 | Entries: []ports.RecentRouteEntry{{ | |
| 134 | - | Key: 11, Generation: 7, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, | |
| 134 | + | Key: 11, Generation: 7, Target: testRouteTarget("logs", 11), Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, | |
| 135 | 135 | }}, | |
| 136 | 136 | } | |
| 137 | 137 | ||
| @@ -14,8 +14,8 @@ func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) { | |||||
| 14 | Generation: 2, | 14 | Generation: 2, | ||
| 15 | Active: ports.RouteRef{Key: 3, Generation: 2}, | 15 | Active: ports.RouteRef{Key: 3, Generation: 2}, | ||
| 16 | Entries: []ports.RecentRouteEntry{ | 16 | Entries: []ports.RecentRouteEntry{ | ||
| 17 | - | {Key: 2, Generation: 1, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote}, | 17 | + | {Key: 2, Generation: 1, Target: testRouteTarget("logs", 2), Name: "logs", HostLabel: "user@edge", Kind: ports.RouteKindRemote}, |
| 18 | - | {Key: 1, Generation: 1, Name: "work", Kind: ports.RouteKindLocal}, | 18 | + | testRouteEntry(1, 1, "work", 1, ports.RouteKindLocal), |
| 19 | }, | 19 | }, | ||
| 20 | }) | 20 | }) | ||
| 21 | 21 | ||||
| @@ -44,7 +44,7 @@ func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) { | |||||
| 44 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | 44 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | ||
| 45 | Generation: 2, | 45 | Generation: 2, | ||
| 46 | Active: ports.RouteRef{Key: 3, Generation: 2}, | 46 | Active: ports.RouteRef{Key: 3, Generation: 2}, | ||
| 47 | - | Entries: []ports.RecentRouteEntry{{Key: ref.Key, Generation: ref.Generation, Name: sess.name, Kind: ports.RouteKindLocal}}, | 47 | + | Entries: []ports.RecentRouteEntry{{Key: ref.Key, Generation: ref.Generation, Target: ports.ExactSessionTarget{LifecycleID: tt.targetID, SessionName: sess.name}, Name: sess.name, Kind: ports.RouteKindLocal}}, |
| 48 | }) | 48 | }) | ||
| 49 | ac.setRouteAttentionSubscription(ports.RouteAttentionSubscription{Targets: []ports.RouteAttentionTarget{{ | 49 | ac.setRouteAttentionSubscription(ports.RouteAttentionSubscription{Targets: []ports.RouteAttentionTarget{{ | ||
| 50 | Ref: ref, | 50 | Ref: ref, | ||
| @@ -77,7 +77,7 @@ func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) { | |||||
| 77 | 77 | ||||
| 78 | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | 78 | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | ||
| 79 | _, _, ac, _ := newManualSessionWithPTYs(t, nil) | 79 | _, _, ac, _ := newManualSessionWithPTYs(t, nil) | ||
| 80 | - | entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}} | 80 | + | entries := []ports.RecentRouteEntry{testRouteEntry(1, 2, "before", 1, ports.RouteKindLocal)} |
| 81 | snapshot := ports.RecentRouteSnapshot{Generation: 3, Entries: entries} | 81 | snapshot := ports.RecentRouteSnapshot{Generation: 3, Entries: entries} | ||
| 82 | ac.setRouteSnapshot(snapshot) | 82 | ac.setRouteSnapshot(snapshot) | ||
| 83 | 83 | ||||
| @@ -105,7 +105,7 @@ func TestPaletteRecentRouteSelectionSendsTypedClientAction(t *testing.T) { | |||||
| 105 | d: d, sess: source, attachment: source, ac: ac, | 105 | d: d, sess: source, attachment: source, ac: ac, | ||
| 106 | routeSnapshot: ports.RecentRouteSnapshot{ | 106 | routeSnapshot: ports.RecentRouteSnapshot{ | ||
| 107 | Generation: 4, | 107 | Generation: 4, | ||
| 108 | - | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 3, Name: "remote", Kind: ports.RouteKindRemote}}, | 108 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(2, 3, "remote", 2, ports.RouteKindRemote)}, |
| 109 | }, | 109 | }, | ||
| 110 | effect: effect, | 110 | effect: effect, | ||
| 111 | } | 111 | } | ||
| @@ -131,7 +131,7 @@ func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) { | |||||
| 131 | snapshot := ports.RecentRouteSnapshot{ | 131 | snapshot := ports.RecentRouteSnapshot{ | ||
| 132 | Generation: 8, | 132 | Generation: 8, | ||
| 133 | Entries: []ports.RecentRouteEntry{{ | 133 | Entries: []ports.RecentRouteEntry{{ | ||
| 134 | - | Key: 11, Generation: 7, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, | 134 | + | Key: 11, Generation: 7, Target: testRouteTarget("logs", 11), Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote, |
| 135 | }}, | 135 | }}, | ||
| 136 | } | 136 | } | ||
| 137 | 137 | ||||
A internal/usecase/daemon/route_identity_test_helpers_test.go +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/ports" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func testRouteTarget(name string, marker byte) ports.ExactSessionTarget { | |
| 9 | + | return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{marker}, SessionName: name} | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | func testRouteEntry(key, generation uint64, name string, marker byte, kind ports.RouteKind) ports.RecentRouteEntry { | |
| 13 | + | return ports.RecentRouteEntry{ | |
| 14 | + | Key: key, Generation: generation, Target: testRouteTarget(name, marker), | |
| 15 | + | Name: name, Kind: kind, | |
| 16 | + | } | |
| 17 | + | } | |
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/ports" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func testRouteTarget(name string, marker byte) ports.ExactSessionTarget { | |||
| 9 | + | return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{marker}, SessionName: name} | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | func testRouteEntry(key, generation uint64, name string, marker byte, kind ports.RouteKind) ports.RecentRouteEntry { | |||
| 13 | + | return ports.RecentRouteEntry{ | |||
| 14 | + | Key: key, Generation: generation, Target: testRouteTarget(name, marker), | |||
| 15 | + | Name: name, Kind: kind, | |||
| 16 | + | } | |||
| 17 | + | } | |||
M internal/usecase/daemon/route_position_test.go +0 -18
| @@ -10,24 +10,6 @@ import ( | |||
| 10 | 10 | "github.com/bnema/vev/internal/ports" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | func TestRouteRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) { | |
| 14 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 15 | - | sess := addControlSession(d, "work", "tab-1", "pane-1") | |
| 16 | - | sess.incarnation = domain.SessionLifecycleID{1} | |
| 17 | - | tr := &closeTrackingTransport{} | |
| 18 | - | target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 19 | - | hello := ports.Hello{ | |
| 20 | - | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name, | |
| 21 | - | Size: defaultSize, ExactTarget: &target, RemoteOrigin: "remote", | |
| 22 | - | EnvironmentPolicy: ports.EnvironmentPolicyClientOwned, | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | _, ac, err := d.routeWithContext(context.Background(), hello, tr) | |
| 26 | - | require.NoError(t, err) | |
| 27 | - | require.Equal(t, "remote", ac.remoteOrigin) | |
| 28 | - | d.clientGone(sess, ac, tr, false) | |
| 29 | - | } | |
| 30 | - | ||
| 31 | 13 | func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) { | |
| 32 | 14 | for _, test := range []struct { | |
| 33 | 15 | name string | |
| @@ -10,24 +10,6 @@ import ( | |||||
| 10 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | - | func TestRouteRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) { | |||
| 14 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 15 | - | sess := addControlSession(d, "work", "tab-1", "pane-1") | |||
| 16 | - | sess.incarnation = domain.SessionLifecycleID{1} | |||
| 17 | - | tr := &closeTrackingTransport{} | |||
| 18 | - | target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |||
| 19 | - | hello := ports.Hello{ | |||
| 20 | - | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name, | |||
| 21 | - | Size: defaultSize, ExactTarget: &target, RemoteOrigin: "remote", | |||
| 22 | - | EnvironmentPolicy: ports.EnvironmentPolicyClientOwned, | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | _, ac, err := d.routeWithContext(context.Background(), hello, tr) | |||
| 26 | - | require.NoError(t, err) | |||
| 27 | - | require.Equal(t, "remote", ac.remoteOrigin) | |||
| 28 | - | d.clientGone(sess, ac, tr, false) | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) { | 13 | func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) { | ||
| 32 | for _, test := range []struct { | 14 | for _, test := range []struct { | ||
| 33 | name string | 15 | name string | ||
M internal/usecase/daemon/session.go +20 -0
| @@ -186,7 +186,21 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 186 | 186 | return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, env, restoredTabNames...) | |
| 187 | 187 | } | |
| 188 | 188 | ||
| 189 | + | // createStoppedSessionLocked resumes exactly expectedStopped. It rechecks the | |
| 190 | + | // stopped lifecycle after catalogue I/O, which intentionally runs without d.mu. | |
| 191 | + | func (d *Daemon) createStoppedSessionLocked(name string, cwd string, sz domain.Size, env []string, expectedStopped stoppedSession, restoredTabNames ...[]string) (*session, error) { | |
| 192 | + | return d.createSessionLockedWithModeAndStoppedFence(name, false, cwd, sz, env, &expectedStopped, restoredTabNames...) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func stoppedSessionMatchesLifecycle(current, expected stoppedSession) bool { | |
| 196 | + | return current.name == expected.name && current.createdAt == expected.createdAt && current.incarnation == expected.incarnation | |
| 197 | + | } | |
| 198 | + | ||
| 189 | 199 | func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) { | |
| 200 | + | return d.createSessionLockedWithModeAndStoppedFence(name, ephemeral, cwd, sz, env, nil, restoredTabNames...) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (d *Daemon) createSessionLockedWithModeAndStoppedFence(name string, ephemeral bool, cwd string, sz domain.Size, env []string, expectedStopped *stoppedSession, restoredTabNames ...[]string) (*session, error) { | |
| 190 | 204 | env = copyEnvironment(env) | |
| 191 | 205 | if _, reserved := d.creating[name]; reserved { | |
| 192 | 206 | return nil, errSessionNameInUse | |
| @@ -194,6 +208,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st | |||
| 194 | 208 | d.creating[name] = struct{}{} | |
| 195 | 209 | defer delete(d.creating, name) | |
| 196 | 210 | stopped, resuming := d.stopped[name] | |
| 211 | + | if expectedStopped != nil && (!resuming || !stoppedSessionMatchesLifecycle(stopped, *expectedStopped)) { | |
| 212 | + | return nil, errAttachmentTransition | |
| 213 | + | } | |
| 197 | 214 | var authoritative domain.CatalogueRecord | |
| 198 | 215 | var authoritativeExists bool | |
| 199 | 216 | if !ephemeral && resuming && d.persistEnabled { | |
| @@ -208,6 +225,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st | |||
| 208 | 225 | return nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | |
| 209 | 226 | } | |
| 210 | 227 | stopped, resuming = d.stopped[name] | |
| 228 | + | if expectedStopped != nil && (!resuming || !stoppedSessionMatchesLifecycle(stopped, *expectedStopped)) { | |
| 229 | + | return nil, errAttachmentTransition | |
| 230 | + | } | |
| 211 | 231 | } | |
| 212 | 232 | var createdAt int64 | |
| 213 | 233 | var incarnation domain.IncarnationID | |
| @@ -186,7 +186,21 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 186 | return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, env, restoredTabNames...) | 186 | return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, env, restoredTabNames...) | ||
| 187 | } | 187 | } | ||
| 188 | 188 | ||||
| 189 | + | // createStoppedSessionLocked resumes exactly expectedStopped. It rechecks the | |||
| 190 | + | // stopped lifecycle after catalogue I/O, which intentionally runs without d.mu. | |||
| 191 | + | func (d *Daemon) createStoppedSessionLocked(name string, cwd string, sz domain.Size, env []string, expectedStopped stoppedSession, restoredTabNames ...[]string) (*session, error) { | |||
| 192 | + | return d.createSessionLockedWithModeAndStoppedFence(name, false, cwd, sz, env, &expectedStopped, restoredTabNames...) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func stoppedSessionMatchesLifecycle(current, expected stoppedSession) bool { | |||
| 196 | + | return current.name == expected.name && current.createdAt == expected.createdAt && current.incarnation == expected.incarnation | |||
| 197 | + | } | |||
| 198 | + | ||||
| 189 | func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) { | 199 | func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) { | ||
| 200 | + | return d.createSessionLockedWithModeAndStoppedFence(name, ephemeral, cwd, sz, env, nil, restoredTabNames...) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func (d *Daemon) createSessionLockedWithModeAndStoppedFence(name string, ephemeral bool, cwd string, sz domain.Size, env []string, expectedStopped *stoppedSession, restoredTabNames ...[]string) (*session, error) { | |||
| 190 | env = copyEnvironment(env) | 204 | env = copyEnvironment(env) | ||
| 191 | if _, reserved := d.creating[name]; reserved { | 205 | if _, reserved := d.creating[name]; reserved { | ||
| 192 | return nil, errSessionNameInUse | 206 | return nil, errSessionNameInUse | ||
| @@ -194,6 +208,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st | |||||
| 194 | d.creating[name] = struct{}{} | 208 | d.creating[name] = struct{}{} | ||
| 195 | defer delete(d.creating, name) | 209 | defer delete(d.creating, name) | ||
| 196 | stopped, resuming := d.stopped[name] | 210 | stopped, resuming := d.stopped[name] | ||
| 211 | + | if expectedStopped != nil && (!resuming || !stoppedSessionMatchesLifecycle(stopped, *expectedStopped)) { | |||
| 212 | + | return nil, errAttachmentTransition | |||
| 213 | + | } | |||
| 197 | var authoritative domain.CatalogueRecord | 214 | var authoritative domain.CatalogueRecord | ||
| 198 | var authoritativeExists bool | 215 | var authoritativeExists bool | ||
| 199 | if !ephemeral && resuming && d.persistEnabled { | 216 | if !ephemeral && resuming && d.persistEnabled { | ||
| @@ -208,6 +225,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st | |||||
| 208 | return nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | 225 | return nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | ||
| 209 | } | 226 | } | ||
| 210 | stopped, resuming = d.stopped[name] | 227 | stopped, resuming = d.stopped[name] | ||
| 228 | + | if expectedStopped != nil && (!resuming || !stoppedSessionMatchesLifecycle(stopped, *expectedStopped)) { | |||
| 229 | + | return nil, errAttachmentTransition | |||
| 230 | + | } | |||
| 211 | } | 231 | } | ||
| 212 | var createdAt int64 | 232 | var createdAt int64 | ||
| 213 | var incarnation domain.IncarnationID | 233 | var incarnation domain.IncarnationID | ||
M internal/usecase/daemon/session_back_route_test.go +1 -3
| @@ -23,9 +23,7 @@ func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T | |||
| 23 | 23 | Generation: 5, | |
| 24 | 24 | Active: ports.RouteRef{Key: 9, Generation: 5}, | |
| 25 | 25 | Previous: ports.RouteRef{Key: 7, Generation: 3}, | |
| 26 | - | Entries: []ports.RecentRouteEntry{{ | |
| 27 | - | Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal, | |
| 28 | - | }}, | |
| 26 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(7, 3, "previous", 7, ports.RouteKindLocal)}, | |
| 29 | 27 | }) | |
| 30 | 28 | ||
| 31 | 29 | require.NoError(t, d.backSessionForAttachment(token)) | |
| @@ -23,9 +23,7 @@ func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T | |||||
| 23 | Generation: 5, | 23 | Generation: 5, | ||
| 24 | Active: ports.RouteRef{Key: 9, Generation: 5}, | 24 | Active: ports.RouteRef{Key: 9, Generation: 5}, | ||
| 25 | Previous: ports.RouteRef{Key: 7, Generation: 3}, | 25 | Previous: ports.RouteRef{Key: 7, Generation: 3}, | ||
| 26 | - | Entries: []ports.RecentRouteEntry{{ | 26 | + | Entries: []ports.RecentRouteEntry{testRouteEntry(7, 3, "previous", 7, ports.RouteKindLocal)}, |
| 27 | - | Key: 7, Generation: 3, Name: "previous", Kind: ports.RouteKindLocal, | |||
| 28 | - | }}, | |||
| 29 | }) | 27 | }) | ||
| 30 | 28 | ||||
| 31 | require.NoError(t, d.backSessionForAttachment(token)) | 29 | require.NoError(t, d.backSessionForAttachment(token)) | ||
M internal/usecase/daemon/session_rename_purge_test.go +5 -3
| @@ -46,10 +46,12 @@ func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T) | |||
| 46 | 46 | require.NoError(t, d.renameSession(sess, "vps-infra")) | |
| 47 | 47 | require.Empty(t, sends, "the route ledger must be published before its identity can be updated") | |
| 48 | 48 | ||
| 49 | + | activeRef := ports.RouteRef{Key: 1, Generation: 1} | |
| 49 | 50 | snapshot := ports.RecentRouteSnapshot{ | |
| 50 | - | Generation: 1, | |
| 51 | - | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 52 | - | Home: ports.RouteRef{Key: 1, Generation: 1}, | |
| 51 | + | Generation: 1, | |
| 52 | + | Active: activeRef, | |
| 53 | + | ActiveEntry: ports.RecentRouteEntry{Key: 1, Generation: 1, Target: testRouteTarget("0", 1), Name: "0", Kind: ports.RouteKindLocal}, | |
| 54 | + | Home: activeRef, | |
| 53 | 55 | } | |
| 54 | 56 | payload, err := ports.MarshalRecentRouteSnapshot(snapshot) | |
| 55 | 57 | require.NoError(t, err) | |
| @@ -46,10 +46,12 @@ func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T) | |||||
| 46 | require.NoError(t, d.renameSession(sess, "vps-infra")) | 46 | require.NoError(t, d.renameSession(sess, "vps-infra")) | ||
| 47 | require.Empty(t, sends, "the route ledger must be published before its identity can be updated") | 47 | require.Empty(t, sends, "the route ledger must be published before its identity can be updated") | ||
| 48 | 48 | ||||
| 49 | + | activeRef := ports.RouteRef{Key: 1, Generation: 1} | |||
| 49 | snapshot := ports.RecentRouteSnapshot{ | 50 | snapshot := ports.RecentRouteSnapshot{ | ||
| 50 | - | Generation: 1, | 51 | + | Generation: 1, |
| 51 | - | Active: ports.RouteRef{Key: 1, Generation: 1}, | 52 | + | Active: activeRef, |
| 52 | - | Home: ports.RouteRef{Key: 1, Generation: 1}, | 53 | + | ActiveEntry: ports.RecentRouteEntry{Key: 1, Generation: 1, Target: testRouteTarget("0", 1), Name: "0", Kind: ports.RouteKindLocal}, |
| 54 | + | Home: activeRef, | |||
| 53 | } | 55 | } | ||
| 54 | payload, err := ports.MarshalRecentRouteSnapshot(snapshot) | 56 | payload, err := ports.MarshalRecentRouteSnapshot(snapshot) | ||
| 55 | require.NoError(t, err) | 57 | require.NoError(t, err) | ||
M internal/usecase/daemon/status.go +14 -6
| @@ -189,15 +189,23 @@ func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot { | |||
| 189 | 189 | } | |
| 190 | 190 | ||
| 191 | 191 | func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle bool) statusSnapshot { | |
| 192 | + | var routeSnapshot ports.RecentRouteSnapshot | |
| 193 | + | if ac != nil { | |
| 194 | + | routeSnapshot = ac.routeSnapshotCopy() | |
| 195 | + | } | |
| 196 | + | ||
| 192 | 197 | s.mu.Lock() | |
| 193 | 198 | defer s.mu.Unlock() | |
| 194 | - | name := s.name | |
| 195 | - | if s.ephemeral { | |
| 196 | - | name += "*" | |
| 197 | - | } | |
| 198 | - | if ac != nil && ac.remoteOrigin != "" { | |
| 199 | - | name += "@" + ac.remoteOrigin | |
| 199 | + | presentation := recentRoutePresentation{name: s.name, ephemeral: s.ephemeral} | |
| 200 | + | active := routeSnapshot.ActiveEntry | |
| 201 | + | activeRef := ports.RouteRef{Key: active.Key, Generation: active.Generation} | |
| 202 | + | if s.incarnation != (domain.SessionLifecycleID{}) && active.Target.LifecycleID == s.incarnation && routeSnapshot.Active == activeRef { | |
| 203 | + | presentation.hostLabel = domain.RemoteDisplayOrigin(active.HostLabel) | |
| 204 | + | if active.Kind == ports.RouteKindRemote { | |
| 205 | + | presentation.kind = recentRouteRemote | |
| 206 | + | } | |
| 200 | 207 | } | |
| 208 | + | name := formatRecentRouteName(presentation, false) | |
| 201 | 209 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | |
| 202 | 210 | activeIndex := 0 | |
| 203 | 211 | if ac != nil { | |
| @@ -189,15 +189,23 @@ func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot { | |||||
| 189 | } | 189 | } | ||
| 190 | 190 | ||||
| 191 | func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle bool) statusSnapshot { | 191 | func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle bool) statusSnapshot { | ||
| 192 | + | var routeSnapshot ports.RecentRouteSnapshot | |||
| 193 | + | if ac != nil { | |||
| 194 | + | routeSnapshot = ac.routeSnapshotCopy() | |||
| 195 | + | } | |||
| 196 | + | ||||
| 192 | s.mu.Lock() | 197 | s.mu.Lock() | ||
| 193 | defer s.mu.Unlock() | 198 | defer s.mu.Unlock() | ||
| 194 | - | name := s.name | 199 | + | presentation := recentRoutePresentation{name: s.name, ephemeral: s.ephemeral} |
| 195 | - | if s.ephemeral { | 200 | + | active := routeSnapshot.ActiveEntry |
| 196 | - | name += "*" | 201 | + | activeRef := ports.RouteRef{Key: active.Key, Generation: active.Generation} |
| 197 | - | } | 202 | + | if s.incarnation != (domain.SessionLifecycleID{}) && active.Target.LifecycleID == s.incarnation && routeSnapshot.Active == activeRef { |
| 198 | - | if ac != nil && ac.remoteOrigin != "" { | 203 | + | presentation.hostLabel = domain.RemoteDisplayOrigin(active.HostLabel) |
| 199 | - | name += "@" + ac.remoteOrigin | 204 | + | if active.Kind == ports.RouteKindRemote { |
| 205 | + | presentation.kind = recentRouteRemote | |||
| 206 | + | } | |||
| 200 | } | 207 | } | ||
| 208 | + | name := formatRecentRouteName(presentation, false) | |||
| 201 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | 209 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | ||
| 202 | activeIndex := 0 | 210 | activeIndex := 0 | ||
| 203 | if ac != nil { | 211 | if ac != nil { | ||
M internal/usecase/daemon/status_test.go +22 -13
| @@ -109,27 +109,36 @@ func TestStatusCompositionGolden(t *testing.T) { | |||
| 109 | 109 | } | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 112 | - | func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) { | |
| 112 | + | func TestStatusSegmentsResolvesActiveLifecyclePresentation(t *testing.T) { | |
| 113 | 113 | tests := []struct { | |
| 114 | - | name string | |
| 115 | - | remoteOrigin string | |
| 116 | - | wantAttachmentBar string | |
| 114 | + | name string | |
| 115 | + | routeLifecycle domain.SessionLifecycleID | |
| 116 | + | wantPresentation string | |
| 117 | 117 | }{ | |
| 118 | - | {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"}, | |
| 119 | - | {name: "local attachment", wantAttachmentBar: "vive"}, | |
| 118 | + | {name: "matching lifecycle", routeLifecycle: domain.SessionLifecycleID{1}, wantPresentation: "vive@arch"}, | |
| 119 | + | {name: "stale lifecycle", routeLifecycle: domain.SessionLifecycleID{99}, wantPresentation: "vive"}, | |
| 120 | 120 | } | |
| 121 | - | ||
| 122 | - | for _, test := range tests { | |
| 123 | - | t.Run(test.name, func(t *testing.T) { | |
| 121 | + | for _, tt := range tests { | |
| 122 | + | t.Run(tt.name, func(t *testing.T) { | |
| 124 | 123 | p, releasePTY := newBlockingPTY(t) | |
| 125 | 124 | _, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 126 | 125 | defer releasePTY() | |
| 127 | 126 | ||
| 128 | 127 | sess.name = "vive" | |
| 129 | - | ac.remoteOrigin = test.remoteOrigin | |
| 128 | + | sess.incarnation = domain.SessionLifecycleID{1} | |
| 129 | + | ref := ports.RouteRef{Key: 1, Generation: 1} | |
| 130 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 131 | + | Generation: 1, | |
| 132 | + | Active: ref, | |
| 133 | + | ActiveEntry: ports.RecentRouteEntry{ | |
| 134 | + | Key: ref.Key, Generation: ref.Generation, | |
| 135 | + | Target: ports.ExactSessionTarget{LifecycleID: tt.routeLifecycle, SessionName: sess.name}, | |
| 136 | + | Name: "vive", HostLabel: "user@arch", Kind: ports.RouteKindRemote, | |
| 137 | + | }, | |
| 138 | + | }) | |
| 130 | 139 | ||
| 131 | - | require.Equal(t, test.wantAttachmentBar, sess.statusSegmentsFor(ac, true).session) | |
| 132 | - | require.Equal(t, "vive", sess.statusSegments(true).session, "remote provenance belongs only to the selected attachment") | |
| 140 | + | require.Equal(t, tt.wantPresentation, sess.statusSegmentsFor(ac, true).session) | |
| 141 | + | require.Equal(t, "vive", sess.statusSegments(true).session, "route presentation belongs only to the selected attachment") | |
| 133 | 142 | }) | |
| 134 | 143 | } | |
| 135 | 144 | } | |
| @@ -981,7 +990,7 @@ func TestBarStateForContextualRecentUsesClientSnapshot(t *testing.T) { | |||
| 981 | 990 | ||
| 982 | 991 | snapshot := ports.RecentRouteSnapshot{ | |
| 983 | 992 | Generation: 1, | |
| 984 | - | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Name: "captured", Kind: ports.RouteKindLocal, Attention: true}}, | |
| 993 | + | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Target: testRouteTarget("captured", 2), Name: "captured", Kind: ports.RouteKindLocal, Attention: true}}, | |
| 985 | 994 | } | |
| 986 | 995 | hints := palette.ContextualHints{ | |
| 987 | 996 | Kind: command.ContextHintRecentSessions, | |
| @@ -109,27 +109,36 @@ func TestStatusCompositionGolden(t *testing.T) { | |||||
| 109 | } | 109 | } | ||
| 110 | } | 110 | } | ||
| 111 | 111 | ||||
| 112 | - | func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) { | 112 | + | func TestStatusSegmentsResolvesActiveLifecyclePresentation(t *testing.T) { |
| 113 | tests := []struct { | 113 | tests := []struct { | ||
| 114 | - | name string | 114 | + | name string |
| 115 | - | remoteOrigin string | 115 | + | routeLifecycle domain.SessionLifecycleID |
| 116 | - | wantAttachmentBar string | 116 | + | wantPresentation string |
| 117 | }{ | 117 | }{ | ||
| 118 | - | {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"}, | 118 | + | {name: "matching lifecycle", routeLifecycle: domain.SessionLifecycleID{1}, wantPresentation: "vive@arch"}, |
| 119 | - | {name: "local attachment", wantAttachmentBar: "vive"}, | 119 | + | {name: "stale lifecycle", routeLifecycle: domain.SessionLifecycleID{99}, wantPresentation: "vive"}, |
| 120 | } | 120 | } | ||
| 121 | - | 121 | + | for _, tt := range tests { | |
| 122 | - | for _, test := range tests { | 122 | + | t.Run(tt.name, func(t *testing.T) { |
| 123 | - | t.Run(test.name, func(t *testing.T) { | |||
| 124 | p, releasePTY := newBlockingPTY(t) | 123 | p, releasePTY := newBlockingPTY(t) | ||
| 125 | _, sess, ac, _ := newManualSessionWithPTYs(t, p) | 124 | _, sess, ac, _ := newManualSessionWithPTYs(t, p) | ||
| 126 | defer releasePTY() | 125 | defer releasePTY() | ||
| 127 | 126 | ||||
| 128 | sess.name = "vive" | 127 | sess.name = "vive" | ||
| 129 | - | ac.remoteOrigin = test.remoteOrigin | 128 | + | sess.incarnation = domain.SessionLifecycleID{1} |
| 129 | + | ref := ports.RouteRef{Key: 1, Generation: 1} | |||
| 130 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |||
| 131 | + | Generation: 1, | |||
| 132 | + | Active: ref, | |||
| 133 | + | ActiveEntry: ports.RecentRouteEntry{ | |||
| 134 | + | Key: ref.Key, Generation: ref.Generation, | |||
| 135 | + | Target: ports.ExactSessionTarget{LifecycleID: tt.routeLifecycle, SessionName: sess.name}, | |||
| 136 | + | Name: "vive", HostLabel: "user@arch", Kind: ports.RouteKindRemote, | |||
| 137 | + | }, | |||
| 138 | + | }) | |||
| 130 | 139 | ||||
| 131 | - | require.Equal(t, test.wantAttachmentBar, sess.statusSegmentsFor(ac, true).session) | 140 | + | require.Equal(t, tt.wantPresentation, sess.statusSegmentsFor(ac, true).session) |
| 132 | - | require.Equal(t, "vive", sess.statusSegments(true).session, "remote provenance belongs only to the selected attachment") | 141 | + | require.Equal(t, "vive", sess.statusSegments(true).session, "route presentation belongs only to the selected attachment") |
| 133 | }) | 142 | }) | ||
| 134 | } | 143 | } | ||
| 135 | } | 144 | } | ||
| @@ -981,7 +990,7 @@ func TestBarStateForContextualRecentUsesClientSnapshot(t *testing.T) { | |||||
| 981 | 990 | ||||
| 982 | snapshot := ports.RecentRouteSnapshot{ | 991 | snapshot := ports.RecentRouteSnapshot{ | ||
| 983 | Generation: 1, | 992 | Generation: 1, | ||
| 984 | - | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Name: "captured", Kind: ports.RouteKindLocal, Attention: true}}, | 993 | + | Entries: []ports.RecentRouteEntry{{Key: 2, Generation: 1, Target: testRouteTarget("captured", 2), Name: "captured", Kind: ports.RouteKindLocal, Attention: true}}, |
| 985 | } | 994 | } | ||
| 986 | hints := palette.ContextualHints{ | 995 | hints := palette.ContextualHints{ | ||
| 987 | Kind: command.ContextHintRecentSessions, | 996 | Kind: command.ContextHintRecentSessions, | ||
M internal/usecase/palette/action_test.go +1 -1
| @@ -54,7 +54,7 @@ func TestArgumentCommandKeepsExactRowVisible(t *testing.T) { | |||
| 54 | 54 | ||
| 55 | 55 | func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) { | |
| 56 | 56 | jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired} | |
| 57 | - | results := []Result{NewStoppedSessionResult("JRS", time.Time{}), NewCommandResult(jrs)} | |
| 57 | + | results := []Result{NewStoppedSessionResult(testExactTarget("JRS", 1), time.Time{}), NewCommandResult(jrs)} | |
| 58 | 58 | ||
| 59 | 59 | result, got, ok := argumentResult(results, "jrs 1") | |
| 60 | 60 | ||
| @@ -54,7 +54,7 @@ func TestArgumentCommandKeepsExactRowVisible(t *testing.T) { | |||||
| 54 | 54 | ||||
| 55 | func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) { | 55 | func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) { | ||
| 56 | jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired} | 56 | jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired} | ||
| 57 | - | results := []Result{NewStoppedSessionResult("JRS", time.Time{}), NewCommandResult(jrs)} | 57 | + | results := []Result{NewStoppedSessionResult(testExactTarget("JRS", 1), time.Time{}), NewCommandResult(jrs)} |
| 58 | 58 | ||||
| 59 | result, got, ok := argumentResult(results, "jrs 1") | 59 | result, got, ok := argumentResult(results, "jrs 1") | ||
| 60 | 60 | ||||
M internal/usecase/palette/fuzzy_test.go +23 -21
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | "time" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | 8 | "github.com/bnema/vev/internal/usecase/command" | |
| 9 | 9 | "github.com/stretchr/testify/require" | |
| 10 | 10 | ) | |
| @@ -81,20 +81,20 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) { | |||
| 81 | 81 | func TestFuzzyOrdersMixedResults(t *testing.T) { | |
| 82 | 82 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | |
| 83 | 83 | tests := []struct { | |
| 84 | - | name string | |
| 85 | - | results []Result | |
| 86 | - | query string | |
| 87 | - | wantText []string | |
| 88 | - | wantKinds []ResultKind | |
| 89 | - | wantPositions [][]int | |
| 90 | - | wantActiveIDs map[int]domain.SessionID | |
| 84 | + | name string | |
| 85 | + | results []Result | |
| 86 | + | query string | |
| 87 | + | wantText []string | |
| 88 | + | wantKinds []ResultKind | |
| 89 | + | wantPositions [][]int | |
| 90 | + | wantActiveTargets map[int]ports.ExactSessionTarget | |
| 91 | 91 | }{ | |
| 92 | 92 | { | |
| 93 | 93 | name: "command shortcode precedes sessions and description matches", | |
| 94 | 94 | results: []Result{ | |
| 95 | - | NewStoppedSessionResult("work", created), | |
| 95 | + | NewStoppedSessionResult(testExactTarget("work", 1), created), | |
| 96 | 96 | NewCommandResult(cmd("WORK", "", "Create a workspace")), | |
| 97 | - | NewActiveSessionResult("work", created, domain.SessionID("work-id")), | |
| 97 | + | NewActiveSessionResult(testExactTarget("work", 2), created), | |
| 98 | 98 | NewCommandResult(cmd("WQORRK", "", "")), | |
| 99 | 99 | NewCommandResult(cmd("ZZZ", "", "work tools")), | |
| 100 | 100 | }, | |
| @@ -106,8 +106,8 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||
| 106 | 106 | { | |
| 107 | 107 | name: "equivalent sessions sort by normalized text", | |
| 108 | 108 | results: []Result{ | |
| 109 | - | NewStoppedSessionResult("aBravo", time.Time{}), | |
| 110 | - | NewStoppedSessionResult("aAlpha", time.Time{}), | |
| 109 | + | NewStoppedSessionResult(testExactTarget("aBravo", 1), time.Time{}), | |
| 110 | + | NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}), | |
| 111 | 111 | }, | |
| 112 | 112 | query: "a", | |
| 113 | 113 | wantText: []string{"Resume session aAlpha", "Resume session aBravo"}, | |
| @@ -117,18 +117,20 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||
| 117 | 117 | { | |
| 118 | 118 | name: "display prefix positions sort before later active names", | |
| 119 | 119 | results: []Result{ | |
| 120 | - | NewStoppedSessionResult("aBravo", time.Time{}), | |
| 121 | - | NewStoppedSessionResult("aAlpha", time.Time{}), | |
| 122 | - | NewActiveSessionResult("aZulu", time.Time{}, "z"), | |
| 123 | - | NewActiveSessionResult("aEcho", time.Time{}, "e"), | |
| 124 | - | NewActiveSessionResult("aEcho", time.Time{}, "e2"), | |
| 120 | + | NewStoppedSessionResult(testExactTarget("aBravo", 1), time.Time{}), | |
| 121 | + | NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}), | |
| 122 | + | NewActiveSessionResult(testExactTarget("aZulu", 3), time.Time{}), | |
| 123 | + | NewActiveSessionResult(testExactTarget("aEcho", 4), time.Time{}), | |
| 124 | + | NewActiveSessionResult(testExactTarget("aEcho", 5), time.Time{}), | |
| 125 | 125 | NewCommandResult(cmd("AX", "", "")), | |
| 126 | 126 | }, | |
| 127 | 127 | query: "a", | |
| 128 | 128 | wantText: []string{"AX", "Resume session aAlpha", "Resume session aBravo", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu"}, | |
| 129 | 129 | wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindStoppedSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession}, | |
| 130 | 130 | wantPositions: [][]int{{0}, {15}, {15}, {18}, {18}, {18}}, | |
| 131 | - | wantActiveIDs: map[int]domain.SessionID{3: "e", 4: "e2", 5: "z"}, | |
| 131 | + | wantActiveTargets: map[int]ports.ExactSessionTarget{ | |
| 132 | + | 3: testExactTarget("aEcho", 4), 4: testExactTarget("aEcho", 5), 5: testExactTarget("aZulu", 3), | |
| 133 | + | }, | |
| 132 | 134 | }, | |
| 133 | 135 | } | |
| 134 | 136 | ||
| @@ -142,10 +144,10 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||
| 142 | 144 | positions[i] = matches[i].Positions | |
| 143 | 145 | } | |
| 144 | 146 | require.Equal(t, tt.wantPositions, positions) | |
| 145 | - | for index, wantID := range tt.wantActiveIDs { | |
| 146 | - | gotID, ok := matches[index].Result.SessionID() | |
| 147 | + | for index, wantTarget := range tt.wantActiveTargets { | |
| 148 | + | gotTarget, ok := matches[index].Result.SessionTarget() | |
| 147 | 149 | require.True(t, ok) | |
| 148 | - | require.Equal(t, wantID, gotID) | |
| 150 | + | require.Equal(t, wantTarget, gotTarget) | |
| 149 | 151 | } | |
| 150 | 152 | }) | |
| 151 | 153 | } | |
| @@ -4,7 +4,7 @@ import ( | |||||
| 4 | "testing" | 4 | "testing" | ||
| 5 | "time" | 5 | "time" | ||
| 6 | 6 | ||||
| 7 | - | "github.com/bnema/vev/internal/domain" | 7 | + | "github.com/bnema/vev/internal/ports" |
| 8 | "github.com/bnema/vev/internal/usecase/command" | 8 | "github.com/bnema/vev/internal/usecase/command" | ||
| 9 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 10 | ) | ||
| @@ -81,20 +81,20 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) { | |||||
| 81 | func TestFuzzyOrdersMixedResults(t *testing.T) { | 81 | func TestFuzzyOrdersMixedResults(t *testing.T) { | ||
| 82 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | 82 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | ||
| 83 | tests := []struct { | 83 | tests := []struct { | ||
| 84 | - | name string | 84 | + | name string |
| 85 | - | results []Result | 85 | + | results []Result |
| 86 | - | query string | 86 | + | query string |
| 87 | - | wantText []string | 87 | + | wantText []string |
| 88 | - | wantKinds []ResultKind | 88 | + | wantKinds []ResultKind |
| 89 | - | wantPositions [][]int | 89 | + | wantPositions [][]int |
| 90 | - | wantActiveIDs map[int]domain.SessionID | 90 | + | wantActiveTargets map[int]ports.ExactSessionTarget |
| 91 | }{ | 91 | }{ | ||
| 92 | { | 92 | { | ||
| 93 | name: "command shortcode precedes sessions and description matches", | 93 | name: "command shortcode precedes sessions and description matches", | ||
| 94 | results: []Result{ | 94 | results: []Result{ | ||
| 95 | - | NewStoppedSessionResult("work", created), | 95 | + | NewStoppedSessionResult(testExactTarget("work", 1), created), |
| 96 | NewCommandResult(cmd("WORK", "", "Create a workspace")), | 96 | NewCommandResult(cmd("WORK", "", "Create a workspace")), | ||
| 97 | - | NewActiveSessionResult("work", created, domain.SessionID("work-id")), | 97 | + | NewActiveSessionResult(testExactTarget("work", 2), created), |
| 98 | NewCommandResult(cmd("WQORRK", "", "")), | 98 | NewCommandResult(cmd("WQORRK", "", "")), | ||
| 99 | NewCommandResult(cmd("ZZZ", "", "work tools")), | 99 | NewCommandResult(cmd("ZZZ", "", "work tools")), | ||
| 100 | }, | 100 | }, | ||
| @@ -106,8 +106,8 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||||
| 106 | { | 106 | { | ||
| 107 | name: "equivalent sessions sort by normalized text", | 107 | name: "equivalent sessions sort by normalized text", | ||
| 108 | results: []Result{ | 108 | results: []Result{ | ||
| 109 | - | NewStoppedSessionResult("aBravo", time.Time{}), | 109 | + | NewStoppedSessionResult(testExactTarget("aBravo", 1), time.Time{}), |
| 110 | - | NewStoppedSessionResult("aAlpha", time.Time{}), | 110 | + | NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}), |
| 111 | }, | 111 | }, | ||
| 112 | query: "a", | 112 | query: "a", | ||
| 113 | wantText: []string{"Resume session aAlpha", "Resume session aBravo"}, | 113 | wantText: []string{"Resume session aAlpha", "Resume session aBravo"}, | ||
| @@ -117,18 +117,20 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||||
| 117 | { | 117 | { | ||
| 118 | name: "display prefix positions sort before later active names", | 118 | name: "display prefix positions sort before later active names", | ||
| 119 | results: []Result{ | 119 | results: []Result{ | ||
| 120 | - | NewStoppedSessionResult("aBravo", time.Time{}), | 120 | + | NewStoppedSessionResult(testExactTarget("aBravo", 1), time.Time{}), |
| 121 | - | NewStoppedSessionResult("aAlpha", time.Time{}), | 121 | + | NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}), |
| 122 | - | NewActiveSessionResult("aZulu", time.Time{}, "z"), | 122 | + | NewActiveSessionResult(testExactTarget("aZulu", 3), time.Time{}), |
| 123 | - | NewActiveSessionResult("aEcho", time.Time{}, "e"), | 123 | + | NewActiveSessionResult(testExactTarget("aEcho", 4), time.Time{}), |
| 124 | - | NewActiveSessionResult("aEcho", time.Time{}, "e2"), | 124 | + | NewActiveSessionResult(testExactTarget("aEcho", 5), time.Time{}), |
| 125 | NewCommandResult(cmd("AX", "", "")), | 125 | NewCommandResult(cmd("AX", "", "")), | ||
| 126 | }, | 126 | }, | ||
| 127 | query: "a", | 127 | query: "a", | ||
| 128 | wantText: []string{"AX", "Resume session aAlpha", "Resume session aBravo", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu"}, | 128 | wantText: []string{"AX", "Resume session aAlpha", "Resume session aBravo", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu"}, | ||
| 129 | wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindStoppedSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession}, | 129 | wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindStoppedSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession}, | ||
| 130 | wantPositions: [][]int{{0}, {15}, {15}, {18}, {18}, {18}}, | 130 | wantPositions: [][]int{{0}, {15}, {15}, {18}, {18}, {18}}, | ||
| 131 | - | wantActiveIDs: map[int]domain.SessionID{3: "e", 4: "e2", 5: "z"}, | 131 | + | wantActiveTargets: map[int]ports.ExactSessionTarget{ |
| 132 | + | 3: testExactTarget("aEcho", 4), 4: testExactTarget("aEcho", 5), 5: testExactTarget("aZulu", 3), | |||
| 133 | + | }, | |||
| 132 | }, | 134 | }, | ||
| 133 | } | 135 | } | ||
| 134 | 136 | ||||
| @@ -142,10 +144,10 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||||
| 142 | positions[i] = matches[i].Positions | 144 | positions[i] = matches[i].Positions | ||
| 143 | } | 145 | } | ||
| 144 | require.Equal(t, tt.wantPositions, positions) | 146 | require.Equal(t, tt.wantPositions, positions) | ||
| 145 | - | for index, wantID := range tt.wantActiveIDs { | 147 | + | for index, wantTarget := range tt.wantActiveTargets { |
| 146 | - | gotID, ok := matches[index].Result.SessionID() | 148 | + | gotTarget, ok := matches[index].Result.SessionTarget() |
| 147 | require.True(t, ok) | 149 | require.True(t, ok) | ||
| 148 | - | require.Equal(t, wantID, gotID) | 150 | + | require.Equal(t, wantTarget, gotTarget) |
| 149 | } | 151 | } | ||
| 150 | }) | 152 | }) | ||
| 151 | } | 153 | } | ||
M internal/usecase/palette/model_test.go +8 -7
| @@ -460,13 +460,14 @@ func TestModelCompleteSelected(t *testing.T) { | |||
| 460 | 460 | ||
| 461 | 461 | func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) { | |
| 462 | 462 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | |
| 463 | + | workTarget := testExactTarget("work", 1) | |
| 463 | 464 | results := []Result{ | |
| 464 | 465 | NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")), | |
| 465 | - | NewActiveSessionResult("work", created, "work-id"), | |
| 466 | - | NewStoppedSessionResult("work", created), | |
| 466 | + | NewActiveSessionResult(workTarget, created), | |
| 467 | + | NewStoppedSessionResult(testExactTarget("work", 2), created), | |
| 467 | 468 | } | |
| 468 | 469 | m := New(results) | |
| 469 | - | results[1] = NewActiveSessionResult("changed", created, "changed-id") | |
| 470 | + | results[1] = NewActiveSessionResult(testExactTarget("changed", 3), created) | |
| 470 | 471 | ||
| 471 | 472 | for _, r := range "switch" { | |
| 472 | 473 | m.Insert(r) | |
| @@ -480,9 +481,9 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T | |||
| 480 | 481 | selected, ok := m.Selected() | |
| 481 | 482 | require.True(t, ok) | |
| 482 | 483 | require.Equal(t, ResultKindActiveSession, selected.Kind()) | |
| 483 | - | id, active := selected.SessionID() | |
| 484 | + | target, active := selected.SessionTarget() | |
| 484 | 485 | require.True(t, active) | |
| 485 | - | require.Equal(t, domain.SessionID("work-id"), id) | |
| 486 | + | require.Equal(t, workTarget, target) | |
| 486 | 487 | require.False(t, m.CompleteSelected(), "sessions never participate in tab completion") | |
| 487 | 488 | require.Equal(t, "switch", m.Query()) | |
| 488 | 489 | _, argument := m.ArgumentCommand() | |
| @@ -493,7 +494,7 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T | |||
| 493 | 494 | } | |
| 494 | 495 | ||
| 495 | 496 | func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) { | |
| 496 | - | m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))}) | |
| 497 | + | m := New([]Result{NewStoppedSessionResult(testExactTarget("work", 1), time.Unix(0, 1))}) | |
| 497 | 498 | m.Insert('w') | |
| 498 | 499 | m.Insert('k') | |
| 499 | 500 | ||
| @@ -506,7 +507,7 @@ func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) { | |||
| 506 | 507 | } | |
| 507 | 508 | ||
| 508 | 509 | func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) { | |
| 509 | - | m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")}) | |
| 510 | + | m := New([]Result{NewActiveSessionResult(testExactTarget("work", 1), time.Unix(0, 1))}) | |
| 510 | 511 | frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"}) | |
| 511 | 512 | require.Len(t, m.Matches(), 1) | |
| 512 | 513 | require.Contains(t, frameRow(frame, 1), "requested session is unavailable") | |
| @@ -460,13 +460,14 @@ func TestModelCompleteSelected(t *testing.T) { | |||||
| 460 | 460 | ||||
| 461 | func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) { | 461 | func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) { | ||
| 462 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | 462 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | ||
| 463 | + | workTarget := testExactTarget("work", 1) | |||
| 463 | results := []Result{ | 464 | results := []Result{ | ||
| 464 | NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")), | 465 | NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")), | ||
| 465 | - | NewActiveSessionResult("work", created, "work-id"), | 466 | + | NewActiveSessionResult(workTarget, created), |
| 466 | - | NewStoppedSessionResult("work", created), | 467 | + | NewStoppedSessionResult(testExactTarget("work", 2), created), |
| 467 | } | 468 | } | ||
| 468 | m := New(results) | 469 | m := New(results) | ||
| 469 | - | results[1] = NewActiveSessionResult("changed", created, "changed-id") | 470 | + | results[1] = NewActiveSessionResult(testExactTarget("changed", 3), created) |
| 470 | 471 | ||||
| 471 | for _, r := range "switch" { | 472 | for _, r := range "switch" { | ||
| 472 | m.Insert(r) | 473 | m.Insert(r) | ||
| @@ -480,9 +481,9 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T | |||||
| 480 | selected, ok := m.Selected() | 481 | selected, ok := m.Selected() | ||
| 481 | require.True(t, ok) | 482 | require.True(t, ok) | ||
| 482 | require.Equal(t, ResultKindActiveSession, selected.Kind()) | 483 | require.Equal(t, ResultKindActiveSession, selected.Kind()) | ||
| 483 | - | id, active := selected.SessionID() | 484 | + | target, active := selected.SessionTarget() |
| 484 | require.True(t, active) | 485 | require.True(t, active) | ||
| 485 | - | require.Equal(t, domain.SessionID("work-id"), id) | 486 | + | require.Equal(t, workTarget, target) |
| 486 | require.False(t, m.CompleteSelected(), "sessions never participate in tab completion") | 487 | require.False(t, m.CompleteSelected(), "sessions never participate in tab completion") | ||
| 487 | require.Equal(t, "switch", m.Query()) | 488 | require.Equal(t, "switch", m.Query()) | ||
| 488 | _, argument := m.ArgumentCommand() | 489 | _, argument := m.ArgumentCommand() | ||
| @@ -493,7 +494,7 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T | |||||
| 493 | } | 494 | } | ||
| 494 | 495 | ||||
| 495 | func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) { | 496 | func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) { | ||
| 496 | - | m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))}) | 497 | + | m := New([]Result{NewStoppedSessionResult(testExactTarget("work", 1), time.Unix(0, 1))}) |
| 497 | m.Insert('w') | 498 | m.Insert('w') | ||
| 498 | m.Insert('k') | 499 | m.Insert('k') | ||
| 499 | 500 | ||||
| @@ -506,7 +507,7 @@ func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) { | |||||
| 506 | } | 507 | } | ||
| 507 | 508 | ||||
| 508 | func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) { | 509 | func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) { | ||
| 509 | - | m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")}) | 510 | + | m := New([]Result{NewActiveSessionResult(testExactTarget("work", 1), time.Unix(0, 1))}) |
| 510 | frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"}) | 511 | frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"}) | ||
| 511 | require.Len(t, m.Matches(), 1) | 512 | require.Len(t, m.Matches(), 1) | ||
| 512 | require.Contains(t, frameRow(frame, 1), "requested session is unavailable") | 513 | require.Contains(t, frameRow(frame, 1), "requested session is unavailable") | ||
M internal/usecase/palette/result.go +9 -9
| @@ -3,7 +3,6 @@ package palette | |||
| 3 | 3 | import ( | |
| 4 | 4 | "time" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/vev/internal/domain" | |
| 7 | 6 | "github.com/bnema/vev/internal/ports" | |
| 8 | 7 | "github.com/bnema/vev/internal/usecase/command" | |
| 9 | 8 | ) | |
| @@ -30,7 +29,7 @@ type Result struct { | |||
| 30 | 29 | type sessionPayload struct { | |
| 31 | 30 | name string | |
| 32 | 31 | createdAt time.Time | |
| 33 | - | sessionID domain.SessionID | |
| 32 | + | target ports.ExactSessionTarget | |
| 34 | 33 | } | |
| 35 | 34 | ||
| 36 | 35 | type routePayload struct { | |
| @@ -44,18 +43,18 @@ func NewCommandResult(cmd command.Command) Result { | |||
| 44 | 43 | } | |
| 45 | 44 | ||
| 46 | 45 | // NewActiveSessionResult creates an immutable active named-session target. | |
| 47 | - | func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) Result { | |
| 46 | + | func NewActiveSessionResult(target ports.ExactSessionTarget, createdAt time.Time) Result { | |
| 48 | 47 | return Result{ | |
| 49 | 48 | kind: ResultKindActiveSession, | |
| 50 | - | session: sessionPayload{name: name, createdAt: createdAt, sessionID: sessionID}, | |
| 49 | + | session: sessionPayload{name: target.SessionName, createdAt: createdAt, target: target}, | |
| 51 | 50 | } | |
| 52 | 51 | } | |
| 53 | 52 | ||
| 54 | 53 | // NewStoppedSessionResult creates an immutable stopped named-session target. | |
| 55 | - | func NewStoppedSessionResult(name string, createdAt time.Time) Result { | |
| 54 | + | func NewStoppedSessionResult(target ports.ExactSessionTarget, createdAt time.Time) Result { | |
| 56 | 55 | return Result{ | |
| 57 | 56 | kind: ResultKindStoppedSession, | |
| 58 | - | session: sessionPayload{name: name, createdAt: createdAt}, | |
| 57 | + | session: sessionPayload{name: target.SessionName, createdAt: createdAt, target: target}, | |
| 59 | 58 | } | |
| 60 | 59 | } | |
| 61 | 60 | ||
| @@ -102,9 +101,10 @@ func (r Result) SessionCreatedAt() (time.Time, bool) { | |||
| 102 | 101 | return r.session.createdAt, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession | |
| 103 | 102 | } | |
| 104 | 103 | ||
| 105 | - | // SessionID returns the lifecycle identity only for active session results. | |
| 106 | - | func (r Result) SessionID() (domain.SessionID, bool) { | |
| 107 | - | return r.session.sessionID, r.kind == ResultKindActiveSession | |
| 104 | + | // SessionTarget returns the exact lifecycle UUID and name for local active and | |
| 105 | + | // stopped session results. | |
| 106 | + | func (r Result) SessionTarget() (ports.ExactSessionTarget, bool) { | |
| 107 | + | return r.session.target, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | 110 | // RouteNavigationAction returns the exact client-ledger target only for a | |
| @@ -3,7 +3,6 @@ package palette | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "time" | 4 | "time" | ||
| 5 | 5 | ||||
| 6 | - | "github.com/bnema/vev/internal/domain" | |||
| 7 | "github.com/bnema/vev/internal/ports" | 6 | "github.com/bnema/vev/internal/ports" | ||
| 8 | "github.com/bnema/vev/internal/usecase/command" | 7 | "github.com/bnema/vev/internal/usecase/command" | ||
| 9 | ) | 8 | ) | ||
| @@ -30,7 +29,7 @@ type Result struct { | |||||
| 30 | type sessionPayload struct { | 29 | type sessionPayload struct { | ||
| 31 | name string | 30 | name string | ||
| 32 | createdAt time.Time | 31 | createdAt time.Time | ||
| 33 | - | sessionID domain.SessionID | 32 | + | target ports.ExactSessionTarget |
| 34 | } | 33 | } | ||
| 35 | 34 | ||||
| 36 | type routePayload struct { | 35 | type routePayload struct { | ||
| @@ -44,18 +43,18 @@ func NewCommandResult(cmd command.Command) Result { | |||||
| 44 | } | 43 | } | ||
| 45 | 44 | ||||
| 46 | // NewActiveSessionResult creates an immutable active named-session target. | 45 | // NewActiveSessionResult creates an immutable active named-session target. | ||
| 47 | - | func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) Result { | 46 | + | func NewActiveSessionResult(target ports.ExactSessionTarget, createdAt time.Time) Result { |
| 48 | return Result{ | 47 | return Result{ | ||
| 49 | kind: ResultKindActiveSession, | 48 | kind: ResultKindActiveSession, | ||
| 50 | - | session: sessionPayload{name: name, createdAt: createdAt, sessionID: sessionID}, | 49 | + | session: sessionPayload{name: target.SessionName, createdAt: createdAt, target: target}, |
| 51 | } | 50 | } | ||
| 52 | } | 51 | } | ||
| 53 | 52 | ||||
| 54 | // NewStoppedSessionResult creates an immutable stopped named-session target. | 53 | // NewStoppedSessionResult creates an immutable stopped named-session target. | ||
| 55 | - | func NewStoppedSessionResult(name string, createdAt time.Time) Result { | 54 | + | func NewStoppedSessionResult(target ports.ExactSessionTarget, createdAt time.Time) Result { |
| 56 | return Result{ | 55 | return Result{ | ||
| 57 | kind: ResultKindStoppedSession, | 56 | kind: ResultKindStoppedSession, | ||
| 58 | - | session: sessionPayload{name: name, createdAt: createdAt}, | 57 | + | session: sessionPayload{name: target.SessionName, createdAt: createdAt, target: target}, |
| 59 | } | 58 | } | ||
| 60 | } | 59 | } | ||
| 61 | 60 | ||||
| @@ -102,9 +101,10 @@ func (r Result) SessionCreatedAt() (time.Time, bool) { | |||||
| 102 | return r.session.createdAt, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession | 101 | return r.session.createdAt, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession | ||
| 103 | } | 102 | } | ||
| 104 | 103 | ||||
| 105 | - | // SessionID returns the lifecycle identity only for active session results. | 104 | + | // SessionTarget returns the exact lifecycle UUID and name for local active and |
| 106 | - | func (r Result) SessionID() (domain.SessionID, bool) { | 105 | + | // stopped session results. |
| 107 | - | return r.session.sessionID, r.kind == ResultKindActiveSession | 106 | + | func (r Result) SessionTarget() (ports.ExactSessionTarget, bool) { |
| 107 | + | return r.session.target, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession | |||
| 108 | } | 108 | } | ||
| 109 | 109 | ||||
| 110 | // RouteNavigationAction returns the exact client-ledger target only for a | 110 | // RouteNavigationAction returns the exact client-ledger target only for a | ||
M internal/usecase/palette/result_test.go +17 -10
| @@ -10,11 +10,17 @@ import ( | |||
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | + | func testExactTarget(name string, marker byte) ports.ExactSessionTarget { | |
| 14 | + | return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{marker}, SessionName: name} | |
| 15 | + | } | |
| 16 | + | ||
| 13 | 17 | func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |
| 14 | 18 | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | |
| 15 | 19 | commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"}) | |
| 16 | - | active := NewActiveSessionResult("work", created, domain.SessionID("session-work")) | |
| 17 | - | stopped := NewStoppedSessionResult("archive", created) | |
| 20 | + | activeTarget := testExactTarget("work", 1) | |
| 21 | + | stoppedTarget := testExactTarget("archive", 2) | |
| 22 | + | active := NewActiveSessionResult(activeTarget, created) | |
| 23 | + | stopped := NewStoppedSessionResult(stoppedTarget, created) | |
| 18 | 24 | ||
| 19 | 25 | require.Equal(t, ResultKindCommand, commandResult.Kind()) | |
| 20 | 26 | require.Equal(t, "NT", commandResult.DisplayText()) | |
| @@ -32,9 +38,9 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||
| 32 | 38 | require.Equal(t, created, createdAt) | |
| 33 | 39 | require.Equal(t, "Switch to session work", active.DisplayText()) | |
| 34 | 40 | require.Equal(t, "Switch to session work", active.SearchText()) | |
| 35 | - | id, ok := active.SessionID() | |
| 41 | + | target, ok := active.SessionTarget() | |
| 36 | 42 | require.True(t, ok) | |
| 37 | - | require.Equal(t, domain.SessionID("session-work"), id) | |
| 43 | + | require.Equal(t, activeTarget, target) | |
| 38 | 44 | ||
| 39 | 45 | require.Equal(t, ResultKindStoppedSession, stopped.Kind()) | |
| 40 | 46 | name, ok = stopped.SessionName() | |
| @@ -45,8 +51,9 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||
| 45 | 51 | require.Equal(t, created, createdAt) | |
| 46 | 52 | require.Equal(t, "Resume session archive", stopped.DisplayText()) | |
| 47 | 53 | require.Equal(t, "Resume session archive", stopped.SearchText()) | |
| 48 | - | _, ok = stopped.SessionID() | |
| 49 | - | require.False(t, ok) | |
| 54 | + | target, ok = stopped.SessionTarget() | |
| 55 | + | require.True(t, ok) | |
| 56 | + | require.Equal(t, stoppedTarget, target) | |
| 50 | 57 | } | |
| 51 | 58 | ||
| 52 | 59 | func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | |
| @@ -71,10 +78,10 @@ func TestSessionResultsSearchDisplayedActionText(t *testing.T) { | |||
| 71 | 78 | query string | |
| 72 | 79 | positions []int | |
| 73 | 80 | }{ | |
| 74 | - | {name: "active prefix", result: NewActiveSessionResult("work", created, "work-id"), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}}, | |
| 75 | - | {name: "active name", result: NewActiveSessionResult("work", created, "work-id"), query: "work", positions: []int{1, 8, 20, 21}}, | |
| 76 | - | {name: "stopped prefix", result: NewStoppedSessionResult("archive", created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}}, | |
| 77 | - | {name: "stopped name", result: NewStoppedSessionResult("archive", created), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}}, | |
| 81 | + | {name: "active prefix", result: NewActiveSessionResult(testExactTarget("work", 1), created), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}}, | |
| 82 | + | {name: "active name", result: NewActiveSessionResult(testExactTarget("work", 1), created), query: "work", positions: []int{1, 8, 20, 21}}, | |
| 83 | + | {name: "stopped prefix", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}}, | |
| 84 | + | {name: "stopped name", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}}, | |
| 78 | 85 | } | |
| 79 | 86 | ||
| 80 | 87 | for _, tt := range tests { | |
| @@ -10,11 +10,17 @@ import ( | |||||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | + | func testExactTarget(name string, marker byte) ports.ExactSessionTarget { | |||
| 14 | + | return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{marker}, SessionName: name} | |||
| 15 | + | } | |||
| 16 | + | ||||
| 13 | func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | 17 | func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | ||
| 14 | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | 18 | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | ||
| 15 | commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"}) | 19 | commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"}) | ||
| 16 | - | active := NewActiveSessionResult("work", created, domain.SessionID("session-work")) | 20 | + | activeTarget := testExactTarget("work", 1) |
| 17 | - | stopped := NewStoppedSessionResult("archive", created) | 21 | + | stoppedTarget := testExactTarget("archive", 2) |
| 22 | + | active := NewActiveSessionResult(activeTarget, created) | |||
| 23 | + | stopped := NewStoppedSessionResult(stoppedTarget, created) | |||
| 18 | 24 | ||||
| 19 | require.Equal(t, ResultKindCommand, commandResult.Kind()) | 25 | require.Equal(t, ResultKindCommand, commandResult.Kind()) | ||
| 20 | require.Equal(t, "NT", commandResult.DisplayText()) | 26 | require.Equal(t, "NT", commandResult.DisplayText()) | ||
| @@ -32,9 +38,9 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||||
| 32 | require.Equal(t, created, createdAt) | 38 | require.Equal(t, created, createdAt) | ||
| 33 | require.Equal(t, "Switch to session work", active.DisplayText()) | 39 | require.Equal(t, "Switch to session work", active.DisplayText()) | ||
| 34 | require.Equal(t, "Switch to session work", active.SearchText()) | 40 | require.Equal(t, "Switch to session work", active.SearchText()) | ||
| 35 | - | id, ok := active.SessionID() | 41 | + | target, ok := active.SessionTarget() |
| 36 | require.True(t, ok) | 42 | require.True(t, ok) | ||
| 37 | - | require.Equal(t, domain.SessionID("session-work"), id) | 43 | + | require.Equal(t, activeTarget, target) |
| 38 | 44 | ||||
| 39 | require.Equal(t, ResultKindStoppedSession, stopped.Kind()) | 45 | require.Equal(t, ResultKindStoppedSession, stopped.Kind()) | ||
| 40 | name, ok = stopped.SessionName() | 46 | name, ok = stopped.SessionName() | ||
| @@ -45,8 +51,9 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||||
| 45 | require.Equal(t, created, createdAt) | 51 | require.Equal(t, created, createdAt) | ||
| 46 | require.Equal(t, "Resume session archive", stopped.DisplayText()) | 52 | require.Equal(t, "Resume session archive", stopped.DisplayText()) | ||
| 47 | require.Equal(t, "Resume session archive", stopped.SearchText()) | 53 | require.Equal(t, "Resume session archive", stopped.SearchText()) | ||
| 48 | - | _, ok = stopped.SessionID() | 54 | + | target, ok = stopped.SessionTarget() |
| 49 | - | require.False(t, ok) | 55 | + | require.True(t, ok) |
| 56 | + | require.Equal(t, stoppedTarget, target) | |||
| 50 | } | 57 | } | ||
| 51 | 58 | ||||
| 52 | func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | 59 | func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | ||
| @@ -71,10 +78,10 @@ func TestSessionResultsSearchDisplayedActionText(t *testing.T) { | |||||
| 71 | query string | 78 | query string | ||
| 72 | positions []int | 79 | positions []int | ||
| 73 | }{ | 80 | }{ | ||
| 74 | - | {name: "active prefix", result: NewActiveSessionResult("work", created, "work-id"), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}}, | 81 | + | {name: "active prefix", result: NewActiveSessionResult(testExactTarget("work", 1), created), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}}, |
| 75 | - | {name: "active name", result: NewActiveSessionResult("work", created, "work-id"), query: "work", positions: []int{1, 8, 20, 21}}, | 82 | + | {name: "active name", result: NewActiveSessionResult(testExactTarget("work", 1), created), query: "work", positions: []int{1, 8, 20, 21}}, |
| 76 | - | {name: "stopped prefix", result: NewStoppedSessionResult("archive", created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}}, | 83 | + | {name: "stopped prefix", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}}, |
| 77 | - | {name: "stopped name", result: NewStoppedSessionResult("archive", created), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}}, | 84 | + | {name: "stopped name", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}}, |
| 78 | } | 85 | } | ||
| 79 | 86 | ||||
| 80 | for _, tt := range tests { | 87 | for _, tt := range tests { | ||