Merge pull request #192 from bnema/fix/stale-ephemeral-route-status
fix(routes): refresh attention indicators on active daemon
19 files · +507 -68
M internal/ports/frame.go +20 -19
| @@ -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 = 29 | |
| 15 | + | const ProtocolVersion uint16 = 30 | |
| 16 | 16 | ||
| 17 | 17 | // MaxFrameLen is the largest permitted frame length, including the type byte | |
| 18 | 18 | // and excluding the four-byte length prefix. | |
| @@ -21,25 +21,26 @@ const MaxFrameLen = 16 << 20 | |||
| 21 | 21 | // MsgType identifies the kind of payload carried by a Frame. | |
| 22 | 22 | type MsgType uint8 | |
| 23 | 23 | ||
| 24 | - | // Frame message types. Client-originated messages occupy 1–13 and 15; | |
| 25 | - | // server-originated messages occupy 16–23 and 25–31. Values 14 and 24 remain | |
| 26 | - | // reserved for future extensions. Route metadata/navigation messages use the | |
| 27 | - | // post-26 range. | |
| 24 | + | // Frame message types use non-contiguous allocations: the legacy client range | |
| 25 | + | // is 1–13 and 15, and newer client subscriptions use 32. Server-originated | |
| 26 | + | // messages occupy 16–23 and 25–31. Values 14 and 24 remain reserved for future | |
| 27 | + | // extensions. | |
| 28 | 28 | const ( | |
| 29 | - | MsgHello MsgType = 1 | |
| 30 | - | MsgInput MsgType = 2 | |
| 31 | - | MsgResize MsgType = 3 | |
| 32 | - | MsgDetach MsgType = 4 | |
| 33 | - | MsgPing MsgType = 5 | |
| 34 | - | MsgList MsgType = 6 | |
| 35 | - | MsgKill MsgType = 7 | |
| 36 | - | MsgTheme MsgType = 8 | |
| 37 | - | MsgAck MsgType = 9 | |
| 38 | - | MsgImagePush MsgType = 10 | |
| 39 | - | MsgClientNotice MsgType = 11 | |
| 40 | - | MsgCommand MsgType = 12 | |
| 41 | - | MsgOutputResetRequest MsgType = 13 | |
| 42 | - | MsgRemotePreviewRequest MsgType = 15 | |
| 29 | + | MsgHello MsgType = 1 | |
| 30 | + | MsgInput MsgType = 2 | |
| 31 | + | MsgResize MsgType = 3 | |
| 32 | + | MsgDetach MsgType = 4 | |
| 33 | + | MsgPing MsgType = 5 | |
| 34 | + | MsgList MsgType = 6 | |
| 35 | + | MsgKill MsgType = 7 | |
| 36 | + | MsgTheme MsgType = 8 | |
| 37 | + | MsgAck MsgType = 9 | |
| 38 | + | MsgImagePush MsgType = 10 | |
| 39 | + | MsgClientNotice MsgType = 11 | |
| 40 | + | MsgCommand MsgType = 12 | |
| 41 | + | MsgOutputResetRequest MsgType = 13 | |
| 42 | + | MsgRemotePreviewRequest MsgType = 15 | |
| 43 | + | MsgRouteAttentionSubscription MsgType = 32 | |
| 43 | 44 | ||
| 44 | 45 | MsgWelcome MsgType = 16 | |
| 45 | 46 | MsgError MsgType = 17 | |
| @@ -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 = 29 | 15 | + | const ProtocolVersion uint16 = 30 |
| 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. | ||
| @@ -21,25 +21,26 @@ const MaxFrameLen = 16 << 20 | |||||
| 21 | // MsgType identifies the kind of payload carried by a Frame. | 21 | // MsgType identifies the kind of payload carried by a Frame. | ||
| 22 | type MsgType uint8 | 22 | type MsgType uint8 | ||
| 23 | 23 | ||||
| 24 | - | // Frame message types. Client-originated messages occupy 1–13 and 15; | 24 | + | // Frame message types use non-contiguous allocations: the legacy client range |
| 25 | - | // server-originated messages occupy 16–23 and 25–31. Values 14 and 24 remain | 25 | + | // is 1–13 and 15, and newer client subscriptions use 32. Server-originated |
| 26 | - | // reserved for future extensions. Route metadata/navigation messages use the | 26 | + | // messages occupy 16–23 and 25–31. Values 14 and 24 remain reserved for future |
| 27 | - | // post-26 range. | 27 | + | // extensions. |
| 28 | const ( | 28 | const ( | ||
| 29 | - | MsgHello MsgType = 1 | 29 | + | MsgHello MsgType = 1 |
| 30 | - | MsgInput MsgType = 2 | 30 | + | MsgInput MsgType = 2 |
| 31 | - | MsgResize MsgType = 3 | 31 | + | MsgResize MsgType = 3 |
| 32 | - | MsgDetach MsgType = 4 | 32 | + | MsgDetach MsgType = 4 |
| 33 | - | MsgPing MsgType = 5 | 33 | + | MsgPing MsgType = 5 |
| 34 | - | MsgList MsgType = 6 | 34 | + | MsgList MsgType = 6 |
| 35 | - | MsgKill MsgType = 7 | 35 | + | MsgKill MsgType = 7 |
| 36 | - | MsgTheme MsgType = 8 | 36 | + | MsgTheme MsgType = 8 |
| 37 | - | MsgAck MsgType = 9 | 37 | + | MsgAck MsgType = 9 |
| 38 | - | MsgImagePush MsgType = 10 | 38 | + | MsgImagePush MsgType = 10 |
| 39 | - | MsgClientNotice MsgType = 11 | 39 | + | MsgClientNotice MsgType = 11 |
| 40 | - | MsgCommand MsgType = 12 | 40 | + | MsgCommand MsgType = 12 |
| 41 | - | MsgOutputResetRequest MsgType = 13 | 41 | + | MsgOutputResetRequest MsgType = 13 |
| 42 | - | MsgRemotePreviewRequest MsgType = 15 | 42 | + | MsgRemotePreviewRequest MsgType = 15 |
| 43 | + | MsgRouteAttentionSubscription MsgType = 32 | |||
| 43 | 44 | ||||
| 44 | MsgWelcome MsgType = 16 | 45 | MsgWelcome MsgType = 16 | ||
| 45 | MsgError MsgType = 17 | 46 | MsgError MsgType = 17 | ||
M internal/ports/frame_test.go +3 -3
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 29 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 29", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 30 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -32,7 +32,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 32 | 32 | {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList}, | |
| 33 | 33 | {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck}, | |
| 34 | 34 | {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice}, | |
| 35 | - | {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, | |
| 35 | + | {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, | |
| 36 | 36 | {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput}, | |
| 37 | 37 | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, | |
| 38 | 38 | {"MsgCommandResult", MsgCommandResult}, | |
| @@ -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 != 29 { | 6 | + | if ProtocolVersion != 30 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 29", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| @@ -32,7 +32,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 32 | {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList}, | 32 | {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList}, | ||
| 33 | {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck}, | 33 | {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck}, | ||
| 34 | {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice}, | 34 | {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice}, | ||
| 35 | - | {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, | 35 | + | {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest}, {"MsgRouteAttentionSubscription", MsgRouteAttentionSubscription}, |
| 36 | {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput}, | 36 | {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput}, | ||
| 37 | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, | 37 | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, | ||
| 38 | {"MsgCommandResult", MsgCommandResult}, | 38 | {"MsgCommandResult", MsgCommandResult}, | ||
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: "001d02" + "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: "001e02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "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: "001d02" + "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: "001e02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "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 +14 -0
| @@ -167,6 +167,20 @@ type RouteRef struct { | |||
| 167 | 167 | Generation uint64 | |
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | + | // RouteAttentionTarget binds one displayed route reference to an exact | |
| 171 | + | // lifecycle on the daemon currently serving the client. It carries no dialer, | |
| 172 | + | // credential, or endpoint. | |
| 173 | + | type RouteAttentionTarget struct { | |
| 174 | + | Ref RouteRef | |
| 175 | + | Target ExactSessionTarget | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // RouteAttentionSubscription is the bounded, client-owned mapping a daemon | |
| 179 | + | // uses solely to resolve live attention while rendering its route snapshot. | |
| 180 | + | type RouteAttentionSubscription struct { | |
| 181 | + | Targets []RouteAttentionTarget | |
| 182 | + | } | |
| 183 | + | ||
| 170 | 184 | func (r RouteRef) empty() bool { return r.Key == 0 && r.Generation == 0 } | |
| 171 | 185 | ||
| 172 | 186 | func (r RouteRef) Validate() error { | |
| @@ -167,6 +167,20 @@ type RouteRef struct { | |||||
| 167 | Generation uint64 | 167 | Generation uint64 | ||
| 168 | } | 168 | } | ||
| 169 | 169 | ||||
| 170 | + | // RouteAttentionTarget binds one displayed route reference to an exact | |||
| 171 | + | // lifecycle on the daemon currently serving the client. It carries no dialer, | |||
| 172 | + | // credential, or endpoint. | |||
| 173 | + | type RouteAttentionTarget struct { | |||
| 174 | + | Ref RouteRef | |||
| 175 | + | Target ExactSessionTarget | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // RouteAttentionSubscription is the bounded, client-owned mapping a daemon | |||
| 179 | + | // uses solely to resolve live attention while rendering its route snapshot. | |||
| 180 | + | type RouteAttentionSubscription struct { | |||
| 181 | + | Targets []RouteAttentionTarget | |||
| 182 | + | } | |||
| 183 | + | ||||
| 170 | func (r RouteRef) empty() bool { return r.Key == 0 && r.Generation == 0 } | 184 | func (r RouteRef) empty() bool { return r.Key == 0 && r.Generation == 0 } | ||
| 171 | 185 | ||||
| 172 | func (r RouteRef) Validate() error { | 186 | func (r RouteRef) Validate() error { | ||
M internal/ports/routes_test.go +22 -0
| @@ -162,6 +162,28 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) { | |||
| 162 | 162 | } | |
| 163 | 163 | } | |
| 164 | 164 | ||
| 165 | + | func TestRouteAttentionSubscriptionCodecIsStrict(t *testing.T) { | |
| 166 | + | want := RouteAttentionSubscription{Targets: []RouteAttentionTarget{{ | |
| 167 | + | Ref: RouteRef{Key: 7, Generation: 3}, | |
| 168 | + | Target: testExactTarget(), | |
| 169 | + | }}} | |
| 170 | + | encoded, err := MarshalRouteAttentionSubscription(want) | |
| 171 | + | require.NoError(t, err) | |
| 172 | + | require.Equal(t, "0100000000000000070000000000000003010203000000000000000000000000000004776f726b", hex.EncodeToString(encoded)) | |
| 173 | + | assertAllPrefixesFail(t, encoded, UnmarshalRouteAttentionSubscription) | |
| 174 | + | _, err = UnmarshalRouteAttentionSubscription(append(append([]byte(nil), encoded...), 0)) | |
| 175 | + | require.Error(t, err) | |
| 176 | + | ||
| 177 | + | got, err := UnmarshalRouteAttentionSubscription(encoded) | |
| 178 | + | require.NoError(t, err) | |
| 179 | + | require.Equal(t, want, got) | |
| 180 | + | ||
| 181 | + | _, err = MarshalRouteAttentionSubscription(RouteAttentionSubscription{Targets: []RouteAttentionTarget{ | |
| 182 | + | want.Targets[0], want.Targets[0], | |
| 183 | + | }}) | |
| 184 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |
| 185 | + | } | |
| 186 | + | ||
| 165 | 187 | func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) { | |
| 166 | 188 | _, err := UnmarshalRecentRouteSnapshot(make([]byte, MaxFrameLen)) | |
| 167 | 189 | require.ErrorIs(t, err, ErrInvalidRouteWire) | |
| @@ -162,6 +162,28 @@ func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) { | |||||
| 162 | } | 162 | } | ||
| 163 | } | 163 | } | ||
| 164 | 164 | ||||
| 165 | + | func TestRouteAttentionSubscriptionCodecIsStrict(t *testing.T) { | |||
| 166 | + | want := RouteAttentionSubscription{Targets: []RouteAttentionTarget{{ | |||
| 167 | + | Ref: RouteRef{Key: 7, Generation: 3}, | |||
| 168 | + | Target: testExactTarget(), | |||
| 169 | + | }}} | |||
| 170 | + | encoded, err := MarshalRouteAttentionSubscription(want) | |||
| 171 | + | require.NoError(t, err) | |||
| 172 | + | require.Equal(t, "0100000000000000070000000000000003010203000000000000000000000000000004776f726b", hex.EncodeToString(encoded)) | |||
| 173 | + | assertAllPrefixesFail(t, encoded, UnmarshalRouteAttentionSubscription) | |||
| 174 | + | _, err = UnmarshalRouteAttentionSubscription(append(append([]byte(nil), encoded...), 0)) | |||
| 175 | + | require.Error(t, err) | |||
| 176 | + | ||||
| 177 | + | got, err := UnmarshalRouteAttentionSubscription(encoded) | |||
| 178 | + | require.NoError(t, err) | |||
| 179 | + | require.Equal(t, want, got) | |||
| 180 | + | ||||
| 181 | + | _, err = MarshalRouteAttentionSubscription(RouteAttentionSubscription{Targets: []RouteAttentionTarget{ | |||
| 182 | + | want.Targets[0], want.Targets[0], | |||
| 183 | + | }}) | |||
| 184 | + | require.ErrorIs(t, err, ErrInvalidRouteWire) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 165 | func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) { | 187 | func TestRecentRouteSnapshotRejectsOversizedFrameBeforeParsing(t *testing.T) { | ||
| 166 | _, err := UnmarshalRecentRouteSnapshot(make([]byte, MaxFrameLen)) | 188 | _, err := UnmarshalRecentRouteSnapshot(make([]byte, MaxFrameLen)) | ||
| 167 | require.ErrorIs(t, err, ErrInvalidRouteWire) | 189 | require.ErrorIs(t, err, ErrInvalidRouteWire) | ||
M internal/ports/routes_wire.go +67 -0
| @@ -340,6 +340,73 @@ func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) { | |||
| 340 | 340 | return w.b, nil | |
| 341 | 341 | } | |
| 342 | 342 | ||
| 343 | + | func validateRouteAttentionSubscription(subscription RouteAttentionSubscription) error { | |
| 344 | + | if len(subscription.Targets) > RouteSnapshotMaxEntries { | |
| 345 | + | return fmt.Errorf("%w: too many attention targets", ErrInvalidRouteWire) | |
| 346 | + | } | |
| 347 | + | refs := make(map[RouteRef]struct{}, len(subscription.Targets)) | |
| 348 | + | for _, target := range subscription.Targets { | |
| 349 | + | if err := validateRouteRef(target.Ref); err != nil || target.Ref.empty() { | |
| 350 | + | return fmt.Errorf("%w: invalid attention route reference", ErrInvalidRouteWire) | |
| 351 | + | } | |
| 352 | + | if err := target.Target.Validate(); err != nil { | |
| 353 | + | return fmt.Errorf("%w: invalid attention target: %v", ErrInvalidRouteWire, err) | |
| 354 | + | } | |
| 355 | + | if _, exists := refs[target.Ref]; exists { | |
| 356 | + | return fmt.Errorf("%w: duplicate attention route reference", ErrInvalidRouteWire) | |
| 357 | + | } | |
| 358 | + | refs[target.Ref] = struct{}{} | |
| 359 | + | } | |
| 360 | + | return nil | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // MarshalRouteAttentionSubscription encodes the bounded client mapping used | |
| 364 | + | // by the connected daemon to resolve live route-attention indicators. | |
| 365 | + | func MarshalRouteAttentionSubscription(subscription RouteAttentionSubscription) ([]byte, error) { | |
| 366 | + | if err := validateRouteAttentionSubscription(subscription); err != nil { | |
| 367 | + | return nil, err | |
| 368 | + | } | |
| 369 | + | w := payloadWriter{} | |
| 370 | + | w.putUint8(uint8(len(subscription.Targets))) | |
| 371 | + | for _, target := range subscription.Targets { | |
| 372 | + | marshalRouteRef(&w, target.Ref) | |
| 373 | + | marshalExactSessionTarget(&w, target.Target) | |
| 374 | + | } | |
| 375 | + | return w.b, nil | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // UnmarshalRouteAttentionSubscription decodes one strict bounded attention | |
| 379 | + | // mapping and rejects truncated, malformed, and trailing payloads. | |
| 380 | + | func UnmarshalRouteAttentionSubscription(b []byte) (RouteAttentionSubscription, error) { | |
| 381 | + | r := payloadReader{b: b} | |
| 382 | + | count, err := r.getUint8() | |
| 383 | + | if err != nil { | |
| 384 | + | return RouteAttentionSubscription{}, err | |
| 385 | + | } | |
| 386 | + | if int(count) > RouteSnapshotMaxEntries { | |
| 387 | + | return RouteAttentionSubscription{}, fmt.Errorf("%w: too many attention targets", ErrInvalidRouteWire) | |
| 388 | + | } | |
| 389 | + | subscription := RouteAttentionSubscription{Targets: make([]RouteAttentionTarget, 0, int(count))} | |
| 390 | + | for range int(count) { | |
| 391 | + | ref, err := unmarshalRouteRef(&r) | |
| 392 | + | if err != nil { | |
| 393 | + | return RouteAttentionSubscription{}, err | |
| 394 | + | } | |
| 395 | + | target, err := unmarshalExactSessionTarget(&r) | |
| 396 | + | if err != nil { | |
| 397 | + | return RouteAttentionSubscription{}, err | |
| 398 | + | } | |
| 399 | + | subscription.Targets = append(subscription.Targets, RouteAttentionTarget{Ref: ref, Target: target}) | |
| 400 | + | } | |
| 401 | + | if err := r.done(); err != nil { | |
| 402 | + | return RouteAttentionSubscription{}, err | |
| 403 | + | } | |
| 404 | + | if err := validateRouteAttentionSubscription(subscription); err != nil { | |
| 405 | + | return RouteAttentionSubscription{}, err | |
| 406 | + | } | |
| 407 | + | return subscription, nil | |
| 408 | + | } | |
| 409 | + | ||
| 343 | 410 | // UnmarshalRecentRouteSnapshot decodes and strictly validates one complete | |
| 344 | 411 | // immutable snapshot, including every referenced active/previous/home entry. | |
| 345 | 412 | func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) { | |
| @@ -340,6 +340,73 @@ func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) { | |||||
| 340 | return w.b, nil | 340 | return w.b, nil | ||
| 341 | } | 341 | } | ||
| 342 | 342 | ||||
| 343 | + | func validateRouteAttentionSubscription(subscription RouteAttentionSubscription) error { | |||
| 344 | + | if len(subscription.Targets) > RouteSnapshotMaxEntries { | |||
| 345 | + | return fmt.Errorf("%w: too many attention targets", ErrInvalidRouteWire) | |||
| 346 | + | } | |||
| 347 | + | refs := make(map[RouteRef]struct{}, len(subscription.Targets)) | |||
| 348 | + | for _, target := range subscription.Targets { | |||
| 349 | + | if err := validateRouteRef(target.Ref); err != nil || target.Ref.empty() { | |||
| 350 | + | return fmt.Errorf("%w: invalid attention route reference", ErrInvalidRouteWire) | |||
| 351 | + | } | |||
| 352 | + | if err := target.Target.Validate(); err != nil { | |||
| 353 | + | return fmt.Errorf("%w: invalid attention target: %v", ErrInvalidRouteWire, err) | |||
| 354 | + | } | |||
| 355 | + | if _, exists := refs[target.Ref]; exists { | |||
| 356 | + | return fmt.Errorf("%w: duplicate attention route reference", ErrInvalidRouteWire) | |||
| 357 | + | } | |||
| 358 | + | refs[target.Ref] = struct{}{} | |||
| 359 | + | } | |||
| 360 | + | return nil | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | // MarshalRouteAttentionSubscription encodes the bounded client mapping used | |||
| 364 | + | // by the connected daemon to resolve live route-attention indicators. | |||
| 365 | + | func MarshalRouteAttentionSubscription(subscription RouteAttentionSubscription) ([]byte, error) { | |||
| 366 | + | if err := validateRouteAttentionSubscription(subscription); err != nil { | |||
| 367 | + | return nil, err | |||
| 368 | + | } | |||
| 369 | + | w := payloadWriter{} | |||
| 370 | + | w.putUint8(uint8(len(subscription.Targets))) | |||
| 371 | + | for _, target := range subscription.Targets { | |||
| 372 | + | marshalRouteRef(&w, target.Ref) | |||
| 373 | + | marshalExactSessionTarget(&w, target.Target) | |||
| 374 | + | } | |||
| 375 | + | return w.b, nil | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | // UnmarshalRouteAttentionSubscription decodes one strict bounded attention | |||
| 379 | + | // mapping and rejects truncated, malformed, and trailing payloads. | |||
| 380 | + | func UnmarshalRouteAttentionSubscription(b []byte) (RouteAttentionSubscription, error) { | |||
| 381 | + | r := payloadReader{b: b} | |||
| 382 | + | count, err := r.getUint8() | |||
| 383 | + | if err != nil { | |||
| 384 | + | return RouteAttentionSubscription{}, err | |||
| 385 | + | } | |||
| 386 | + | if int(count) > RouteSnapshotMaxEntries { | |||
| 387 | + | return RouteAttentionSubscription{}, fmt.Errorf("%w: too many attention targets", ErrInvalidRouteWire) | |||
| 388 | + | } | |||
| 389 | + | subscription := RouteAttentionSubscription{Targets: make([]RouteAttentionTarget, 0, int(count))} | |||
| 390 | + | for range int(count) { | |||
| 391 | + | ref, err := unmarshalRouteRef(&r) | |||
| 392 | + | if err != nil { | |||
| 393 | + | return RouteAttentionSubscription{}, err | |||
| 394 | + | } | |||
| 395 | + | target, err := unmarshalExactSessionTarget(&r) | |||
| 396 | + | if err != nil { | |||
| 397 | + | return RouteAttentionSubscription{}, err | |||
| 398 | + | } | |||
| 399 | + | subscription.Targets = append(subscription.Targets, RouteAttentionTarget{Ref: ref, Target: target}) | |||
| 400 | + | } | |||
| 401 | + | if err := r.done(); err != nil { | |||
| 402 | + | return RouteAttentionSubscription{}, err | |||
| 403 | + | } | |||
| 404 | + | if err := validateRouteAttentionSubscription(subscription); err != nil { | |||
| 405 | + | return RouteAttentionSubscription{}, err | |||
| 406 | + | } | |||
| 407 | + | return subscription, nil | |||
| 408 | + | } | |||
| 409 | + | ||||
| 343 | // UnmarshalRecentRouteSnapshot decodes and strictly validates one complete | 410 | // UnmarshalRecentRouteSnapshot decodes and strictly validates one complete | ||
| 344 | // immutable snapshot, including every referenced active/previous/home entry. | 411 | // immutable snapshot, including every referenced active/previous/home entry. | ||
| 345 | func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) { | 412 | func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) { | ||
M internal/ports/wire_final_test.go +3 -3
| @@ -11,8 +11,8 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |
| 14 | - | if ProtocolVersion != 29 { | |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 29", ProtocolVersion) | |
| 14 | + | if ProtocolVersion != 30 { | |
| 15 | + | t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion) | |
| 16 | 16 | } | |
| 17 | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 18 | 18 | if len(payload) < 2 { | |
| @@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 26 | 26 | ||
| 27 | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | |
| 28 | 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 29 | - | want := append([]byte{0, 29, 2}, make([]byte, 16+8)...) | |
| 29 | + | want := append([]byte{0, 30, 2}, make([]byte, 16+8)...) | |
| 30 | 30 | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | |
| 31 | 31 | got := MarshalHello(msg) | |
| 32 | 32 | if !bytes.Equal(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 != 29 { | 14 | + | if ProtocolVersion != 30 { |
| 15 | - | t.Fatalf("ProtocolVersion = %d, want 29", ProtocolVersion) | 15 | + | t.Fatalf("ProtocolVersion = %d, want 30", ProtocolVersion) |
| 16 | } | 16 | } | ||
| 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | 17 | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | ||
| 18 | if len(payload) < 2 { | 18 | if len(payload) < 2 { | ||
| @@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||||
| 26 | 26 | ||||
| 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | 27 | func TestFinalHelloGoldenStrict(t *testing.T) { | ||
| 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | 28 | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | ||
| 29 | - | want := append([]byte{0, 29, 2}, make([]byte, 16+8)...) | 29 | + | want := append([]byte{0, 30, 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, 0, 0) | ||
| 31 | got := MarshalHello(msg) | 31 | got := MarshalHello(msg) | ||
| 32 | if !bytes.Equal(got, want) { | 32 | if !bytes.Equal(got, want) { | ||
M internal/ports/wire_test.go +6 -6
| @@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 131 | 131 | } | |
| 132 | 132 | got := MarshalHello(msg) | |
| 133 | 133 | want := []byte{ | |
| 134 | - | 0x00, 0x1d, 0x00, // version, intent, first client ID byte | |
| 134 | + | 0x00, 0x1e, 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 | |
| @@ -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(29), ProtocolVersion) | |
| 364 | + | require.Equal(t, uint16(30), 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, 0x1d, | |
| 525 | + | 0x00, 0x1e, | |
| 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, 0x1d, | |
| 548 | + | 0x00, 0x1e, | |
| 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, 0x1d, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 977 | + | 0x00, 0x1e, 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, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 987 | + | 0x00, 0x1e, 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 | }, | |
| @@ -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, 0x1d, 0x00, // version, intent, first client ID byte | 134 | + | 0x00, 0x1e, 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 | ||
| @@ -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(29), ProtocolVersion) | 364 | + | require.Equal(t, uint16(30), 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, 0x1d, | 525 | + | 0x00, 0x1e, |
| 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, 0x1d, | 548 | + | 0x00, 0x1e, |
| 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, 0x1d, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | 977 | + | 0x00, 0x1e, 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, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 987 | + | 0x00, 0x1e, 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 +24 -8
| @@ -1118,12 +1118,19 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1118 | 1118 | if commitErr != nil { | |
| 1119 | 1119 | return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr)) | |
| 1120 | 1120 | } | |
| 1121 | - | payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot()) | |
| 1121 | + | snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot()) | |
| 1122 | 1122 | if err != nil { | |
| 1123 | 1123 | return welcomedResult(fmt.Errorf("vev: encoding route snapshot: %w", err)) | |
| 1124 | 1124 | } | |
| 1125 | + | attentionPayload, err := ports.MarshalRouteAttentionSubscription(a.runner.ledger.attentionSubscription()) | |
| 1126 | + | if err != nil { | |
| 1127 | + | return welcomedResult(fmt.Errorf("vev: encoding route attention subscription: %w", err)) | |
| 1128 | + | } | |
| 1125 | 1129 | if err := sendHandshake(func() error { | |
| 1126 | - | return transport.Send(ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload}) | |
| 1130 | + | if err := transport.Send(ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: snapshotPayload}); err != nil { | |
| 1131 | + | return err | |
| 1132 | + | } | |
| 1133 | + | return transport.Send(ports.Frame{Type: ports.MsgRouteAttentionSubscription, Payload: attentionPayload}) | |
| 1127 | 1134 | }); err != nil { | |
| 1128 | 1135 | return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err)) | |
| 1129 | 1136 | } | |
| @@ -1359,16 +1366,25 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1359 | 1366 | if a.runner.ledger == nil { | |
| 1360 | 1367 | return errors.New("vev: route ledger unavailable") | |
| 1361 | 1368 | } | |
| 1362 | - | payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot()) | |
| 1369 | + | snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot()) | |
| 1363 | 1370 | if err != nil { | |
| 1364 | 1371 | return err | |
| 1365 | 1372 | } | |
| 1366 | - | select { | |
| 1367 | - | case sendCh <- ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload}: | |
| 1368 | - | return nil | |
| 1369 | - | case <-loopCtx.Done(): | |
| 1370 | - | return context.Canceled | |
| 1373 | + | attentionPayload, err := ports.MarshalRouteAttentionSubscription(a.runner.ledger.attentionSubscription()) | |
| 1374 | + | if err != nil { | |
| 1375 | + | return err | |
| 1371 | 1376 | } | |
| 1377 | + | for _, frame := range []ports.Frame{ | |
| 1378 | + | {Type: ports.MsgRecentRouteSnapshot, Payload: snapshotPayload}, | |
| 1379 | + | {Type: ports.MsgRouteAttentionSubscription, Payload: attentionPayload}, | |
| 1380 | + | } { | |
| 1381 | + | select { | |
| 1382 | + | case sendCh <- frame: | |
| 1383 | + | case <-loopCtx.Done(): | |
| 1384 | + | return context.Canceled | |
| 1385 | + | } | |
| 1386 | + | } | |
| 1387 | + | return nil | |
| 1372 | 1388 | } | |
| 1373 | 1389 | sendRouteFailure := func(action ports.RouteNavigationAction, code ports.RouteFailureCode) error { | |
| 1374 | 1390 | payload, err := ports.MarshalRouteNavigationFailure(ports.RouteNavigationFailure{ | |
| @@ -1118,12 +1118,19 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1118 | if commitErr != nil { | 1118 | if commitErr != nil { | ||
| 1119 | return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr)) | 1119 | return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr)) | ||
| 1120 | } | 1120 | } | ||
| 1121 | - | payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot()) | 1121 | + | snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot()) |
| 1122 | if err != nil { | 1122 | if err != nil { | ||
| 1123 | return welcomedResult(fmt.Errorf("vev: encoding route snapshot: %w", err)) | 1123 | return welcomedResult(fmt.Errorf("vev: encoding route snapshot: %w", err)) | ||
| 1124 | } | 1124 | } | ||
| 1125 | + | attentionPayload, err := ports.MarshalRouteAttentionSubscription(a.runner.ledger.attentionSubscription()) | |||
| 1126 | + | if err != nil { | |||
| 1127 | + | return welcomedResult(fmt.Errorf("vev: encoding route attention subscription: %w", err)) | |||
| 1128 | + | } | |||
| 1125 | if err := sendHandshake(func() error { | 1129 | if err := sendHandshake(func() error { | ||
| 1126 | - | return transport.Send(ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload}) | 1130 | + | if err := transport.Send(ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: snapshotPayload}); err != nil { |
| 1131 | + | return err | |||
| 1132 | + | } | |||
| 1133 | + | return transport.Send(ports.Frame{Type: ports.MsgRouteAttentionSubscription, Payload: attentionPayload}) | |||
| 1127 | }); err != nil { | 1134 | }); err != nil { | ||
| 1128 | return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err)) | 1135 | return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err)) | ||
| 1129 | } | 1136 | } | ||
| @@ -1359,16 +1366,25 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1359 | if a.runner.ledger == nil { | 1366 | if a.runner.ledger == nil { | ||
| 1360 | return errors.New("vev: route ledger unavailable") | 1367 | return errors.New("vev: route ledger unavailable") | ||
| 1361 | } | 1368 | } | ||
| 1362 | - | payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot()) | 1369 | + | snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot()) |
| 1363 | if err != nil { | 1370 | if err != nil { | ||
| 1364 | return err | 1371 | return err | ||
| 1365 | } | 1372 | } | ||
| 1366 | - | select { | 1373 | + | attentionPayload, err := ports.MarshalRouteAttentionSubscription(a.runner.ledger.attentionSubscription()) |
| 1367 | - | case sendCh <- ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload}: | 1374 | + | if err != nil { |
| 1368 | - | return nil | 1375 | + | return err |
| 1369 | - | case <-loopCtx.Done(): | |||
| 1370 | - | return context.Canceled | |||
| 1371 | } | 1376 | } | ||
| 1377 | + | for _, frame := range []ports.Frame{ | |||
| 1378 | + | {Type: ports.MsgRecentRouteSnapshot, Payload: snapshotPayload}, | |||
| 1379 | + | {Type: ports.MsgRouteAttentionSubscription, Payload: attentionPayload}, | |||
| 1380 | + | } { | |||
| 1381 | + | select { | |||
| 1382 | + | case sendCh <- frame: | |||
| 1383 | + | case <-loopCtx.Done(): | |||
| 1384 | + | return context.Canceled | |||
| 1385 | + | } | |||
| 1386 | + | } | |||
| 1387 | + | return nil | |||
| 1372 | } | 1388 | } | ||
| 1373 | sendRouteFailure := func(action ports.RouteNavigationAction, code ports.RouteFailureCode) error { | 1389 | sendRouteFailure := func(action ports.RouteNavigationAction, code ports.RouteFailureCode) error { | ||
| 1374 | payload, err := ports.MarshalRouteNavigationFailure(ports.RouteNavigationFailure{ | 1390 | payload, err := ports.MarshalRouteNavigationFailure(ports.RouteNavigationFailure{ | ||
M internal/usecase/client/client_test.go +17 -8
| @@ -438,17 +438,26 @@ func TestAttachPublishesCommittedRouteSnapshot(t *testing.T) { | |||
| 438 | 438 | require.NoError(t, err) | |
| 439 | 439 | ||
| 440 | 440 | var snapshot ports.RecentRouteSnapshot | |
| 441 | - | found := false | |
| 441 | + | var subscription ports.RouteAttentionSubscription | |
| 442 | + | foundSnapshot := false | |
| 443 | + | foundSubscription := false | |
| 442 | 444 | for _, frame := range tr.Sends() { | |
| 443 | - | if frame.Type != ports.MsgRecentRouteSnapshot { | |
| 444 | - | continue | |
| 445 | + | switch frame.Type { | |
| 446 | + | case ports.MsgRecentRouteSnapshot: | |
| 447 | + | var err error | |
| 448 | + | snapshot, err = ports.UnmarshalRecentRouteSnapshot(frame.Payload) | |
| 449 | + | require.NoError(t, err) | |
| 450 | + | foundSnapshot = true | |
| 451 | + | case ports.MsgRouteAttentionSubscription: | |
| 452 | + | var err error | |
| 453 | + | subscription, err = ports.UnmarshalRouteAttentionSubscription(frame.Payload) | |
| 454 | + | require.NoError(t, err) | |
| 455 | + | foundSubscription = true | |
| 445 | 456 | } | |
| 446 | - | var err error | |
| 447 | - | snapshot, err = ports.UnmarshalRecentRouteSnapshot(frame.Payload) | |
| 448 | - | require.NoError(t, err) | |
| 449 | - | found = true | |
| 450 | 457 | } | |
| 451 | - | require.True(t, found, "successful Welcome must publish a route snapshot") | |
| 458 | + | require.True(t, foundSnapshot, "successful Welcome must publish a route snapshot") | |
| 459 | + | require.True(t, foundSubscription, "successful Welcome must publish a route attention subscription") | |
| 460 | + | require.Empty(t, subscription.Targets, "the active route itself needs no status subscription") | |
| 452 | 461 | require.NotZero(t, snapshot.Generation) | |
| 453 | 462 | require.NotZero(t, snapshot.Active.Key) | |
| 454 | 463 | require.Empty(t, snapshot.Entries, "the active route is metadata-only") | |
| @@ -438,17 +438,26 @@ func TestAttachPublishesCommittedRouteSnapshot(t *testing.T) { | |||||
| 438 | require.NoError(t, err) | 438 | require.NoError(t, err) | ||
| 439 | 439 | ||||
| 440 | var snapshot ports.RecentRouteSnapshot | 440 | var snapshot ports.RecentRouteSnapshot | ||
| 441 | - | found := false | 441 | + | var subscription ports.RouteAttentionSubscription |
| 442 | + | foundSnapshot := false | |||
| 443 | + | foundSubscription := false | |||
| 442 | for _, frame := range tr.Sends() { | 444 | for _, frame := range tr.Sends() { | ||
| 443 | - | if frame.Type != ports.MsgRecentRouteSnapshot { | 445 | + | switch frame.Type { |
| 444 | - | continue | 446 | + | case ports.MsgRecentRouteSnapshot: |
| 447 | + | var err error | |||
| 448 | + | snapshot, err = ports.UnmarshalRecentRouteSnapshot(frame.Payload) | |||
| 449 | + | require.NoError(t, err) | |||
| 450 | + | foundSnapshot = true | |||
| 451 | + | case ports.MsgRouteAttentionSubscription: | |||
| 452 | + | var err error | |||
| 453 | + | subscription, err = ports.UnmarshalRouteAttentionSubscription(frame.Payload) | |||
| 454 | + | require.NoError(t, err) | |||
| 455 | + | foundSubscription = true | |||
| 445 | } | 456 | } | ||
| 446 | - | var err error | |||
| 447 | - | snapshot, err = ports.UnmarshalRecentRouteSnapshot(frame.Payload) | |||
| 448 | - | require.NoError(t, err) | |||
| 449 | - | found = true | |||
| 450 | } | 457 | } | ||
| 451 | - | require.True(t, found, "successful Welcome must publish a route snapshot") | 458 | + | require.True(t, foundSnapshot, "successful Welcome must publish a route snapshot") |
| 459 | + | require.True(t, foundSubscription, "successful Welcome must publish a route attention subscription") | |||
| 460 | + | require.Empty(t, subscription.Targets, "the active route itself needs no status subscription") | |||
| 452 | require.NotZero(t, snapshot.Generation) | 461 | require.NotZero(t, snapshot.Generation) | ||
| 453 | require.NotZero(t, snapshot.Active.Key) | 462 | require.NotZero(t, snapshot.Active.Key) | ||
| 454 | require.Empty(t, snapshot.Entries, "the active route is metadata-only") | 463 | require.Empty(t, snapshot.Entries, "the active route is metadata-only") | ||
M internal/usecase/client/routes.go +47 -1
| @@ -347,10 +347,32 @@ func (l *routeLedger) commitCommittedIdentity(identity ports.CommittedRouteIdent | |||
| 347 | 347 | if activeIndex < 0 { | |
| 348 | 348 | return routeIdentity{}, errors.New("active route unavailable") | |
| 349 | 349 | } | |
| 350 | - | candidate, err := prepareRouteCandidate(routeCandidateForCommittedIdentity(l.entries[activeIndex], identity)) | |
| 350 | + | active := l.entries[activeIndex] | |
| 351 | + | candidate, err := prepareRouteCandidate(routeCandidateForCommittedIdentity(active, identity)) | |
| 351 | 352 | if err != nil { | |
| 352 | 353 | return routeIdentity{}, err | |
| 353 | 354 | } | |
| 355 | + | // A session rename changes its exact attach target but not its lifecycle. | |
| 356 | + | // Keep the active route's process-local identity so the obsolete name cannot | |
| 357 | + | // survive as a separately selectable history entry. | |
| 358 | + | if active.target.LifecycleID == candidate.target.LifecycleID { | |
| 359 | + | if l.generation == ^routeGeneration(0) { | |
| 360 | + | return routeIdentity{}, errors.New("route ledger identity space exhausted") | |
| 361 | + | } | |
| 362 | + | l.generation++ | |
| 363 | + | l.entries[activeIndex] = routeRecord{ | |
| 364 | + | identity: active.identity, | |
| 365 | + | origin: candidate.origin, | |
| 366 | + | originKey: candidate.originKey, | |
| 367 | + | target: candidate.target, | |
| 368 | + | presentation: candidate.presentation, | |
| 369 | + | dialer: candidate.dialer, | |
| 370 | + | request: candidate.request, | |
| 371 | + | resumeToken: candidate.resumeToken, | |
| 372 | + | home: active.home, | |
| 373 | + | } | |
| 374 | + | return active.identity, nil | |
| 375 | + | } | |
| 354 | 376 | return l.commitLocked(candidate) | |
| 355 | 377 | } | |
| 356 | 378 | ||
| @@ -453,6 +475,30 @@ func cloneRouteRecord(entry routeRecord) routeRecord { | |||
| 453 | 475 | return entry | |
| 454 | 476 | } | |
| 455 | 477 | ||
| 478 | + | // attentionSubscription publishes only routes served by the active route's | |
| 479 | + | // daemon. Other route origins remain private to their owning daemon. | |
| 480 | + | func (l *routeLedger) attentionSubscription() ports.RouteAttentionSubscription { | |
| 481 | + | l.mu.RLock() | |
| 482 | + | defer l.mu.RUnlock() | |
| 483 | + | ||
| 484 | + | activeIndex := l.indexByIdentityLocked(l.active) | |
| 485 | + | if activeIndex < 0 { | |
| 486 | + | return ports.RouteAttentionSubscription{} | |
| 487 | + | } | |
| 488 | + | active := l.entries[activeIndex] | |
| 489 | + | subscription := ports.RouteAttentionSubscription{Targets: make([]ports.RouteAttentionTarget, 0, len(l.entries))} | |
| 490 | + | for _, entry := range l.entries { | |
| 491 | + | if entry.identity == active.identity || entry.origin != active.origin || entry.originKey != active.originKey { | |
| 492 | + | continue | |
| 493 | + | } | |
| 494 | + | subscription.Targets = append(subscription.Targets, ports.RouteAttentionTarget{ | |
| 495 | + | Ref: entry.identity.wire(), | |
| 496 | + | Target: entry.target, | |
| 497 | + | }) | |
| 498 | + | } | |
| 499 | + | return subscription | |
| 500 | + | } | |
| 501 | + | ||
| 456 | 502 | func (l *routeLedger) snapshot() ports.RecentRouteSnapshot { | |
| 457 | 503 | l.mu.RLock() | |
| 458 | 504 | defer l.mu.RUnlock() | |
| @@ -347,10 +347,32 @@ func (l *routeLedger) commitCommittedIdentity(identity ports.CommittedRouteIdent | |||||
| 347 | if activeIndex < 0 { | 347 | if activeIndex < 0 { | ||
| 348 | return routeIdentity{}, errors.New("active route unavailable") | 348 | return routeIdentity{}, errors.New("active route unavailable") | ||
| 349 | } | 349 | } | ||
| 350 | - | candidate, err := prepareRouteCandidate(routeCandidateForCommittedIdentity(l.entries[activeIndex], identity)) | 350 | + | active := l.entries[activeIndex] |
| 351 | + | candidate, err := prepareRouteCandidate(routeCandidateForCommittedIdentity(active, identity)) | |||
| 351 | if err != nil { | 352 | if err != nil { | ||
| 352 | return routeIdentity{}, err | 353 | return routeIdentity{}, err | ||
| 353 | } | 354 | } | ||
| 355 | + | // A session rename changes its exact attach target but not its lifecycle. | |||
| 356 | + | // Keep the active route's process-local identity so the obsolete name cannot | |||
| 357 | + | // survive as a separately selectable history entry. | |||
| 358 | + | if active.target.LifecycleID == candidate.target.LifecycleID { | |||
| 359 | + | if l.generation == ^routeGeneration(0) { | |||
| 360 | + | return routeIdentity{}, errors.New("route ledger identity space exhausted") | |||
| 361 | + | } | |||
| 362 | + | l.generation++ | |||
| 363 | + | l.entries[activeIndex] = routeRecord{ | |||
| 364 | + | identity: active.identity, | |||
| 365 | + | origin: candidate.origin, | |||
| 366 | + | originKey: candidate.originKey, | |||
| 367 | + | target: candidate.target, | |||
| 368 | + | presentation: candidate.presentation, | |||
| 369 | + | dialer: candidate.dialer, | |||
| 370 | + | request: candidate.request, | |||
| 371 | + | resumeToken: candidate.resumeToken, | |||
| 372 | + | home: active.home, | |||
| 373 | + | } | |||
| 374 | + | return active.identity, nil | |||
| 375 | + | } | |||
| 354 | return l.commitLocked(candidate) | 376 | return l.commitLocked(candidate) | ||
| 355 | } | 377 | } | ||
| 356 | 378 | ||||
| @@ -453,6 +475,30 @@ func cloneRouteRecord(entry routeRecord) routeRecord { | |||||
| 453 | return entry | 475 | return entry | ||
| 454 | } | 476 | } | ||
| 455 | 477 | ||||
| 478 | + | // attentionSubscription publishes only routes served by the active route's | |||
| 479 | + | // daemon. Other route origins remain private to their owning daemon. | |||
| 480 | + | func (l *routeLedger) attentionSubscription() ports.RouteAttentionSubscription { | |||
| 481 | + | l.mu.RLock() | |||
| 482 | + | defer l.mu.RUnlock() | |||
| 483 | + | ||||
| 484 | + | activeIndex := l.indexByIdentityLocked(l.active) | |||
| 485 | + | if activeIndex < 0 { | |||
| 486 | + | return ports.RouteAttentionSubscription{} | |||
| 487 | + | } | |||
| 488 | + | active := l.entries[activeIndex] | |||
| 489 | + | subscription := ports.RouteAttentionSubscription{Targets: make([]ports.RouteAttentionTarget, 0, len(l.entries))} | |||
| 490 | + | for _, entry := range l.entries { | |||
| 491 | + | if entry.identity == active.identity || entry.origin != active.origin || entry.originKey != active.originKey { | |||
| 492 | + | continue | |||
| 493 | + | } | |||
| 494 | + | subscription.Targets = append(subscription.Targets, ports.RouteAttentionTarget{ | |||
| 495 | + | Ref: entry.identity.wire(), | |||
| 496 | + | Target: entry.target, | |||
| 497 | + | }) | |||
| 498 | + | } | |||
| 499 | + | return subscription | |||
| 500 | + | } | |||
| 501 | + | ||||
| 456 | func (l *routeLedger) snapshot() ports.RecentRouteSnapshot { | 502 | func (l *routeLedger) snapshot() ports.RecentRouteSnapshot { | ||
| 457 | l.mu.RLock() | 503 | l.mu.RLock() | ||
| 458 | defer l.mu.RUnlock() | 504 | defer l.mu.RUnlock() | ||
M internal/usecase/client/routes_test.go +53 -0
| @@ -109,6 +109,59 @@ func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) { | |||
| 109 | 109 | require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID) | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 112 | + | func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) { | |
| 113 | + | ledger := newRouteLedger() | |
| 114 | + | first := routeTestCandidate(0, ports.RouteOriginRemote) | |
| 115 | + | first.originKey = "host-a" | |
| 116 | + | firstIdentity, err := ledger.commit(first) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | other := routeTestCandidate(1, ports.RouteOriginLocal) | |
| 119 | + | _, err = ledger.commit(other) | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | otherRemote := routeTestCandidate(2, ports.RouteOriginRemote) | |
| 122 | + | otherRemote.originKey = "host-b" | |
| 123 | + | _, err = ledger.commit(otherRemote) | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | active := routeTestCandidate(3, ports.RouteOriginRemote) | |
| 126 | + | active.originKey = "host-a" | |
| 127 | + | _, err = ledger.commit(active) | |
| 128 | + | require.NoError(t, err) | |
| 129 | + | ||
| 130 | + | subscription := ledger.attentionSubscription() | |
| 131 | + | ||
| 132 | + | require.Equal(t, []ports.RouteAttentionTarget{{ | |
| 133 | + | Ref: firstIdentity.wire(), | |
| 134 | + | Target: first.target, | |
| 135 | + | }}, subscription.Targets) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestCommittedIdentityRenamesActiveRouteInPlace(t *testing.T) { | |
| 139 | + | ledger := newRouteLedger() | |
| 140 | + | original := routeTestCandidate(0, ports.RouteOriginLocal) | |
| 141 | + | original.target = ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "0"} | |
| 142 | + | original.presentation.name = "0" | |
| 143 | + | original.request.SessionName = "0" | |
| 144 | + | identity, err := ledger.commit(original) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | before := ledger.snapshot() | |
| 147 | + | ||
| 148 | + | committed, err := ledger.commitCommittedIdentity(ports.CommittedRouteIdentity{ | |
| 149 | + | Target: ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "vps-infra"}, | |
| 150 | + | Ephemeral: false, | |
| 151 | + | }) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | ||
| 154 | + | snapshot := ledger.snapshot() | |
| 155 | + | require.Equal(t, identity, committed, "a rename must retain the route's process-local identity") | |
| 156 | + | require.Greater(t, snapshot.Generation, before.Generation, "a rename invalidates prior route snapshots") | |
| 157 | + | require.Empty(t, snapshot.Entries, "the old ephemeral label must not remain in route history") | |
| 158 | + | active, ok := ledger.lookup(snapshot.Active) | |
| 159 | + | require.True(t, ok) | |
| 160 | + | require.Equal(t, "vps-infra", active.presentation.name) | |
| 161 | + | require.Equal(t, "vps-infra", active.target.SessionName) | |
| 162 | + | require.False(t, active.presentation.ephemeral) | |
| 163 | + | } | |
| 164 | + | ||
| 112 | 165 | func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) { | |
| 113 | 166 | ledger := newRouteLedger() | |
| 114 | 167 | home := routeTestCandidate(1, ports.RouteOriginLocal) | |
| @@ -109,6 +109,59 @@ func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) { | |||||
| 109 | require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID) | 109 | require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID) | ||
| 110 | } | 110 | } | ||
| 111 | 111 | ||||
| 112 | + | func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) { | |||
| 113 | + | ledger := newRouteLedger() | |||
| 114 | + | first := routeTestCandidate(0, ports.RouteOriginRemote) | |||
| 115 | + | first.originKey = "host-a" | |||
| 116 | + | firstIdentity, err := ledger.commit(first) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | other := routeTestCandidate(1, ports.RouteOriginLocal) | |||
| 119 | + | _, err = ledger.commit(other) | |||
| 120 | + | require.NoError(t, err) | |||
| 121 | + | otherRemote := routeTestCandidate(2, ports.RouteOriginRemote) | |||
| 122 | + | otherRemote.originKey = "host-b" | |||
| 123 | + | _, err = ledger.commit(otherRemote) | |||
| 124 | + | require.NoError(t, err) | |||
| 125 | + | active := routeTestCandidate(3, ports.RouteOriginRemote) | |||
| 126 | + | active.originKey = "host-a" | |||
| 127 | + | _, err = ledger.commit(active) | |||
| 128 | + | require.NoError(t, err) | |||
| 129 | + | ||||
| 130 | + | subscription := ledger.attentionSubscription() | |||
| 131 | + | ||||
| 132 | + | require.Equal(t, []ports.RouteAttentionTarget{{ | |||
| 133 | + | Ref: firstIdentity.wire(), | |||
| 134 | + | Target: first.target, | |||
| 135 | + | }}, subscription.Targets) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestCommittedIdentityRenamesActiveRouteInPlace(t *testing.T) { | |||
| 139 | + | ledger := newRouteLedger() | |||
| 140 | + | original := routeTestCandidate(0, ports.RouteOriginLocal) | |||
| 141 | + | original.target = ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "0"} | |||
| 142 | + | original.presentation.name = "0" | |||
| 143 | + | original.request.SessionName = "0" | |||
| 144 | + | identity, err := ledger.commit(original) | |||
| 145 | + | require.NoError(t, err) | |||
| 146 | + | before := ledger.snapshot() | |||
| 147 | + | ||||
| 148 | + | committed, err := ledger.commitCommittedIdentity(ports.CommittedRouteIdentity{ | |||
| 149 | + | Target: ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "vps-infra"}, | |||
| 150 | + | Ephemeral: false, | |||
| 151 | + | }) | |||
| 152 | + | require.NoError(t, err) | |||
| 153 | + | ||||
| 154 | + | snapshot := ledger.snapshot() | |||
| 155 | + | require.Equal(t, identity, committed, "a rename must retain the route's process-local identity") | |||
| 156 | + | require.Greater(t, snapshot.Generation, before.Generation, "a rename invalidates prior route snapshots") | |||
| 157 | + | require.Empty(t, snapshot.Entries, "the old ephemeral label must not remain in route history") | |||
| 158 | + | active, ok := ledger.lookup(snapshot.Active) | |||
| 159 | + | require.True(t, ok) | |||
| 160 | + | require.Equal(t, "vps-infra", active.presentation.name) | |||
| 161 | + | require.Equal(t, "vps-infra", active.target.SessionName) | |||
| 162 | + | require.False(t, active.presentation.ephemeral) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 112 | func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) { | 165 | func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) { | ||
| 113 | ledger := newRouteLedger() | 166 | ledger := newRouteLedger() | ||
| 114 | home := routeTestCandidate(1, ports.RouteOriginLocal) | 167 | home := routeTestCandidate(1, ports.RouteOriginLocal) | ||
M internal/usecase/daemon/client.go +55 -15
| @@ -85,20 +85,22 @@ type attachedClient struct { | |||
| 85 | 85 | // view is attachment-local navigation state. It is never inferred from a | |
| 86 | 86 | // session-wide active tab, so multiple attachments can observe different | |
| 87 | 87 | // tabs and panes without changing shared session ownership. | |
| 88 | - | viewMu sync.Mutex | |
| 89 | - | view attachmentView | |
| 90 | - | sess Guarded[*session] | |
| 91 | - | mouseScan mouse.Scanner | |
| 92 | - | themeMu sync.Mutex | |
| 93 | - | clientTheme themeui.Theme | |
| 94 | - | appliedTheme appliedTheme | |
| 95 | - | lastCursor cursorOut | |
| 96 | - | lastRoutePosition ports.RoutePosition | |
| 97 | - | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 98 | - | linkMu sync.Mutex | |
| 99 | - | sendMu sync.Mutex | |
| 100 | - | routeMu sync.RWMutex | |
| 101 | - | routeSnapshot ports.RecentRouteSnapshot | |
| 88 | + | viewMu sync.Mutex | |
| 89 | + | view attachmentView | |
| 90 | + | sess Guarded[*session] | |
| 91 | + | mouseScan mouse.Scanner | |
| 92 | + | themeMu sync.Mutex | |
| 93 | + | clientTheme themeui.Theme | |
| 94 | + | appliedTheme appliedTheme | |
| 95 | + | lastCursor cursorOut | |
| 96 | + | lastRoutePosition ports.RoutePosition | |
| 97 | + | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 98 | + | linkMu sync.Mutex | |
| 99 | + | sendMu sync.Mutex | |
| 100 | + | routeMu sync.RWMutex | |
| 101 | + | routeSnapshot ports.RecentRouteSnapshot | |
| 102 | + | pendingRouteIdentity bool | |
| 103 | + | routeAttentionSubscription ports.RouteAttentionSubscription | |
| 102 | 104 | // routeCreatedSession marks a session created by this attachment's route. | |
| 103 | 105 | // A handshake that never commits Welcome must tear down that exact empty | |
| 104 | 106 | // session, while an attachment routed to an existing session must not. | |
| @@ -351,11 +353,31 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool { | |||
| 351 | 353 | return tr != nil && ac.transportIs(tr) | |
| 352 | 354 | } | |
| 353 | 355 | ||
| 354 | - | func (ac *attachedClient) setRouteSnapshot(snapshot ports.RecentRouteSnapshot) { | |
| 356 | + | // setRouteSnapshot records a client-owned route view and reports whether a | |
| 357 | + | // rename identity was deferred until that first usable publication. | |
| 358 | + | func (ac *attachedClient) setRouteSnapshot(snapshot ports.RecentRouteSnapshot) bool { | |
| 355 | 359 | snapshot.Entries = append([]ports.RecentRouteEntry(nil), snapshot.Entries...) | |
| 356 | 360 | ac.routeMu.Lock() | |
| 357 | 361 | ac.routeSnapshot = snapshot | |
| 362 | + | replayPendingIdentity := snapshot.Generation != 0 && ac.pendingRouteIdentity | |
| 363 | + | if replayPendingIdentity { | |
| 364 | + | ac.pendingRouteIdentity = false | |
| 365 | + | } | |
| 358 | 366 | ac.routeMu.Unlock() | |
| 367 | + | return replayPendingIdentity | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | // deferRouteIdentityUntilSnapshot records a rename that happens before the | |
| 371 | + | // client has published its route ledger. It returns false once a snapshot is | |
| 372 | + | // already available and the identity may be sent immediately. | |
| 373 | + | func (ac *attachedClient) deferRouteIdentityUntilSnapshot() bool { | |
| 374 | + | ac.routeMu.Lock() | |
| 375 | + | defer ac.routeMu.Unlock() | |
| 376 | + | if ac.routeSnapshot.Generation != 0 { | |
| 377 | + | return false | |
| 378 | + | } | |
| 379 | + | ac.pendingRouteIdentity = true | |
| 380 | + | return true | |
| 359 | 381 | } | |
| 360 | 382 | ||
| 361 | 383 | func (ac *attachedClient) routeSnapshotCopy() ports.RecentRouteSnapshot { | |
| @@ -366,6 +388,24 @@ func (ac *attachedClient) routeSnapshotCopy() ports.RecentRouteSnapshot { | |||
| 366 | 388 | return snapshot | |
| 367 | 389 | } | |
| 368 | 390 | ||
| 391 | + | func (ac *attachedClient) setRouteAttentionSubscription(subscription ports.RouteAttentionSubscription) { | |
| 392 | + | subscription.Targets = append([]ports.RouteAttentionTarget(nil), subscription.Targets...) | |
| 393 | + | ac.routeMu.Lock() | |
| 394 | + | ac.routeAttentionSubscription = subscription | |
| 395 | + | ac.routeMu.Unlock() | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func (ac *attachedClient) routeAttentionTarget(ref ports.RouteRef) (ports.ExactSessionTarget, bool) { | |
| 399 | + | ac.routeMu.RLock() | |
| 400 | + | defer ac.routeMu.RUnlock() | |
| 401 | + | for _, target := range ac.routeAttentionSubscription.Targets { | |
| 402 | + | if target.Ref == ref { | |
| 403 | + | return target.Target, true | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | return ports.ExactSessionTarget{}, false | |
| 407 | + | } | |
| 408 | + | ||
| 369 | 409 | func (ac *attachedClient) ackOutputState(epoch, state uint64) { | |
| 370 | 410 | ac.sendMu.Lock() | |
| 371 | 411 | defer ac.sendMu.Unlock() | |
| @@ -85,20 +85,22 @@ type attachedClient struct { | |||||
| 85 | // view is attachment-local navigation state. It is never inferred from a | 85 | // view is attachment-local navigation state. It is never inferred from a | ||
| 86 | // session-wide active tab, so multiple attachments can observe different | 86 | // session-wide active tab, so multiple attachments can observe different | ||
| 87 | // tabs and panes without changing shared session ownership. | 87 | // tabs and panes without changing shared session ownership. | ||
| 88 | - | viewMu sync.Mutex | 88 | + | viewMu sync.Mutex |
| 89 | - | view attachmentView | 89 | + | view attachmentView |
| 90 | - | sess Guarded[*session] | 90 | + | sess Guarded[*session] |
| 91 | - | mouseScan mouse.Scanner | 91 | + | mouseScan mouse.Scanner |
| 92 | - | themeMu sync.Mutex | 92 | + | themeMu sync.Mutex |
| 93 | - | clientTheme themeui.Theme | 93 | + | clientTheme themeui.Theme |
| 94 | - | appliedTheme appliedTheme | 94 | + | appliedTheme appliedTheme |
| 95 | - | lastCursor cursorOut | 95 | + | lastCursor cursorOut |
| 96 | - | lastRoutePosition ports.RoutePosition | 96 | + | lastRoutePosition ports.RoutePosition |
| 97 | - | renderStages renderStageHooks // optional render and handoff observability hooks | 97 | + | renderStages renderStageHooks // optional render and handoff observability hooks |
| 98 | - | linkMu sync.Mutex | 98 | + | linkMu sync.Mutex |
| 99 | - | sendMu sync.Mutex | 99 | + | sendMu sync.Mutex |
| 100 | - | routeMu sync.RWMutex | 100 | + | routeMu sync.RWMutex |
| 101 | - | routeSnapshot ports.RecentRouteSnapshot | 101 | + | routeSnapshot ports.RecentRouteSnapshot |
| 102 | + | pendingRouteIdentity bool | |||
| 103 | + | routeAttentionSubscription ports.RouteAttentionSubscription | |||
| 102 | // routeCreatedSession marks a session created by this attachment's route. | 104 | // routeCreatedSession marks a session created by this attachment's route. | ||
| 103 | // A handshake that never commits Welcome must tear down that exact empty | 105 | // A handshake that never commits Welcome must tear down that exact empty | ||
| 104 | // session, while an attachment routed to an existing session must not. | 106 | // session, while an attachment routed to an existing session must not. | ||
| @@ -351,11 +353,31 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool { | |||||
| 351 | return tr != nil && ac.transportIs(tr) | 353 | return tr != nil && ac.transportIs(tr) | ||
| 352 | } | 354 | } | ||
| 353 | 355 | ||||
| 354 | - | func (ac *attachedClient) setRouteSnapshot(snapshot ports.RecentRouteSnapshot) { | 356 | + | // setRouteSnapshot records a client-owned route view and reports whether a |
| 357 | + | // rename identity was deferred until that first usable publication. | |||
| 358 | + | func (ac *attachedClient) setRouteSnapshot(snapshot ports.RecentRouteSnapshot) bool { | |||
| 355 | snapshot.Entries = append([]ports.RecentRouteEntry(nil), snapshot.Entries...) | 359 | snapshot.Entries = append([]ports.RecentRouteEntry(nil), snapshot.Entries...) | ||
| 356 | ac.routeMu.Lock() | 360 | ac.routeMu.Lock() | ||
| 357 | ac.routeSnapshot = snapshot | 361 | ac.routeSnapshot = snapshot | ||
| 362 | + | replayPendingIdentity := snapshot.Generation != 0 && ac.pendingRouteIdentity | |||
| 363 | + | if replayPendingIdentity { | |||
| 364 | + | ac.pendingRouteIdentity = false | |||
| 365 | + | } | |||
| 358 | ac.routeMu.Unlock() | 366 | ac.routeMu.Unlock() | ||
| 367 | + | return replayPendingIdentity | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | // deferRouteIdentityUntilSnapshot records a rename that happens before the | |||
| 371 | + | // client has published its route ledger. It returns false once a snapshot is | |||
| 372 | + | // already available and the identity may be sent immediately. | |||
| 373 | + | func (ac *attachedClient) deferRouteIdentityUntilSnapshot() bool { | |||
| 374 | + | ac.routeMu.Lock() | |||
| 375 | + | defer ac.routeMu.Unlock() | |||
| 376 | + | if ac.routeSnapshot.Generation != 0 { | |||
| 377 | + | return false | |||
| 378 | + | } | |||
| 379 | + | ac.pendingRouteIdentity = true | |||
| 380 | + | return true | |||
| 359 | } | 381 | } | ||
| 360 | 382 | ||||
| 361 | func (ac *attachedClient) routeSnapshotCopy() ports.RecentRouteSnapshot { | 383 | func (ac *attachedClient) routeSnapshotCopy() ports.RecentRouteSnapshot { | ||
| @@ -366,6 +388,24 @@ func (ac *attachedClient) routeSnapshotCopy() ports.RecentRouteSnapshot { | |||||
| 366 | return snapshot | 388 | return snapshot | ||
| 367 | } | 389 | } | ||
| 368 | 390 | ||||
| 391 | + | func (ac *attachedClient) setRouteAttentionSubscription(subscription ports.RouteAttentionSubscription) { | |||
| 392 | + | subscription.Targets = append([]ports.RouteAttentionTarget(nil), subscription.Targets...) | |||
| 393 | + | ac.routeMu.Lock() | |||
| 394 | + | ac.routeAttentionSubscription = subscription | |||
| 395 | + | ac.routeMu.Unlock() | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | func (ac *attachedClient) routeAttentionTarget(ref ports.RouteRef) (ports.ExactSessionTarget, bool) { | |||
| 399 | + | ac.routeMu.RLock() | |||
| 400 | + | defer ac.routeMu.RUnlock() | |||
| 401 | + | for _, target := range ac.routeAttentionSubscription.Targets { | |||
| 402 | + | if target.Ref == ref { | |||
| 403 | + | return target.Target, true | |||
| 404 | + | } | |||
| 405 | + | } | |||
| 406 | + | return ports.ExactSessionTarget{}, false | |||
| 407 | + | } | |||
| 408 | + | ||||
| 369 | func (ac *attachedClient) ackOutputState(epoch, state uint64) { | 409 | func (ac *attachedClient) ackOutputState(epoch, state uint64) { | ||
| 370 | ac.sendMu.Lock() | 410 | ac.sendMu.Lock() | ||
| 371 | defer ac.sendMu.Unlock() | 411 | defer ac.sendMu.Unlock() | ||
M internal/usecase/daemon/client_frame_routing.go +16 -3
| @@ -128,11 +128,24 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||
| 128 | 128 | } | |
| 129 | 129 | case ports.MsgRecentRouteSnapshot: | |
| 130 | 130 | snapshot, derr := ports.UnmarshalRecentRouteSnapshot(f.Payload) | |
| 131 | - | if derr == nil { | |
| 132 | - | token.ac.setRouteSnapshot(snapshot) | |
| 133 | - | } else { | |
| 131 | + | if derr != nil { | |
| 134 | 132 | d.log.Warn("malformed recent route snapshot", "err", derr) | |
| 133 | + | break | |
| 134 | + | } | |
| 135 | + | if !token.ac.setRouteSnapshot(snapshot) { | |
| 136 | + | break | |
| 137 | + | } | |
| 138 | + | if err := d.sendCommittedRouteIdentityForAttachment(token); err != nil { | |
| 139 | + | d.detachOnAttachmentSendError(token, token.transport.transport) | |
| 140 | + | } | |
| 141 | + | case ports.MsgRouteAttentionSubscription: | |
| 142 | + | subscription, derr := ports.UnmarshalRouteAttentionSubscription(f.Payload) | |
| 143 | + | if derr != nil { | |
| 144 | + | d.log.Warn("malformed route attention subscription", "err", derr) | |
| 145 | + | break | |
| 135 | 146 | } | |
| 147 | + | token.ac.setRouteAttentionSubscription(subscription) | |
| 148 | + | d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-attention") | |
| 136 | 149 | case ports.MsgRouteNavigationFailure: | |
| 137 | 150 | failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload) | |
| 138 | 151 | if derr != nil { | |
| @@ -128,11 +128,24 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||||
| 128 | } | 128 | } | ||
| 129 | case ports.MsgRecentRouteSnapshot: | 129 | case ports.MsgRecentRouteSnapshot: | ||
| 130 | snapshot, derr := ports.UnmarshalRecentRouteSnapshot(f.Payload) | 130 | snapshot, derr := ports.UnmarshalRecentRouteSnapshot(f.Payload) | ||
| 131 | - | if derr == nil { | 131 | + | if derr != nil { |
| 132 | - | token.ac.setRouteSnapshot(snapshot) | |||
| 133 | - | } else { | |||
| 134 | d.log.Warn("malformed recent route snapshot", "err", derr) | 132 | d.log.Warn("malformed recent route snapshot", "err", derr) | ||
| 133 | + | break | |||
| 134 | + | } | |||
| 135 | + | if !token.ac.setRouteSnapshot(snapshot) { | |||
| 136 | + | break | |||
| 137 | + | } | |||
| 138 | + | if err := d.sendCommittedRouteIdentityForAttachment(token); err != nil { | |||
| 139 | + | d.detachOnAttachmentSendError(token, token.transport.transport) | |||
| 140 | + | } | |||
| 141 | + | case ports.MsgRouteAttentionSubscription: | |||
| 142 | + | subscription, derr := ports.UnmarshalRouteAttentionSubscription(f.Payload) | |||
| 143 | + | if derr != nil { | |||
| 144 | + | d.log.Warn("malformed route attention subscription", "err", derr) | |||
| 145 | + | break | |||
| 135 | } | 146 | } | ||
| 147 | + | token.ac.setRouteAttentionSubscription(subscription) | |||
| 148 | + | d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-attention") | |||
| 136 | case ports.MsgRouteNavigationFailure: | 149 | case ports.MsgRouteNavigationFailure: | ||
| 137 | failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload) | 150 | failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload) | ||
| 138 | if derr != nil { | 151 | if derr != nil { | ||
M internal/usecase/daemon/recent_route_snapshot.go +37 -0
| @@ -26,3 +26,40 @@ func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) [] | |||
| 26 | 26 | func formatRecentRouteSnapshot(snapshot ports.RecentRouteSnapshot) []recentRouteDisplay { | |
| 27 | 27 | return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot)) | |
| 28 | 28 | } | |
| 29 | + | ||
| 30 | + | // formatRecentRouteSnapshotForAttachment resolves attention against this | |
| 31 | + | // daemon's live sessions. The client retains route ordering and presentation; | |
| 32 | + | // it subscribes only the exact routes this daemon serves. | |
| 33 | + | func (d *Daemon) formatRecentRouteSnapshotForAttachment(ac *attachedClient, snapshot ports.RecentRouteSnapshot) []recentRouteDisplay { | |
| 34 | + | presentations := recentRoutePresentationsFromSnapshot(snapshot) | |
| 35 | + | if d == nil || ac == nil { | |
| 36 | + | return formatRecentRoutePresentations(presentations) | |
| 37 | + | } | |
| 38 | + | for i, entry := range snapshot.Entries { | |
| 39 | + | target, ok := ac.routeAttentionTarget(ports.RouteRef{Key: entry.Key, Generation: entry.Generation}) | |
| 40 | + | if ok { | |
| 41 | + | presentations[i].attention = d.routeHasAttention(target) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | return formatRecentRoutePresentations(presentations) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (d *Daemon) routeHasAttention(target ports.ExactSessionTarget) bool { | |
| 48 | + | d.mu.Lock() | |
| 49 | + | sess := d.findByNameLocked(target.SessionName) | |
| 50 | + | d.mu.Unlock() | |
| 51 | + | if sess == nil { | |
| 52 | + | return false | |
| 53 | + | } | |
| 54 | + | sess.mu.Lock() | |
| 55 | + | defer sess.mu.Unlock() | |
| 56 | + | if sess.incarnation != target.LifecycleID { | |
| 57 | + | return false | |
| 58 | + | } | |
| 59 | + | for _, tb := range sess.tabs { | |
| 60 | + | if tb.attention { | |
| 61 | + | return true | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | return false | |
| 65 | + | } | |
| @@ -26,3 +26,40 @@ func recentRoutePresentationsFromSnapshot(snapshot ports.RecentRouteSnapshot) [] | |||||
| 26 | func formatRecentRouteSnapshot(snapshot ports.RecentRouteSnapshot) []recentRouteDisplay { | 26 | func formatRecentRouteSnapshot(snapshot ports.RecentRouteSnapshot) []recentRouteDisplay { | ||
| 27 | return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot)) | 27 | return formatRecentRoutePresentations(recentRoutePresentationsFromSnapshot(snapshot)) | ||
| 28 | } | 28 | } | ||
| 29 | + | ||||
| 30 | + | // formatRecentRouteSnapshotForAttachment resolves attention against this | |||
| 31 | + | // daemon's live sessions. The client retains route ordering and presentation; | |||
| 32 | + | // it subscribes only the exact routes this daemon serves. | |||
| 33 | + | func (d *Daemon) formatRecentRouteSnapshotForAttachment(ac *attachedClient, snapshot ports.RecentRouteSnapshot) []recentRouteDisplay { | |||
| 34 | + | presentations := recentRoutePresentationsFromSnapshot(snapshot) | |||
| 35 | + | if d == nil || ac == nil { | |||
| 36 | + | return formatRecentRoutePresentations(presentations) | |||
| 37 | + | } | |||
| 38 | + | for i, entry := range snapshot.Entries { | |||
| 39 | + | target, ok := ac.routeAttentionTarget(ports.RouteRef{Key: entry.Key, Generation: entry.Generation}) | |||
| 40 | + | if ok { | |||
| 41 | + | presentations[i].attention = d.routeHasAttention(target) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | return formatRecentRoutePresentations(presentations) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (d *Daemon) routeHasAttention(target ports.ExactSessionTarget) bool { | |||
| 48 | + | d.mu.Lock() | |||
| 49 | + | sess := d.findByNameLocked(target.SessionName) | |||
| 50 | + | d.mu.Unlock() | |||
| 51 | + | if sess == nil { | |||
| 52 | + | return false | |||
| 53 | + | } | |||
| 54 | + | sess.mu.Lock() | |||
| 55 | + | defer sess.mu.Unlock() | |||
| 56 | + | if sess.incarnation != target.LifecycleID { | |||
| 57 | + | return false | |||
| 58 | + | } | |||
| 59 | + | for _, tb := range sess.tabs { | |||
| 60 | + | if tb.attention { | |||
| 61 | + | return true | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | return false | |||
| 65 | + | } | |||
M internal/usecase/daemon/recent_route_snapshot_test.go +38 -0
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 7 | "github.com/bnema/vev/internal/ports" | |
| 7 | 8 | "github.com/stretchr/testify/require" | |
| 8 | 9 | ) | |
| @@ -24,6 +25,43 @@ func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) { | |||
| 24 | 25 | require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name}) | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 28 | + | func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) { | |
| 29 | + | for _, tt := range []struct { | |
| 30 | + | name string | |
| 31 | + | targetID domain.SessionLifecycleID | |
| 32 | + | attention bool | |
| 33 | + | }{ | |
| 34 | + | {name: "matching lifecycle", targetID: domain.SessionLifecycleID{9}, attention: true}, | |
| 35 | + | {name: "stale lifecycle", targetID: domain.SessionLifecycleID{8}, attention: false}, | |
| 36 | + | } { | |
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |
| 38 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 39 | + | sess.mu.Lock() | |
| 40 | + | sess.incarnation = domain.SessionLifecycleID{9} | |
| 41 | + | sess.tabs[0].attention = true | |
| 42 | + | sess.mu.Unlock() | |
| 43 | + | ref := ports.RouteRef{Key: 2, Generation: 1} | |
| 44 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 45 | + | 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}}, | |
| 48 | + | }) | |
| 49 | + | ac.setRouteAttentionSubscription(ports.RouteAttentionSubscription{Targets: []ports.RouteAttentionTarget{{ | |
| 50 | + | Ref: ref, | |
| 51 | + | Target: ports.ExactSessionTarget{ | |
| 52 | + | LifecycleID: tt.targetID, | |
| 53 | + | SessionName: sess.name, | |
| 54 | + | }, | |
| 55 | + | }}}) | |
| 56 | + | ||
| 57 | + | state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, ports.RecentRouteSnapshot{}) | |
| 58 | + | ||
| 59 | + | require.Len(t, state.mru, 1) | |
| 60 | + | require.Equal(t, tt.attention, state.mru[0].attention) | |
| 61 | + | }) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 27 | 65 | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | |
| 28 | 66 | _, _, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 29 | 67 | entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}} | |
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 6 | "github.com/bnema/vev/internal/ports" | 7 | "github.com/bnema/vev/internal/ports" | ||
| 7 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 8 | ) | 9 | ) | ||
| @@ -24,6 +25,43 @@ func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) { | |||||
| 24 | require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name}) | 25 | require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name}) | ||
| 25 | } | 26 | } | ||
| 26 | 27 | ||||
| 28 | + | func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) { | |||
| 29 | + | for _, tt := range []struct { | |||
| 30 | + | name string | |||
| 31 | + | targetID domain.SessionLifecycleID | |||
| 32 | + | attention bool | |||
| 33 | + | }{ | |||
| 34 | + | {name: "matching lifecycle", targetID: domain.SessionLifecycleID{9}, attention: true}, | |||
| 35 | + | {name: "stale lifecycle", targetID: domain.SessionLifecycleID{8}, attention: false}, | |||
| 36 | + | } { | |||
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 38 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 39 | + | sess.mu.Lock() | |||
| 40 | + | sess.incarnation = domain.SessionLifecycleID{9} | |||
| 41 | + | sess.tabs[0].attention = true | |||
| 42 | + | sess.mu.Unlock() | |||
| 43 | + | ref := ports.RouteRef{Key: 2, Generation: 1} | |||
| 44 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |||
| 45 | + | 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}}, | |||
| 48 | + | }) | |||
| 49 | + | ac.setRouteAttentionSubscription(ports.RouteAttentionSubscription{Targets: []ports.RouteAttentionTarget{{ | |||
| 50 | + | Ref: ref, | |||
| 51 | + | Target: ports.ExactSessionTarget{ | |||
| 52 | + | LifecycleID: tt.targetID, | |||
| 53 | + | SessionName: sess.name, | |||
| 54 | + | }, | |||
| 55 | + | }}}) | |||
| 56 | + | ||||
| 57 | + | state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, ports.RecentRouteSnapshot{}) | |||
| 58 | + | ||||
| 59 | + | require.Len(t, state.mru, 1) | |||
| 60 | + | require.Equal(t, tt.attention, state.mru[0].attention) | |||
| 61 | + | }) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 27 | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | 65 | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | ||
| 28 | _, _, ac, _ := newManualSessionWithPTYs(t, nil) | 66 | _, _, ac, _ := newManualSessionWithPTYs(t, nil) | ||
| 29 | entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}} | 67 | entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}} | ||
M internal/usecase/daemon/session.go +27 -0
| @@ -915,9 +915,36 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 915 | 915 | sess.snapshotMu.Unlock() | |
| 916 | 916 | } | |
| 917 | 917 | markSnapshotDirty(sess) | |
| 918 | + | d.publishRenamedSessionRouteIdentity(sess) | |
| 918 | 919 | return nil | |
| 919 | 920 | } | |
| 920 | 921 | ||
| 922 | + | // publishRenamedSessionRouteIdentity updates each ready client's exact route | |
| 923 | + | // target after a session rename. A renamed session retains its lifecycle but | |
| 924 | + | // its old name is no longer attachable, so clients must replace—not retain—the | |
| 925 | + | // prior route record. | |
| 926 | + | func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) { | |
| 927 | + | for _, ac := range sess.snapshotAttachments() { | |
| 928 | + | if ac.deferRouteIdentityUntilSnapshot() { | |
| 929 | + | continue | |
| 930 | + | } | |
| 931 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 932 | + | if token.ac == nil { | |
| 933 | + | continue | |
| 934 | + | } | |
| 935 | + | effect, admitted := ac.beginAttachmentEffect(token) | |
| 936 | + | if !admitted { | |
| 937 | + | continue | |
| 938 | + | } | |
| 939 | + | token = effect.connectionToken() | |
| 940 | + | err := d.sendCommittedRouteIdentityForAttachment(token) | |
| 941 | + | effect.End() | |
| 942 | + | if err != nil { | |
| 943 | + | d.detachOnAttachmentSendError(token, token.transport.transport) | |
| 944 | + | } | |
| 945 | + | } | |
| 946 | + | } | |
| 947 | + | ||
| 921 | 948 | func (d *Daemon) renameTab(sess *session, tb *tab, name string) error { | |
| 922 | 949 | if tb == nil { | |
| 923 | 950 | return errors.New("tab required") | |
| @@ -915,9 +915,36 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 915 | sess.snapshotMu.Unlock() | 915 | sess.snapshotMu.Unlock() | ||
| 916 | } | 916 | } | ||
| 917 | markSnapshotDirty(sess) | 917 | markSnapshotDirty(sess) | ||
| 918 | + | d.publishRenamedSessionRouteIdentity(sess) | |||
| 918 | return nil | 919 | return nil | ||
| 919 | } | 920 | } | ||
| 920 | 921 | ||||
| 922 | + | // publishRenamedSessionRouteIdentity updates each ready client's exact route | |||
| 923 | + | // target after a session rename. A renamed session retains its lifecycle but | |||
| 924 | + | // its old name is no longer attachable, so clients must replace—not retain—the | |||
| 925 | + | // prior route record. | |||
| 926 | + | func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) { | |||
| 927 | + | for _, ac := range sess.snapshotAttachments() { | |||
| 928 | + | if ac.deferRouteIdentityUntilSnapshot() { | |||
| 929 | + | continue | |||
| 930 | + | } | |||
| 931 | + | token := sess.attachmentToken(ac, ac.transport()) | |||
| 932 | + | if token.ac == nil { | |||
| 933 | + | continue | |||
| 934 | + | } | |||
| 935 | + | effect, admitted := ac.beginAttachmentEffect(token) | |||
| 936 | + | if !admitted { | |||
| 937 | + | continue | |||
| 938 | + | } | |||
| 939 | + | token = effect.connectionToken() | |||
| 940 | + | err := d.sendCommittedRouteIdentityForAttachment(token) | |||
| 941 | + | effect.End() | |||
| 942 | + | if err != nil { | |||
| 943 | + | d.detachOnAttachmentSendError(token, token.transport.transport) | |||
| 944 | + | } | |||
| 945 | + | } | |||
| 946 | + | } | |||
| 947 | + | ||||
| 921 | func (d *Daemon) renameTab(sess *session, tb *tab, name string) error { | 948 | func (d *Daemon) renameTab(sess *session, tb *tab, name string) error { | ||
| 922 | if tb == nil { | 949 | if tb == nil { | ||
| 923 | return errors.New("tab required") | 950 | return errors.New("tab required") | ||
M internal/usecase/daemon/session_rename_purge_test.go +56 -0
| @@ -6,9 +6,65 @@ import ( | |||
| 6 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 13 | + | func TestRenamePublishesCommittedRouteIdentityToAttachedClients(t *testing.T) { | |
| 14 | + | d, sess, ac, sends := newManualSessionWithPTYs(t) | |
| 15 | + | sess.mu.Lock() | |
| 16 | + | sess.name = "0" | |
| 17 | + | sess.ephemeral = true | |
| 18 | + | sess.incarnation = domain.IncarnationID{1} | |
| 19 | + | sess.mu.Unlock() | |
| 20 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 21 | + | ac.publishAttachmentCapability(token) | |
| 22 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1}) | |
| 23 | + | ||
| 24 | + | require.NoError(t, d.renameSession(sess, "vps-infra")) | |
| 25 | + | ||
| 26 | + | frame := awaitFrame(t, sends, ports.MsgCommittedRouteIdentity) | |
| 27 | + | identity, err := ports.UnmarshalCommittedRouteIdentity(frame.Payload) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | require.Equal(t, ports.ExactSessionTarget{ | |
| 30 | + | LifecycleID: domain.IncarnationID{1}, | |
| 31 | + | SessionName: "vps-infra", | |
| 32 | + | }, identity.Target) | |
| 33 | + | require.False(t, identity.Ephemeral) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T) { | |
| 37 | + | d, sess, ac, sends := newManualSessionWithPTYs(t) | |
| 38 | + | sess.mu.Lock() | |
| 39 | + | sess.name = "0" | |
| 40 | + | sess.ephemeral = true | |
| 41 | + | sess.incarnation = domain.IncarnationID{1} | |
| 42 | + | sess.mu.Unlock() | |
| 43 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 44 | + | ac.publishAttachmentCapability(token) | |
| 45 | + | ||
| 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") | |
| 48 | + | ||
| 49 | + | snapshot := ports.RecentRouteSnapshot{ | |
| 50 | + | Generation: 1, | |
| 51 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |
| 52 | + | Home: ports.RouteRef{Key: 1, Generation: 1}, | |
| 53 | + | } | |
| 54 | + | payload, err := ports.MarshalRecentRouteSnapshot(snapshot) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload})) | |
| 57 | + | ||
| 58 | + | frame := awaitFrame(t, sends, ports.MsgCommittedRouteIdentity) | |
| 59 | + | identity, err := ports.UnmarshalCommittedRouteIdentity(frame.Payload) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | require.Equal(t, ports.ExactSessionTarget{ | |
| 62 | + | LifecycleID: domain.IncarnationID{1}, | |
| 63 | + | SessionName: "vps-infra", | |
| 64 | + | }, identity.Target) | |
| 65 | + | require.False(t, identity.Ephemeral) | |
| 66 | + | } | |
| 67 | + | ||
| 12 | 68 | func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) { | |
| 13 | 69 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 14 | 70 | repository := &retryablePurgeRepository{} | |
| @@ -6,9 +6,65 @@ import ( | |||||
| 6 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 7 | 7 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 10 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 13 | + | func TestRenamePublishesCommittedRouteIdentityToAttachedClients(t *testing.T) { | |||
| 14 | + | d, sess, ac, sends := newManualSessionWithPTYs(t) | |||
| 15 | + | sess.mu.Lock() | |||
| 16 | + | sess.name = "0" | |||
| 17 | + | sess.ephemeral = true | |||
| 18 | + | sess.incarnation = domain.IncarnationID{1} | |||
| 19 | + | sess.mu.Unlock() | |||
| 20 | + | token := sess.attachmentToken(ac, ac.transport()) | |||
| 21 | + | ac.publishAttachmentCapability(token) | |||
| 22 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1}) | |||
| 23 | + | ||||
| 24 | + | require.NoError(t, d.renameSession(sess, "vps-infra")) | |||
| 25 | + | ||||
| 26 | + | frame := awaitFrame(t, sends, ports.MsgCommittedRouteIdentity) | |||
| 27 | + | identity, err := ports.UnmarshalCommittedRouteIdentity(frame.Payload) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | require.Equal(t, ports.ExactSessionTarget{ | |||
| 30 | + | LifecycleID: domain.IncarnationID{1}, | |||
| 31 | + | SessionName: "vps-infra", | |||
| 32 | + | }, identity.Target) | |||
| 33 | + | require.False(t, identity.Ephemeral) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T) { | |||
| 37 | + | d, sess, ac, sends := newManualSessionWithPTYs(t) | |||
| 38 | + | sess.mu.Lock() | |||
| 39 | + | sess.name = "0" | |||
| 40 | + | sess.ephemeral = true | |||
| 41 | + | sess.incarnation = domain.IncarnationID{1} | |||
| 42 | + | sess.mu.Unlock() | |||
| 43 | + | token := sess.attachmentToken(ac, ac.transport()) | |||
| 44 | + | ac.publishAttachmentCapability(token) | |||
| 45 | + | ||||
| 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") | |||
| 48 | + | ||||
| 49 | + | snapshot := ports.RecentRouteSnapshot{ | |||
| 50 | + | Generation: 1, | |||
| 51 | + | Active: ports.RouteRef{Key: 1, Generation: 1}, | |||
| 52 | + | Home: ports.RouteRef{Key: 1, Generation: 1}, | |||
| 53 | + | } | |||
| 54 | + | payload, err := ports.MarshalRecentRouteSnapshot(snapshot) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload})) | |||
| 57 | + | ||||
| 58 | + | frame := awaitFrame(t, sends, ports.MsgCommittedRouteIdentity) | |||
| 59 | + | identity, err := ports.UnmarshalCommittedRouteIdentity(frame.Payload) | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | require.Equal(t, ports.ExactSessionTarget{ | |||
| 62 | + | LifecycleID: domain.IncarnationID{1}, | |||
| 63 | + | SessionName: "vps-infra", | |||
| 64 | + | }, identity.Target) | |||
| 65 | + | require.False(t, identity.Ephemeral) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 12 | func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) { | 68 | func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) { | ||
| 13 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 69 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 14 | repository := &retryablePurgeRepository{} | 70 | repository := &retryablePurgeRepository{} | ||
M internal/usecase/daemon/status.go +1 -1
| @@ -290,7 +290,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached | |||
| 290 | 290 | if ranked != nil { | |
| 291 | 291 | state.rankedRecent = ranked | |
| 292 | 292 | } else if routeSnapshot.Generation != 0 { | |
| 293 | - | state.mru = formatRecentRouteSnapshot(routeSnapshot) | |
| 293 | + | state.mru = d.formatRecentRouteSnapshotForAttachment(ac, routeSnapshot) | |
| 294 | 294 | } | |
| 295 | 295 | return state | |
| 296 | 296 | } | |
| @@ -290,7 +290,7 @@ func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attached | |||||
| 290 | if ranked != nil { | 290 | if ranked != nil { | ||
| 291 | state.rankedRecent = ranked | 291 | state.rankedRecent = ranked | ||
| 292 | } else if routeSnapshot.Generation != 0 { | 292 | } else if routeSnapshot.Generation != 0 { | ||
| 293 | - | state.mru = formatRecentRouteSnapshot(routeSnapshot) | 293 | + | state.mru = d.formatRecentRouteSnapshotForAttachment(ac, routeSnapshot) |
| 294 | } | 294 | } | ||
| 295 | return state | 295 | return state | ||
| 296 | } | 296 | } | ||