detached 5ee470f

fix(routes): retain rename identity before route publication

commit
5ee470f6324ce557a48519ce1e57708aaa833361
author
brice · · 2026-08-11 03:43 UTC
parent
1690f30
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
2121 // MsgType identifies the kind of payload carried by a Frame.
2222 type MsgType uint8
2323
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.
2828 const (
2929 MsgHello MsgType = 1
3030 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 uint822 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 remain25+// is 1–13 and 15, and newer client subscriptions use 32. Server-originated
26-// reserved for future extensions. Route metadata/navigation messages use the26+// 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 = 129 MsgHello MsgType = 1
30 MsgInput MsgType = 230 MsgInput MsgType = 2
M internal/ports/routes_test.go +1 -0
@@ -169,6 +169,7 @@ func TestRouteAttentionSubscriptionCodecIsStrict(t *testing.T) {
169169 }}}
170170 encoded, err := MarshalRouteAttentionSubscription(want)
171171 require.NoError(t, err)
172+ require.Equal(t, "0100000000000000070000000000000003010203000000000000000000000000000004776f726b", hex.EncodeToString(encoded))
172173 assertAllPrefixesFail(t, encoded, UnmarshalRouteAttentionSubscription)
173174 _, err = UnmarshalRouteAttentionSubscription(append(append([]byte(nil), encoded...), 0))
174175 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)
118118 other := routeTestCandidate(1, ports.RouteOriginLocal)
119119 _, err = ledger.commit(other)
120120 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)
122126 active.originKey = "host-a"
123127 _, err = ledger.commit(active)
124128 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 {
9999 sendMu sync.Mutex
100100 routeMu sync.RWMutex
101101 routeSnapshot ports.RecentRouteSnapshot
102+ pendingRouteIdentity bool
102103 routeAttentionSubscription ports.RouteAttentionSubscription
103104 // routeCreatedSession marks a session created by this attachment's route.
104105 // A handshake that never commits Welcome must tear down that exact empty
@@ -352,11 +353,31 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool {
352353 return tr != nil && ac.transportIs(tr)
353354 }
354355
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 {
356359 snapshot.Entries = append([]ports.RecentRouteEntry(nil), snapshot.Entries...)
357360 ac.routeMu.Lock()
358361 ac.routeSnapshot = snapshot
362+ replayPendingIdentity := snapshot.Generation != 0 && ac.pendingRouteIdentity
363+ if replayPendingIdentity {
364+ ac.pendingRouteIdentity = false
365+ }
359366 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
360381 }
361382
362383 func (ac *attachedClient) routeSnapshotCopy() ports.RecentRouteSnapshot {
@@ -99,6 +99,7 @@ type attachedClient struct {
99 sendMu sync.Mutex99 sendMu sync.Mutex
100 routeMu sync.RWMutex100 routeMu sync.RWMutex
101 routeSnapshot ports.RecentRouteSnapshot101 routeSnapshot ports.RecentRouteSnapshot
102+ pendingRouteIdentity bool
102 routeAttentionSubscription ports.RouteAttentionSubscription103 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 empty105 // 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 = snapshot361 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
128128 }
129129 case ports.MsgRecentRouteSnapshot:
130130 snapshot, derr := ports.UnmarshalRecentRouteSnapshot(f.Payload)
131- if derr == nil {
132- token.ac.setRouteSnapshot(snapshot)
133- } else {
131+ if derr != nil {
134132 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)
135140 }
136141 case ports.MsgRouteAttentionSubscription:
137142 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) {
2626 }
2727
2828 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+ }
5263 }
5364
5465 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 = true32+ 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 {
925925 // prior route record.
926926 func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) {
927927 for _, ac := range sess.snapshotAttachments() {
928- if ac.routeSnapshotCopy().Generation == 0 {
928+ if ac.deferRouteIdentityUntilSnapshot() {
929929 continue
930930 }
931931 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 continue929 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) {
3333 require.False(t, identity.Ephemeral)
3434 }
3535
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+
3668 func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {
3769 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
3870 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{}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close