fix(navigation): enforce lifecycle identity at handoff
- commit
- 86aeb129e91de34f2fe561e284fb3218b6a8e26a
- author
- brice · · 2026-08-18 20:19 UTC
- committer
- brice · · 2026-08-19 04:15 UTC
- parent
- f22cb3f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +91 -40
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/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/routes.go +1 -1
| @@ -262,5 +262,5 @@ const ( | |||
| 262 | 262 | // of work a receiver performs before returning to its transport loop. The | |
| 263 | 263 | // private client history may use a smaller product cap. | |
| 264 | 264 | RouteSnapshotMaxEntries = 32 | |
| 265 | - | RouteLabelMaxBytes = 128 | |
| 265 | + | RouteLabelMaxBytes = 256 | |
| 266 | 266 | ) | |
| @@ -262,5 +262,5 @@ const ( | |||||
| 262 | // 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 | ||
| 263 | // private client history may use a smaller product cap. | 263 | // private client history may use a smaller product cap. | ||
| 264 | RouteSnapshotMaxEntries = 32 | 264 | RouteSnapshotMaxEntries = 32 | ||
| 265 | - | RouteLabelMaxBytes = 128 | 265 | + | RouteLabelMaxBytes = 256 |
| 266 | ) | 266 | ) | ||
M internal/ports/routes_test.go +6 -0
| @@ -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" | |
| @@ -195,6 +196,11 @@ func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) { | |||
| 195 | 196 | require.ErrorIs(t, err, ErrInvalidRouteWire) | |
| 196 | 197 | } | |
| 197 | 198 | ||
| 199 | + | func TestRouteLabelBoundsMatchRemoteDisplayOrigin(t *testing.T) { | |
| 200 | + | require.NoError(t, ValidateRouteLabel(strings.Repeat("a", 256), false)) | |
| 201 | + | require.Error(t, ValidateRouteLabel(strings.Repeat("a", 257), false)) | |
| 202 | + | } | |
| 203 | + | ||
| 198 | 204 | func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) { | |
| 199 | 205 | values := []string{"line\nfeed", "\u2028line-separator", "\u2029paragraph-separator", "\x1b[31mred", "\u202eoverride", string([]byte{0xff, 0xfe})} | |
| 200 | 206 | for _, value := range values { | |
| @@ -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" | ||
| @@ -195,6 +196,11 @@ func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) { | |||||
| 195 | require.ErrorIs(t, err, ErrInvalidRouteWire) | 196 | require.ErrorIs(t, err, ErrInvalidRouteWire) | ||
| 196 | } | 197 | } | ||
| 197 | 198 | ||||
| 199 | + | func TestRouteLabelBoundsMatchRemoteDisplayOrigin(t *testing.T) { | |||
| 200 | + | require.NoError(t, ValidateRouteLabel(strings.Repeat("a", 256), false)) | |||
| 201 | + | require.Error(t, ValidateRouteLabel(strings.Repeat("a", 257), false)) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 198 | func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) { | 204 | func TestRouteLabelsRejectTerminalUnsafeText(t *testing.T) { | ||
| 199 | values := []string{"line\nfeed", "\u2028line-separator", "\u2029paragraph-separator", "\x1b[31mred", "\u202eoverride", string([]byte{0xff, 0xfe})} | 205 | values := []string{"line\nfeed", "\u2028line-separator", "\u2029paragraph-separator", "\x1b[31mred", "\u202eoverride", string([]byte{0xff, 0xfe})} | ||
| 200 | for _, value := range values { | 206 | for _, value := range values { | ||
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/palette.go +9 -11
| @@ -73,15 +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 | - | target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name} | |
| 83 | - | active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), target)) | |
| 82 | + | localLifecycles[snap.incarnation] = struct{}{} | |
| 83 | + | if candidate == current { | |
| 84 | + | continue | |
| 84 | 85 | } | |
| 86 | + | target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name} | |
| 87 | + | active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), target)) | |
| 85 | 88 | } | |
| 86 | 89 | sort.Slice(active, func(i, j int) bool { | |
| 87 | 90 | left, _ := active[i].SessionName() | |
| @@ -90,11 +93,6 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 90 | 93 | }) | |
| 91 | 94 | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | |
| 92 | 95 | results = append(results, active...) | |
| 93 | - | localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(active)+len(stopped)) | |
| 94 | - | for _, candidate := range active { | |
| 95 | - | target, _ := candidate.SessionTarget() | |
| 96 | - | localLifecycles[target.LifecycleID] = struct{}{} | |
| 97 | - | } | |
| 98 | 96 | for _, candidate := range stopped { | |
| 99 | 97 | target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name} | |
| 100 | 98 | results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt), target)) | |
| @@ -274,7 +272,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 274 | 272 | } else if action, ok := selected.RouteNavigationAction(); ok { | |
| 275 | 273 | routeTarget = action | |
| 276 | 274 | hasRouteTarget = true | |
| 277 | - | } else if _, ok := selected.SessionName(); ok { | |
| 275 | + | } else if _, ok := selected.SessionTarget(); ok { | |
| 278 | 276 | sessionTarget = selected | |
| 279 | 277 | hasSessionTarget = true | |
| 280 | 278 | } else { | |
| @@ -73,15 +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 | - | target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name} | 84 | + | continue |
| 83 | - | active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), target)) | |||
| 84 | } | 85 | } | ||
| 86 | + | target := ports.ExactSessionTarget{LifecycleID: snap.incarnation, SessionName: snap.name} | |||
| 87 | + | active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), target)) | |||
| 85 | } | 88 | } | ||
| 86 | sort.Slice(active, func(i, j int) bool { | 89 | sort.Slice(active, func(i, j int) bool { | ||
| 87 | left, _ := active[i].SessionName() | 90 | left, _ := active[i].SessionName() | ||
| @@ -90,11 +93,6 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 90 | }) | 93 | }) | ||
| 91 | 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 }) | ||
| 92 | results = append(results, active...) | 95 | results = append(results, active...) | ||
| 93 | - | localLifecycles := make(map[domain.SessionLifecycleID]struct{}, len(active)+len(stopped)) | |||
| 94 | - | for _, candidate := range active { | |||
| 95 | - | target, _ := candidate.SessionTarget() | |||
| 96 | - | localLifecycles[target.LifecycleID] = struct{}{} | |||
| 97 | - | } | |||
| 98 | for _, candidate := range stopped { | 96 | for _, candidate := range stopped { | ||
| 99 | target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name} | 97 | target := ports.ExactSessionTarget{LifecycleID: candidate.incarnation, SessionName: candidate.name} | ||
| 100 | results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt), target)) | 98 | results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt), target)) | ||
| @@ -274,7 +272,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 274 | } else if action, ok := selected.RouteNavigationAction(); ok { | 272 | } else if action, ok := selected.RouteNavigationAction(); ok { | ||
| 275 | routeTarget = action | 273 | routeTarget = action | ||
| 276 | hasRouteTarget = true | 274 | hasRouteTarget = true | ||
| 277 | - | } else if _, ok := selected.SessionName(); ok { | 275 | + | } else if _, ok := selected.SessionTarget(); ok { |
| 278 | sessionTarget = selected | 276 | sessionTarget = selected | ||
| 279 | hasSessionTarget = true | 277 | hasSessionTarget = true | ||
| 280 | } else { | 278 | } else { | ||
M internal/usecase/daemon/palette_test.go +42 -9
| @@ -2,7 +2,6 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | - | "strings" | |
| 6 | 5 | "sync" | |
| 7 | 6 | "testing" | |
| 8 | 7 | ||
| @@ -15,6 +14,7 @@ import ( | |||
| 15 | 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | 15 | "github.com/bnema/vev/internal/usecase/command" | |
| 17 | 16 | "github.com/bnema/vev/internal/usecase/palette" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 18 | 18 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 19 | 19 | "github.com/bnema/vev/internal/usecase/ui" | |
| 20 | 20 | ) | |
| @@ -423,13 +423,17 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) { | |||
| 423 | 423 | func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { | |
| 424 | 424 | d := newTestDaemon(t, nil, stubClock{}) | |
| 425 | 425 | current := addControlSession(d, "hi", "tab-1", "pane-1") | |
| 426 | + | current.ephemeral = false | |
| 427 | + | current.incarnation = domain.SessionLifecycleID{1} | |
| 426 | 428 | local := addControlSession(d, "vev", "tab-2", "pane-2") | |
| 427 | 429 | local.ephemeral = false | |
| 430 | + | local.incarnation = domain.SessionLifecycleID{2} | |
| 428 | 431 | remoteOne := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"} | |
| 429 | 432 | remoteTwo := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"} | |
| 430 | 433 | snapshot := ports.RecentRouteSnapshot{ | |
| 431 | 434 | Generation: 2, | |
| 432 | 435 | Entries: []ports.RecentRouteEntry{ | |
| 436 | + | {Key: 1, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: current.incarnation, SessionName: "hi"}, Name: "hi", Kind: ports.RouteKindLocal}, | |
| 433 | 437 | {Key: 2, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: ports.RouteKindLocal}, | |
| 434 | 438 | {Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote}, | |
| 435 | 439 | {Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote}, | |
| @@ -437,17 +441,46 @@ func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { | |||
| 437 | 441 | } | |
| 438 | 442 | ||
| 439 | 443 | results := d.paletteResults(current, nil, snapshot) | |
| 440 | - | var matching []string | |
| 444 | + | var sessionTargets []ports.ExactSessionTarget | |
| 445 | + | var routeActions []ports.RouteNavigationAction | |
| 441 | 446 | for _, result := range results { | |
| 442 | - | if strings.Contains(result.DisplayText(), "session vev") { | |
| 443 | - | matching = append(matching, result.DisplayText()) | |
| 447 | + | if target, ok := result.SessionTarget(); ok { | |
| 448 | + | sessionTargets = append(sessionTargets, target) | |
| 444 | 449 | } | |
| 450 | + | if action, ok := result.RouteNavigationAction(); ok { | |
| 451 | + | routeActions = append(routeActions, action) | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | require.Equal(t, []ports.ExactSessionTarget{{LifecycleID: local.incarnation, SessionName: "vev"}}, sessionTargets) | |
| 455 | + | require.Equal(t, []ports.RouteNavigationAction{ | |
| 456 | + | {SnapshotGeneration: 2, Key: 3, Generation: 1}, | |
| 457 | + | {SnapshotGeneration: 2, Key: 4, Generation: 1}, | |
| 458 | + | }, routeActions) | |
| 459 | + | require.Equal(t, "Switch to session vev@arch", results[len(results)-2].DisplayText()) | |
| 460 | + | require.Equal(t, "Switch to session vev@arch", results[len(results)-1].DisplayText()) | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | func TestPaletteLifecycleTargetRejectsSameNameReplacement(t *testing.T) { | |
| 464 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 465 | + | defer releaseAll(releases) | |
| 466 | + | target := d.sessions["recent"] | |
| 467 | + | target.incarnation = domain.SessionLifecycleID{2} | |
| 468 | + | target.createdAt = 7 | |
| 469 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 470 | + | expectedCreatedAt := target.createdAt | |
| 471 | + | ||
| 472 | + | err := d.switchToTargetForAttachment(token, picker.Target{ | |
| 473 | + | Name: "recent", Incarnation: domain.SessionLifecycleID{9}, | |
| 474 | + | ExpectedCreatedAt: &expectedCreatedAt, TabIndex: -1, | |
| 475 | + | }, sessionHandoffGuard{}, "palette-session") | |
| 476 | + | ||
| 477 | + | require.ErrorIs(t, err, errAttachmentTransition) | |
| 478 | + | require.Same(t, current, ac.currentSession()) | |
| 479 | + | select { | |
| 480 | + | case frame := <-sends: | |
| 481 | + | require.NotEqual(t, ports.MsgAttachTarget, frame.Type) | |
| 482 | + | default: | |
| 445 | 483 | } | |
| 446 | - | require.Equal(t, []string{ | |
| 447 | - | "Switch to session vev", | |
| 448 | - | "Switch to session vev@arch", | |
| 449 | - | "Switch to session vev@arch", | |
| 450 | - | }, matching) | |
| 451 | 484 | } | |
| 452 | 485 | ||
| 453 | 486 | func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) { | |
| @@ -2,7 +2,6 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | - | "strings" | |||
| 6 | "sync" | 5 | "sync" | ||
| 7 | "testing" | 6 | "testing" | ||
| 8 | 7 | ||||
| @@ -15,6 +14,7 @@ import ( | |||||
| 15 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 16 | "github.com/bnema/vev/internal/usecase/command" | 15 | "github.com/bnema/vev/internal/usecase/command" | ||
| 17 | "github.com/bnema/vev/internal/usecase/palette" | 16 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 17 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 18 | themeui "github.com/bnema/vev/internal/usecase/theme" | 18 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 19 | "github.com/bnema/vev/internal/usecase/ui" | 19 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 20 | ) | 20 | ) | ||
| @@ -423,13 +423,17 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) { | |||||
| 423 | func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { | 423 | func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { | ||
| 424 | d := newTestDaemon(t, nil, stubClock{}) | 424 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 425 | current := addControlSession(d, "hi", "tab-1", "pane-1") | 425 | current := addControlSession(d, "hi", "tab-1", "pane-1") | ||
| 426 | + | current.ephemeral = false | |||
| 427 | + | current.incarnation = domain.SessionLifecycleID{1} | |||
| 426 | local := addControlSession(d, "vev", "tab-2", "pane-2") | 428 | local := addControlSession(d, "vev", "tab-2", "pane-2") | ||
| 427 | local.ephemeral = false | 429 | local.ephemeral = false | ||
| 430 | + | local.incarnation = domain.SessionLifecycleID{2} | |||
| 428 | remoteOne := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"} | 431 | remoteOne := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{21}, SessionName: "vev"} | ||
| 429 | remoteTwo := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"} | 432 | remoteTwo := ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{22}, SessionName: "vev"} | ||
| 430 | snapshot := ports.RecentRouteSnapshot{ | 433 | snapshot := ports.RecentRouteSnapshot{ | ||
| 431 | Generation: 2, | 434 | Generation: 2, | ||
| 432 | Entries: []ports.RecentRouteEntry{ | 435 | Entries: []ports.RecentRouteEntry{ | ||
| 436 | + | {Key: 1, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: current.incarnation, SessionName: "hi"}, Name: "hi", Kind: ports.RouteKindLocal}, | |||
| 433 | {Key: 2, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: ports.RouteKindLocal}, | 437 | {Key: 2, Generation: 1, Target: ports.ExactSessionTarget{LifecycleID: local.incarnation, SessionName: "vev"}, Name: "vev", Kind: ports.RouteKindLocal}, | ||
| 434 | {Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote}, | 438 | {Key: 3, Generation: 1, Target: remoteOne, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote}, | ||
| 435 | {Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote}, | 439 | {Key: 4, Generation: 1, Target: remoteTwo, Name: "vev", HostLabel: "arch", Kind: ports.RouteKindRemote}, | ||
| @@ -437,17 +441,46 @@ func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { | |||||
| 437 | } | 441 | } | ||
| 438 | 442 | ||||
| 439 | results := d.paletteResults(current, nil, snapshot) | 443 | results := d.paletteResults(current, nil, snapshot) | ||
| 440 | - | var matching []string | 444 | + | var sessionTargets []ports.ExactSessionTarget |
| 445 | + | var routeActions []ports.RouteNavigationAction | |||
| 441 | for _, result := range results { | 446 | for _, result := range results { | ||
| 442 | - | if strings.Contains(result.DisplayText(), "session vev") { | 447 | + | if target, ok := result.SessionTarget(); ok { |
| 443 | - | matching = append(matching, result.DisplayText()) | 448 | + | sessionTargets = append(sessionTargets, target) |
| 444 | } | 449 | } | ||
| 450 | + | if action, ok := result.RouteNavigationAction(); ok { | |||
| 451 | + | routeActions = append(routeActions, action) | |||
| 452 | + | } | |||
| 453 | + | } | |||
| 454 | + | require.Equal(t, []ports.ExactSessionTarget{{LifecycleID: local.incarnation, SessionName: "vev"}}, sessionTargets) | |||
| 455 | + | require.Equal(t, []ports.RouteNavigationAction{ | |||
| 456 | + | {SnapshotGeneration: 2, Key: 3, Generation: 1}, | |||
| 457 | + | {SnapshotGeneration: 2, Key: 4, Generation: 1}, | |||
| 458 | + | }, routeActions) | |||
| 459 | + | require.Equal(t, "Switch to session vev@arch", results[len(results)-2].DisplayText()) | |||
| 460 | + | require.Equal(t, "Switch to session vev@arch", results[len(results)-1].DisplayText()) | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | func TestPaletteLifecycleTargetRejectsSameNameReplacement(t *testing.T) { | |||
| 464 | + | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |||
| 465 | + | defer releaseAll(releases) | |||
| 466 | + | target := d.sessions["recent"] | |||
| 467 | + | target.incarnation = domain.SessionLifecycleID{2} | |||
| 468 | + | target.createdAt = 7 | |||
| 469 | + | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |||
| 470 | + | expectedCreatedAt := target.createdAt | |||
| 471 | + | ||||
| 472 | + | err := d.switchToTargetForAttachment(token, picker.Target{ | |||
| 473 | + | Name: "recent", Incarnation: domain.SessionLifecycleID{9}, | |||
| 474 | + | ExpectedCreatedAt: &expectedCreatedAt, TabIndex: -1, | |||
| 475 | + | }, sessionHandoffGuard{}, "palette-session") | |||
| 476 | + | ||||
| 477 | + | require.ErrorIs(t, err, errAttachmentTransition) | |||
| 478 | + | require.Same(t, current, ac.currentSession()) | |||
| 479 | + | select { | |||
| 480 | + | case frame := <-sends: | |||
| 481 | + | require.NotEqual(t, ports.MsgAttachTarget, frame.Type) | |||
| 482 | + | default: | |||
| 445 | } | 483 | } | ||
| 446 | - | require.Equal(t, []string{ | |||
| 447 | - | "Switch to session vev", | |||
| 448 | - | "Switch to session vev@arch", | |||
| 449 | - | "Switch to session vev@arch", | |||
| 450 | - | }, matching) | |||
| 451 | } | 484 | } | ||
| 452 | 485 | ||||
| 453 | func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) { | 486 | func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) { | ||
M internal/usecase/daemon/picker.go +11 -8
| @@ -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) { | |
| @@ -1212,7 +1212,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 1212 | 1212 | ||
| 1213 | 1213 | unlock := lockAttachmentSessions(from, targetSess) | |
| 1214 | 1214 | _, sourceAttached := from.attachments[ac] | |
| 1215 | - | if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) { | |
| 1215 | + | if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) { | |
| 1216 | 1216 | unlock() | |
| 1217 | 1217 | return nil, attachmentTransitionResult{}, false | |
| 1218 | 1218 | } | |
| @@ -1278,7 +1278,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 1278 | 1278 | expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true, | |
| 1279 | 1279 | createTargetLocked: func() (*session, error) { | |
| 1280 | 1280 | current, ok := d.stopped[target.Name] | |
| 1281 | - | if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) { | |
| 1281 | + | if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) { | |
| 1282 | 1282 | return nil, errAttachmentTransition | |
| 1283 | 1283 | } | |
| 1284 | 1284 | from.mu.Lock() | |
| @@ -1331,7 +1331,10 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 1331 | 1331 | return targetSess, transition, true, nil | |
| 1332 | 1332 | } | |
| 1333 | 1333 | ||
| 1334 | - | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | |
| 1334 | + | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64, incarnation domain.IncarnationID) bool { | |
| 1335 | + | if target.Incarnation != (domain.IncarnationID{}) && target.Incarnation != incarnation { | |
| 1336 | + | return false | |
| 1337 | + | } | |
| 1335 | 1338 | return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt) | |
| 1336 | 1339 | } | |
| 1337 | 1340 | ||
| @@ -1356,7 +1359,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 1356 | 1359 | transition attachmentTransitionResult | |
| 1357 | 1360 | switched bool | |
| 1358 | 1361 | ) | |
| 1359 | - | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |
| 1362 | + | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) { | |
| 1360 | 1363 | _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "") | |
| 1361 | 1364 | } | |
| 1362 | 1365 | 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) { | ||
| @@ -1212,7 +1212,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||||
| 1212 | 1212 | ||||
| 1213 | unlock := lockAttachmentSessions(from, targetSess) | 1213 | unlock := lockAttachmentSessions(from, targetSess) | ||
| 1214 | _, sourceAttached := from.attachments[ac] | 1214 | _, sourceAttached := from.attachments[ac] | ||
| 1215 | - | if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) { | 1215 | + | if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) { |
| 1216 | unlock() | 1216 | unlock() | ||
| 1217 | return nil, attachmentTransitionResult{}, false | 1217 | return nil, attachmentTransitionResult{}, false | ||
| 1218 | } | 1218 | } | ||
| @@ -1278,7 +1278,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||||
| 1278 | expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true, | 1278 | expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true, | ||
| 1279 | createTargetLocked: func() (*session, error) { | 1279 | createTargetLocked: func() (*session, error) { | ||
| 1280 | current, ok := d.stopped[target.Name] | 1280 | current, ok := d.stopped[target.Name] | ||
| 1281 | - | if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) { | 1281 | + | if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) { |
| 1282 | return nil, errAttachmentTransition | 1282 | return nil, errAttachmentTransition | ||
| 1283 | } | 1283 | } | ||
| 1284 | from.mu.Lock() | 1284 | from.mu.Lock() | ||
| @@ -1331,7 +1331,10 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||||
| 1331 | return targetSess, transition, true, nil | 1331 | return targetSess, transition, true, nil | ||
| 1332 | } | 1332 | } | ||
| 1333 | 1333 | ||||
| 1334 | - | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | 1334 | + | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64, incarnation domain.IncarnationID) bool { |
| 1335 | + | if target.Incarnation != (domain.IncarnationID{}) && target.Incarnation != incarnation { | |||
| 1336 | + | return false | |||
| 1337 | + | } | |||
| 1335 | return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt) | 1338 | return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt) | ||
| 1336 | } | 1339 | } | ||
| 1337 | 1340 | ||||
| @@ -1356,7 +1359,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 1356 | transition attachmentTransitionResult | 1359 | transition attachmentTransitionResult | ||
| 1357 | switched bool | 1360 | switched bool | ||
| 1358 | ) | 1361 | ) | ||
| 1359 | - | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | 1362 | + | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) { |
| 1360 | _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "") | 1363 | _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "") | ||
| 1361 | } | 1364 | } | ||
| 1362 | d.mu.Unlock() | 1365 | 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 | } | ||