fix(client): normalize created remote routes (#242)
- commit
- 1fe51988d3397cfc19993eb77a0a91430ee2727d
- author
- bnema · · 2026-08-30 15:41 UTC
- committer
- GitHub · · 2026-08-30 15:41 UTC
- parent
- 0bf02ca
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
2 files · +73 -0
M internal/usecase/client/client_test.go +65 -0
| @@ -1262,6 +1262,71 @@ func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) { | |||
| 1262 | 1262 | } | |
| 1263 | 1263 | } | |
| 1264 | 1264 | ||
| 1265 | + | func TestRouteNavigationReturnsToCreatedRemoteSession(t *testing.T) { | |
| 1266 | + | term := newRunTerminal() | |
| 1267 | + | defer term.in.unblock() | |
| 1268 | + | ||
| 1269 | + | localLifecycle := domain.SessionLifecycleID{1} | |
| 1270 | + | remoteLifecycle := domain.SessionLifecycleID{2} | |
| 1271 | + | welcome := func(name string, lifecycle domain.SessionLifecycleID) wire.Frame { | |
| 1272 | + | return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{ | |
| 1273 | + | SessionID: name + "-id", SessionName: name, ResumeToken: 1, | |
| 1274 | + | Capabilities: protocol.CapabilityResume, | |
| 1275 | + | CommittedIdentity: &protocol.CommittedRouteIdentity{ | |
| 1276 | + | Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}, | |
| 1277 | + | }, | |
| 1278 | + | })) | |
| 1279 | + | } | |
| 1280 | + | createRemote := protocol.AttachTarget{ | |
| 1281 | + | RequestID: 1, Endpoint: "remote", Session: "created", Intent: protocol.IntentNew, | |
| 1282 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 1283 | + | } | |
| 1284 | + | local1 := &recordingTransport{recvs: []recvItem{ | |
| 1285 | + | {f: welcome("misc", localLifecycle)}, | |
| 1286 | + | {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(createRemote))}, | |
| 1287 | + | }} | |
| 1288 | + | remote1 := &recordingTransport{recvs: []recvItem{ | |
| 1289 | + | {f: welcome("created", remoteLifecycle)}, | |
| 1290 | + | {f: frameOf(wire.MsgNavigateRecentRoute, mustMarshalRouteAction(protocol.RouteNavigationAction{ | |
| 1291 | + | SnapshotGeneration: 2, Key: 1, Generation: 1, | |
| 1292 | + | }))}, | |
| 1293 | + | }} | |
| 1294 | + | local2 := &recordingTransport{recvs: []recvItem{ | |
| 1295 | + | {f: welcome("misc", localLifecycle)}, | |
| 1296 | + | {f: frameOf(wire.MsgNavigateRecentRoute, mustMarshalRouteAction(protocol.RouteNavigationAction{ | |
| 1297 | + | SnapshotGeneration: 3, Key: 2, Generation: 2, | |
| 1298 | + | }))}, | |
| 1299 | + | }} | |
| 1300 | + | remote2 := &recordingTransport{recvs: []recvItem{ | |
| 1301 | + | {f: welcome("created", remoteLifecycle)}, | |
| 1302 | + | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |
| 1303 | + | }} | |
| 1304 | + | localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2}} | |
| 1305 | + | remoteDialer := &sequenceDialer{trs: []wire.Transport{remote1, remote2}} | |
| 1306 | + | deps := testDependencies(localDialer, term, realClock{}, nil, nil) | |
| 1307 | + | deps.AttachHandoff = func(target protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) { | |
| 1308 | + | require.Equal(t, createRemote, target) | |
| 1309 | + | return remoteDialer, client.AttachRequest{ | |
| 1310 | + | Intent: protocol.IntentNew, SessionName: target.Session, Remote: true, | |
| 1311 | + | Origin: protocol.RouteOriginDiscovery, OriginKey: target.Endpoint, | |
| 1312 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |
| 1313 | + | }, nil | |
| 1314 | + | } | |
| 1315 | + | ||
| 1316 | + | err := runTestClient(context.Background(), deps, client.AttachRequest{ | |
| 1317 | + | Intent: protocol.IntentAttach, SessionName: "misc", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |
| 1318 | + | }) | |
| 1319 | + | require.NoError(t, err) | |
| 1320 | + | require.Equal(t, int32(2), remoteDialer.calls.Load()) | |
| 1321 | + | returnHello := helloFromSend(t, remote2) | |
| 1322 | + | require.Equal(t, protocol.IntentResume, returnHello.Intent) | |
| 1323 | + | require.Equal(t, "created", returnHello.Name) | |
| 1324 | + | require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "created"}, returnHello.ExactTarget) | |
| 1325 | + | require.Nil(t, returnHello.RemoteTarget) | |
| 1326 | + | require.Equal(t, protocol.EnvironmentPolicyDaemonOwned, returnHello.EnvironmentPolicy) | |
| 1327 | + | require.Equal(t, protocol.NavigationCapabilityHomePicker, returnHello.NavigationCapabilities) | |
| 1328 | + | } | |
| 1329 | + | ||
| 1265 | 1330 | func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) { | |
| 1266 | 1331 | term := newRunTerminal() | |
| 1267 | 1332 | defer term.in.unblock() | |
| @@ -1262,6 +1262,71 @@ func TestRemoteCreationFailuresRestoreSourceAndReportCorrelation(t *testing.T) { | |||||
| 1262 | } | 1262 | } | ||
| 1263 | } | 1263 | } | ||
| 1264 | 1264 | ||||
| 1265 | + | func TestRouteNavigationReturnsToCreatedRemoteSession(t *testing.T) { | |||
| 1266 | + | term := newRunTerminal() | |||
| 1267 | + | defer term.in.unblock() | |||
| 1268 | + | ||||
| 1269 | + | localLifecycle := domain.SessionLifecycleID{1} | |||
| 1270 | + | remoteLifecycle := domain.SessionLifecycleID{2} | |||
| 1271 | + | welcome := func(name string, lifecycle domain.SessionLifecycleID) wire.Frame { | |||
| 1272 | + | return frameOf(wire.MsgWelcome, wire.MarshalWelcome(protocol.Welcome{ | |||
| 1273 | + | SessionID: name + "-id", SessionName: name, ResumeToken: 1, | |||
| 1274 | + | Capabilities: protocol.CapabilityResume, | |||
| 1275 | + | CommittedIdentity: &protocol.CommittedRouteIdentity{ | |||
| 1276 | + | Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}, | |||
| 1277 | + | }, | |||
| 1278 | + | })) | |||
| 1279 | + | } | |||
| 1280 | + | createRemote := protocol.AttachTarget{ | |||
| 1281 | + | RequestID: 1, Endpoint: "remote", Session: "created", Intent: protocol.IntentNew, | |||
| 1282 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |||
| 1283 | + | } | |||
| 1284 | + | local1 := &recordingTransport{recvs: []recvItem{ | |||
| 1285 | + | {f: welcome("misc", localLifecycle)}, | |||
| 1286 | + | {f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(createRemote))}, | |||
| 1287 | + | }} | |||
| 1288 | + | remote1 := &recordingTransport{recvs: []recvItem{ | |||
| 1289 | + | {f: welcome("created", remoteLifecycle)}, | |||
| 1290 | + | {f: frameOf(wire.MsgNavigateRecentRoute, mustMarshalRouteAction(protocol.RouteNavigationAction{ | |||
| 1291 | + | SnapshotGeneration: 2, Key: 1, Generation: 1, | |||
| 1292 | + | }))}, | |||
| 1293 | + | }} | |||
| 1294 | + | local2 := &recordingTransport{recvs: []recvItem{ | |||
| 1295 | + | {f: welcome("misc", localLifecycle)}, | |||
| 1296 | + | {f: frameOf(wire.MsgNavigateRecentRoute, mustMarshalRouteAction(protocol.RouteNavigationAction{ | |||
| 1297 | + | SnapshotGeneration: 3, Key: 2, Generation: 2, | |||
| 1298 | + | }))}, | |||
| 1299 | + | }} | |||
| 1300 | + | remote2 := &recordingTransport{recvs: []recvItem{ | |||
| 1301 | + | {f: welcome("created", remoteLifecycle)}, | |||
| 1302 | + | {f: frameOf(wire.MsgDetached, wire.MarshalDetached(protocol.Detached{Reason: protocol.ReasonDetach}))}, | |||
| 1303 | + | }} | |||
| 1304 | + | localDialer := &sequenceDialer{trs: []wire.Transport{local1, local2}} | |||
| 1305 | + | remoteDialer := &sequenceDialer{trs: []wire.Transport{remote1, remote2}} | |||
| 1306 | + | deps := testDependencies(localDialer, term, realClock{}, nil, nil) | |||
| 1307 | + | deps.AttachHandoff = func(target protocol.AttachTarget) (ports.ClientDialer, client.AttachRequest, error) { | |||
| 1308 | + | require.Equal(t, createRemote, target) | |||
| 1309 | + | return remoteDialer, client.AttachRequest{ | |||
| 1310 | + | Intent: protocol.IntentNew, SessionName: target.Session, Remote: true, | |||
| 1311 | + | Origin: protocol.RouteOriginDiscovery, OriginKey: target.Endpoint, | |||
| 1312 | + | EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, | |||
| 1313 | + | }, nil | |||
| 1314 | + | } | |||
| 1315 | + | ||||
| 1316 | + | err := runTestClient(context.Background(), deps, client.AttachRequest{ | |||
| 1317 | + | Intent: protocol.IntentAttach, SessionName: "misc", Origin: protocol.RouteOriginLocal, OriginKey: "local", | |||
| 1318 | + | }) | |||
| 1319 | + | require.NoError(t, err) | |||
| 1320 | + | require.Equal(t, int32(2), remoteDialer.calls.Load()) | |||
| 1321 | + | returnHello := helloFromSend(t, remote2) | |||
| 1322 | + | require.Equal(t, protocol.IntentResume, returnHello.Intent) | |||
| 1323 | + | require.Equal(t, "created", returnHello.Name) | |||
| 1324 | + | require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "created"}, returnHello.ExactTarget) | |||
| 1325 | + | require.Nil(t, returnHello.RemoteTarget) | |||
| 1326 | + | require.Equal(t, protocol.EnvironmentPolicyDaemonOwned, returnHello.EnvironmentPolicy) | |||
| 1327 | + | require.Equal(t, protocol.NavigationCapabilityHomePicker, returnHello.NavigationCapabilities) | |||
| 1328 | + | } | |||
| 1329 | + | ||||
| 1265 | func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) { | 1330 | func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) { | ||
| 1266 | term := newRunTerminal() | 1331 | term := newRunTerminal() | ||
| 1267 | defer term.in.unblock() | 1332 | defer term.in.unblock() | ||
M internal/usecase/client/routes.go +8 -0
| @@ -116,6 +116,7 @@ func cloneRoutePosition(position *protocol.RoutePosition) *protocol.RoutePositio | |||
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | 118 | func routeCandidateForAttach(request AttachRequest, identity protocol.CommittedRouteIdentity, dialer ports.ClientDialer, resumeToken uint64) routeCandidate { | |
| 119 | + | created := request.Intent == protocol.IntentNew | |
| 119 | 120 | origin := normalizeRouteOrigin(request.Origin, request.Remote) | |
| 120 | 121 | originKey := normalizeRouteOriginKey(request.OriginKey, origin) | |
| 121 | 122 | kind := protocol.RouteKindLocal | |
| @@ -135,9 +136,16 @@ func routeCandidateForAttach(request AttachRequest, identity protocol.CommittedR | |||
| 135 | 136 | request = cloneAttachRequest(request) | |
| 136 | 137 | request.Origin = origin | |
| 137 | 138 | request.OriginKey = originKey | |
| 139 | + | request.Intent = protocol.IntentAttach | |
| 138 | 140 | request.SessionName = identity.Target.SessionName | |
| 139 | 141 | request.ExactTarget = &identity.Target | |
| 140 | 142 | request.HostLabel = hostLabel | |
| 143 | + | // Client-owned environment is creation input, not durable route metadata. | |
| 144 | + | // Once a remote creation commits, later navigation must preserve the new | |
| 145 | + | // session's daemon-owned environment and can safely advertise hybrid Home. | |
| 146 | + | if created && kind == protocol.RouteKindRemote { | |
| 147 | + | request.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned | |
| 148 | + | } | |
| 141 | 149 | // Discovery targets are point-in-time attach authority, including a live | |
| 142 | 150 | // tab selector. Once Welcome commits an exact route, mutable tab memory is | |
| 143 | 151 | // carried independently by PreferredTabID. | |
| @@ -116,6 +116,7 @@ func cloneRoutePosition(position *protocol.RoutePosition) *protocol.RoutePositio | |||||
| 116 | } | 116 | } | ||
| 117 | 117 | ||||
| 118 | func routeCandidateForAttach(request AttachRequest, identity protocol.CommittedRouteIdentity, dialer ports.ClientDialer, resumeToken uint64) routeCandidate { | 118 | func routeCandidateForAttach(request AttachRequest, identity protocol.CommittedRouteIdentity, dialer ports.ClientDialer, resumeToken uint64) routeCandidate { | ||
| 119 | + | created := request.Intent == protocol.IntentNew | |||
| 119 | origin := normalizeRouteOrigin(request.Origin, request.Remote) | 120 | origin := normalizeRouteOrigin(request.Origin, request.Remote) | ||
| 120 | originKey := normalizeRouteOriginKey(request.OriginKey, origin) | 121 | originKey := normalizeRouteOriginKey(request.OriginKey, origin) | ||
| 121 | kind := protocol.RouteKindLocal | 122 | kind := protocol.RouteKindLocal | ||
| @@ -135,9 +136,16 @@ func routeCandidateForAttach(request AttachRequest, identity protocol.CommittedR | |||||
| 135 | request = cloneAttachRequest(request) | 136 | request = cloneAttachRequest(request) | ||
| 136 | request.Origin = origin | 137 | request.Origin = origin | ||
| 137 | request.OriginKey = originKey | 138 | request.OriginKey = originKey | ||
| 139 | + | request.Intent = protocol.IntentAttach | |||
| 138 | request.SessionName = identity.Target.SessionName | 140 | request.SessionName = identity.Target.SessionName | ||
| 139 | request.ExactTarget = &identity.Target | 141 | request.ExactTarget = &identity.Target | ||
| 140 | request.HostLabel = hostLabel | 142 | request.HostLabel = hostLabel | ||
| 143 | + | // Client-owned environment is creation input, not durable route metadata. | |||
| 144 | + | // Once a remote creation commits, later navigation must preserve the new | |||
| 145 | + | // session's daemon-owned environment and can safely advertise hybrid Home. | |||
| 146 | + | if created && kind == protocol.RouteKindRemote { | |||
| 147 | + | request.EnvironmentPolicy = protocol.EnvironmentPolicyDaemonOwned | |||
| 148 | + | } | |||
| 141 | // Discovery targets are point-in-time attach authority, including a live | 149 | // Discovery targets are point-in-time attach authority, including a live | ||
| 142 | // tab selector. Once Welcome commits an exact route, mutable tab memory is | 150 | // tab selector. Once Welcome commits an exact route, mutable tab memory is | ||
| 143 | // carried independently by PreferredTabID. | 151 | // carried independently by PreferredTabID. | ||