fix(routes): retain rename identity before route publication
- commit
- 5ee470f6324ce557a48519ce1e57708aaa833361
- author
- brice · · 2026-08-11 03:43 UTC
- parent
- 1690f30
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +107 -33
M internal/ports/frame.go +4 -4
| @@ -21,10 +21,10 @@ 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, 15, and 32; | |
| 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 | 29 | MsgHello MsgType = 1 | |
| 30 | 30 | MsgInput MsgType = 2 | |
| @@ -21,10 +21,10 @@ 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, 15, and 32; | 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 | ||
M internal/ports/routes_test.go +1 -0
| @@ -169,6 +169,7 @@ func TestRouteAttentionSubscriptionCodecIsStrict(t *testing.T) { | |||
| 169 | 169 | }}} | |
| 170 | 170 | encoded, err := MarshalRouteAttentionSubscription(want) | |
| 171 | 171 | require.NoError(t, err) | |
| 172 | + | require.Equal(t, "0100000000000000070000000000000003010203000000000000000000000000000004776f726b", hex.EncodeToString(encoded)) | |
| 172 | 173 | assertAllPrefixesFail(t, encoded, UnmarshalRouteAttentionSubscription) | |
| 173 | 174 | _, err = UnmarshalRouteAttentionSubscription(append(append([]byte(nil), encoded...), 0)) | |
| 174 | 175 | require.Error(t, err) | |
| @@ -169,6 +169,7 @@ func TestRouteAttentionSubscriptionCodecIsStrict(t *testing.T) { | |||||
| 169 | }}} | 169 | }}} | ||
| 170 | encoded, err := MarshalRouteAttentionSubscription(want) | 170 | encoded, err := MarshalRouteAttentionSubscription(want) | ||
| 171 | require.NoError(t, err) | 171 | require.NoError(t, err) | ||
| 172 | + | require.Equal(t, "0100000000000000070000000000000003010203000000000000000000000000000004776f726b", hex.EncodeToString(encoded)) | |||
| 172 | assertAllPrefixesFail(t, encoded, UnmarshalRouteAttentionSubscription) | 173 | assertAllPrefixesFail(t, encoded, UnmarshalRouteAttentionSubscription) | ||
| 173 | _, err = UnmarshalRouteAttentionSubscription(append(append([]byte(nil), encoded...), 0)) | 174 | _, err = UnmarshalRouteAttentionSubscription(append(append([]byte(nil), encoded...), 0)) | ||
| 174 | require.Error(t, err) | 175 | require.Error(t, err) | ||
M internal/usecase/client/routes_test.go +5 -1
| @@ -118,7 +118,11 @@ func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) | |||
| 118 | 118 | other := routeTestCandidate(1, ports.RouteOriginLocal) | |
| 119 | 119 | _, err = ledger.commit(other) | |
| 120 | 120 | require.NoError(t, err) | |
| 121 | - | active := routeTestCandidate(2, ports.RouteOriginRemote) | |
| 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) | |
| 122 | 126 | active.originKey = "host-a" | |
| 123 | 127 | _, err = ledger.commit(active) | |
| 124 | 128 | require.NoError(t, err) | |
| @@ -118,7 +118,11 @@ func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) | |||||
| 118 | other := routeTestCandidate(1, ports.RouteOriginLocal) | 118 | other := routeTestCandidate(1, ports.RouteOriginLocal) | ||
| 119 | _, err = ledger.commit(other) | 119 | _, err = ledger.commit(other) | ||
| 120 | require.NoError(t, err) | 120 | require.NoError(t, err) | ||
| 121 | - | active := routeTestCandidate(2, ports.RouteOriginRemote) | 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) | |||
| 122 | active.originKey = "host-a" | 126 | active.originKey = "host-a" | ||
| 123 | _, err = ledger.commit(active) | 127 | _, err = ledger.commit(active) | ||
| 124 | require.NoError(t, err) | 128 | require.NoError(t, err) | ||
M internal/usecase/daemon/client.go +22 -1
| @@ -99,6 +99,7 @@ type attachedClient struct { | |||
| 99 | 99 | sendMu sync.Mutex | |
| 100 | 100 | routeMu sync.RWMutex | |
| 101 | 101 | routeSnapshot ports.RecentRouteSnapshot | |
| 102 | + | pendingRouteIdentity bool | |
| 102 | 103 | routeAttentionSubscription ports.RouteAttentionSubscription | |
| 103 | 104 | // routeCreatedSession marks a session created by this attachment's route. | |
| 104 | 105 | // A handshake that never commits Welcome must tear down that exact empty | |
| @@ -352,11 +353,31 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool { | |||
| 352 | 353 | return tr != nil && ac.transportIs(tr) | |
| 353 | 354 | } | |
| 354 | 355 | ||
| 355 | - | 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 { | |
| 356 | 359 | snapshot.Entries = append([]ports.RecentRouteEntry(nil), snapshot.Entries...) | |
| 357 | 360 | ac.routeMu.Lock() | |
| 358 | 361 | ac.routeSnapshot = snapshot | |
| 362 | + | replayPendingIdentity := snapshot.Generation != 0 && ac.pendingRouteIdentity | |
| 363 | + | if replayPendingIdentity { | |
| 364 | + | ac.pendingRouteIdentity = false | |
| 365 | + | } | |
| 359 | 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 | |
| 360 | 381 | } | |
| 361 | 382 | ||
| 362 | 383 | func (ac *attachedClient) routeSnapshotCopy() ports.RecentRouteSnapshot { | |
| @@ -99,6 +99,7 @@ type attachedClient struct { | |||||
| 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 | |||
| 102 | routeAttentionSubscription ports.RouteAttentionSubscription | 103 | routeAttentionSubscription ports.RouteAttentionSubscription | ||
| 103 | // routeCreatedSession marks a session created by this attachment's route. | 104 | // routeCreatedSession marks a session created by this attachment's route. | ||
| 104 | // 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 | ||
| @@ -352,11 +353,31 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool { | |||||
| 352 | return tr != nil && ac.transportIs(tr) | 353 | return tr != nil && ac.transportIs(tr) | ||
| 353 | } | 354 | } | ||
| 354 | 355 | ||||
| 355 | - | 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 { | |||
| 356 | snapshot.Entries = append([]ports.RecentRouteEntry(nil), snapshot.Entries...) | 359 | snapshot.Entries = append([]ports.RecentRouteEntry(nil), snapshot.Entries...) | ||
| 357 | ac.routeMu.Lock() | 360 | ac.routeMu.Lock() | ||
| 358 | ac.routeSnapshot = snapshot | 361 | ac.routeSnapshot = snapshot | ||
| 362 | + | replayPendingIdentity := snapshot.Generation != 0 && ac.pendingRouteIdentity | |||
| 363 | + | if replayPendingIdentity { | |||
| 364 | + | ac.pendingRouteIdentity = false | |||
| 365 | + | } | |||
| 359 | 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 | |||
| 360 | } | 381 | } | ||
| 361 | 382 | ||||
| 362 | func (ac *attachedClient) routeSnapshotCopy() ports.RecentRouteSnapshot { | 383 | func (ac *attachedClient) routeSnapshotCopy() ports.RecentRouteSnapshot { | ||
M internal/usecase/daemon/client_frame_routing.go +8 -3
| @@ -128,10 +128,15 @@ 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) | |
| 135 | 140 | } | |
| 136 | 141 | case ports.MsgRouteAttentionSubscription: | |
| 137 | 142 | subscription, derr := ports.UnmarshalRouteAttentionSubscription(f.Payload) | |
| @@ -128,10 +128,15 @@ 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) | |||
| 135 | } | 140 | } | ||
| 136 | case ports.MsgRouteAttentionSubscription: | 141 | case ports.MsgRouteAttentionSubscription: | ||
| 137 | subscription, derr := ports.UnmarshalRouteAttentionSubscription(f.Payload) | 142 | subscription, derr := ports.UnmarshalRouteAttentionSubscription(f.Payload) | ||
M internal/usecase/daemon/recent_route_snapshot_test.go +34 -23
| @@ -26,29 +26,40 @@ func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) { | |||
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) { | |
| 29 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 30 | - | sess.mu.Lock() | |
| 31 | - | sess.incarnation = domain.SessionLifecycleID{9} | |
| 32 | - | sess.tabs[0].attention = true | |
| 33 | - | sess.mu.Unlock() | |
| 34 | - | ref := ports.RouteRef{Key: 2, Generation: 1} | |
| 35 | - | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 36 | - | Generation: 2, | |
| 37 | - | Active: ports.RouteRef{Key: 3, Generation: 2}, | |
| 38 | - | Entries: []ports.RecentRouteEntry{{Key: ref.Key, Generation: ref.Generation, Name: sess.name, Kind: ports.RouteKindLocal}}, | |
| 39 | - | }) | |
| 40 | - | ac.setRouteAttentionSubscription(ports.RouteAttentionSubscription{Targets: []ports.RouteAttentionTarget{{ | |
| 41 | - | Ref: ref, | |
| 42 | - | Target: ports.ExactSessionTarget{ | |
| 43 | - | LifecycleID: sess.incarnation, | |
| 44 | - | SessionName: sess.name, | |
| 45 | - | }, | |
| 46 | - | }}}) | |
| 47 | - | ||
| 48 | - | state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, ports.RecentRouteSnapshot{}) | |
| 49 | - | ||
| 50 | - | require.Len(t, state.mru, 1) | |
| 51 | - | require.True(t, state.mru[0].attention) | |
| 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 | + | } | |
| 52 | 63 | } | |
| 53 | 64 | ||
| 54 | 65 | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | |
| @@ -26,29 +26,40 @@ func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) { | |||||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) { | 28 | func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) { | ||
| 29 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | 29 | + | for _, tt := range []struct { |
| 30 | - | sess.mu.Lock() | 30 | + | name string |
| 31 | - | sess.incarnation = domain.SessionLifecycleID{9} | 31 | + | targetID domain.SessionLifecycleID |
| 32 | - | sess.tabs[0].attention = true | 32 | + | attention bool |
| 33 | - | sess.mu.Unlock() | 33 | + | }{ |
| 34 | - | ref := ports.RouteRef{Key: 2, Generation: 1} | 34 | + | {name: "matching lifecycle", targetID: domain.SessionLifecycleID{9}, attention: true}, |
| 35 | - | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | 35 | + | {name: "stale lifecycle", targetID: domain.SessionLifecycleID{8}, attention: false}, |
| 36 | - | Generation: 2, | 36 | + | } { |
| 37 | - | Active: ports.RouteRef{Key: 3, Generation: 2}, | 37 | + | t.Run(tt.name, func(t *testing.T) { |
| 38 | - | Entries: []ports.RecentRouteEntry{{Key: ref.Key, Generation: ref.Generation, Name: sess.name, Kind: ports.RouteKindLocal}}, | 38 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) |
| 39 | - | }) | 39 | + | sess.mu.Lock() |
| 40 | - | ac.setRouteAttentionSubscription(ports.RouteAttentionSubscription{Targets: []ports.RouteAttentionTarget{{ | 40 | + | sess.incarnation = domain.SessionLifecycleID{9} |
| 41 | - | Ref: ref, | 41 | + | sess.tabs[0].attention = true |
| 42 | - | Target: ports.ExactSessionTarget{ | 42 | + | sess.mu.Unlock() |
| 43 | - | LifecycleID: sess.incarnation, | 43 | + | ref := ports.RouteRef{Key: 2, Generation: 1} |
| 44 | - | SessionName: sess.name, | 44 | + | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ |
| 45 | - | }, | 45 | + | Generation: 2, |
| 46 | - | }}}) | 46 | + | Active: ports.RouteRef{Key: 3, Generation: 2}, |
| 47 | - | 47 | + | Entries: []ports.RecentRouteEntry{{Key: ref.Key, Generation: ref.Generation, Name: sess.name, Kind: ports.RouteKindLocal}}, | |
| 48 | - | state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, ports.RecentRouteSnapshot{}) | 48 | + | }) |
| 49 | - | 49 | + | ac.setRouteAttentionSubscription(ports.RouteAttentionSubscription{Targets: []ports.RouteAttentionTarget{{ | |
| 50 | - | require.Len(t, state.mru, 1) | 50 | + | Ref: ref, |
| 51 | - | require.True(t, state.mru[0].attention) | 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 | + | } | |||
| 52 | } | 63 | } | ||
| 53 | 64 | ||||
| 54 | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | 65 | func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) { | ||
M internal/usecase/daemon/session.go +1 -1
| @@ -925,7 +925,7 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 925 | 925 | // prior route record. | |
| 926 | 926 | func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) { | |
| 927 | 927 | for _, ac := range sess.snapshotAttachments() { | |
| 928 | - | if ac.routeSnapshotCopy().Generation == 0 { | |
| 928 | + | if ac.deferRouteIdentityUntilSnapshot() { | |
| 929 | 929 | continue | |
| 930 | 930 | } | |
| 931 | 931 | token := sess.attachmentToken(ac, ac.transport()) | |
| @@ -925,7 +925,7 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 925 | // prior route record. | 925 | // prior route record. | ||
| 926 | func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) { | 926 | func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) { | ||
| 927 | for _, ac := range sess.snapshotAttachments() { | 927 | for _, ac := range sess.snapshotAttachments() { | ||
| 928 | - | if ac.routeSnapshotCopy().Generation == 0 { | 928 | + | if ac.deferRouteIdentityUntilSnapshot() { |
| 929 | continue | 929 | continue | ||
| 930 | } | 930 | } | ||
| 931 | token := sess.attachmentToken(ac, ac.transport()) | 931 | token := sess.attachmentToken(ac, ac.transport()) | ||
M internal/usecase/daemon/session_rename_purge_test.go +32 -0
| @@ -33,6 +33,38 @@ func TestRenamePublishesCommittedRouteIdentityToAttachedClients(t *testing.T) { | |||
| 33 | 33 | require.False(t, identity.Ephemeral) | |
| 34 | 34 | } | |
| 35 | 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 | + | ||
| 36 | 68 | func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) { | |
| 37 | 69 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 38 | 70 | repository := &retryablePurgeRepository{} | |
| @@ -33,6 +33,38 @@ func TestRenamePublishesCommittedRouteIdentityToAttachedClients(t *testing.T) { | |||||
| 33 | require.False(t, identity.Ephemeral) | 33 | require.False(t, identity.Ephemeral) | ||
| 34 | } | 34 | } | ||
| 35 | 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 | + | ||||
| 36 | func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) { | 68 | func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) { | ||
| 37 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 69 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 38 | repository := &retryablePurgeRepository{} | 70 | repository := &retryablePurgeRepository{} | ||