detached 55169b2

fix(remote): bound connection handoff retries

commit
55169b26322e3cae78ed30eace5414b71560e099
author
brice · · 2026-07-31 21:43 UTC
parent
01e78fb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +73 -4
M internal/usecase/daemon/client_frame_routing.go +12 -3
@@ -8,11 +8,16 @@ import (
88 "github.com/bnema/vev/internal/usecase/command"
99 )
1010
11+const connectionRoleAttempts = 8
12+
1113 // currentConnectionRole retries the lock-free session-to-role snapshot when a
1214 // handoff lands between those reads. The role gate still performs the final
1315 // mutation admission after this function returns.
1416 func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentRoleToken, bool) {
15- for ac.currentTransportIs(tr) {
17+ for range connectionRoleAttempts {
18+ if !ac.currentTransportIs(tr) {
19+ break
20+ }
1621 sess := ac.currentAttachmentSession()
1722 if sess == nil {
1823 return nil, attachmentRoleToken{}, false
@@ -45,7 +50,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
4550 }
4651 f, err := tr.Recv()
4752 if err != nil {
48- for {
53+ for range connectionRoleAttempts {
4954 sess, token, ok := d.currentConnectionRole(ac, tr)
5055 if !ok {
5156 return
@@ -56,11 +61,15 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
5661 d.detachProxyOnSendError(proxy, ac, tr)
5762 } else if local, ok := localSession(sess); ok {
5863 d.clientGone(local, ac, tr, false)
64+ } else {
65+ return
5966 }
60- if !ac.currentTransportIs(tr) || ac.currentAttachmentSession() == nil {
67+ current := ac.currentAttachmentSession()
68+ if current == sess || !ac.currentTransportIs(tr) || current == nil {
6169 return
6270 }
6371 }
72+ return
6473 }
6574 _, token, ok := d.currentConnectionRole(ac, tr)
6675 if !ok {
@@ -8,11 +8,16 @@ import (
8 "github.com/bnema/vev/internal/usecase/command"8 "github.com/bnema/vev/internal/usecase/command"
9 )9 )
10 10
11+const connectionRoleAttempts = 8
12+
11 // currentConnectionRole retries the lock-free session-to-role snapshot when a13 // currentConnectionRole retries the lock-free session-to-role snapshot when a
12 // handoff lands between those reads. The role gate still performs the final14 // handoff lands between those reads. The role gate still performs the final
13 // mutation admission after this function returns.15 // mutation admission after this function returns.
14 func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentRoleToken, bool) {16 func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentRoleToken, bool) {
15- for ac.currentTransportIs(tr) {17+ for range connectionRoleAttempts {
18+ if !ac.currentTransportIs(tr) {
19+ break
20+ }
16 sess := ac.currentAttachmentSession()21 sess := ac.currentAttachmentSession()
17 if sess == nil {22 if sess == nil {
18 return nil, attachmentRoleToken{}, false23 return nil, attachmentRoleToken{}, false
@@ -45,7 +50,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
45 }50 }
46 f, err := tr.Recv()51 f, err := tr.Recv()
47 if err != nil {52 if err != nil {
48- for {53+ for range connectionRoleAttempts {
49 sess, token, ok := d.currentConnectionRole(ac, tr)54 sess, token, ok := d.currentConnectionRole(ac, tr)
50 if !ok {55 if !ok {
51 return56 return
@@ -56,11 +61,15 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
56 d.detachProxyOnSendError(proxy, ac, tr)61 d.detachProxyOnSendError(proxy, ac, tr)
57 } else if local, ok := localSession(sess); ok {62 } else if local, ok := localSession(sess); ok {
58 d.clientGone(local, ac, tr, false)63 d.clientGone(local, ac, tr, false)
64+ } else {
65+ return
59 }66 }
60- if !ac.currentTransportIs(tr) || ac.currentAttachmentSession() == nil {67+ current := ac.currentAttachmentSession()
68+ if current == sess || !ac.currentTransportIs(tr) || current == nil {
61 return69 return
62 }70 }
63 }71 }
72+ return
64 }73 }
65 _, token, ok := d.currentConnectionRole(ac, tr)74 _, token, ok := d.currentConnectionRole(ac, tr)
66 if !ok {75 if !ok {
M internal/usecase/daemon/proxy_input_test.go +61 -1
@@ -830,6 +830,64 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
830830 require.False(t, ac.overlays.pickerActive())
831831 }
832832
833+func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) {
834+ d := newTestDaemon(t, nil, stubClock{})
835+ client := newProxyTestTransport()
836+ ac := &attachedClient{}
837+ ac.replaceTransport(client)
838+ first := &session{sessionCore: sessionCore{client: ac}}
839+ second := &session{sessionCore: sessionCore{client: ac}}
840+ ac.setSession(first)
841+
842+ const unstableSnapshots = 100
843+ attempts := 0
844+ d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
845+ attempts++
846+ if attempts >= unstableSnapshots {
847+ return
848+ }
849+ if snapshot == first {
850+ ac.setSession(second)
851+ } else {
852+ ac.setSession(first)
853+ }
854+ }
855+
856+ _, _, ok := d.currentConnectionRole(ac, client)
857+ require.False(t, ok)
858+ require.Less(t, attempts, unstableSnapshots)
859+}
860+
861+func TestConnectionLoopStopsWhenReceiveErrorCleanupMakesNoProgress(t *testing.T) {
862+ d := newTestDaemon(t, nil, stubClock{})
863+ client := newProxyTestTransport()
864+ ac := &attachedClient{}
865+ ac.replaceTransport(client)
866+ local := &session{sessionCore: sessionCore{client: ac}}
867+ ac.setSession(local)
868+
869+ cleanupAttempts := 0
870+ d.beforeClientGoneDetach = func() {
871+ cleanupAttempts++
872+ local.mu.Lock()
873+ local.client = &attachedClient{}
874+ local.mu.Unlock()
875+ }
876+ snapshots := 0
877+ d.afterConnectionSessionSnapshot = func(attachmentSession) {
878+ snapshots++
879+ if snapshots == 100 {
880+ ac.setSession(nil)
881+ }
882+ }
883+ client.recv <- proxyRecv{err: io.EOF}
884+
885+ d.runConnLoop(ac)
886+
887+ require.Equal(t, 1, cleanupAttempts)
888+ require.Equal(t, 1, snapshots)
889+}
890+
833891 func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
834892 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
835893 client := newProxyTestTransport()
@@ -843,10 +901,11 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
843901 d.remoteDialerFactory = newProxyConstructionFactory(remote)
844902 d.remoteTransportMode = ports.RemoteTransportUDP
845903 token := roleEffectForTest(t, local.attachmentToken(ac, client))
904+ snapshots := make(chan attachmentSession, 1)
846905 transitionErr := make(chan error, 1)
847906 d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
848907 d.afterConnectionSessionSnapshot = nil
849- require.Same(t, local, snapshot)
908+ snapshots <- snapshot
850909 transitionErr <- d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
851910 }
852911
@@ -856,6 +915,7 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
856915 close(loopDone)
857916 }()
858917 client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}}
918+ require.Same(t, local, awaitTestValue(t, snapshots, "connection role was not snapshotted"))
859919 require.NoError(t, awaitTestValue(t, transitionErr, "handoff did not run during role resolution"))
860920 proxy, ok := ac.currentAttachmentSession().(*proxySession)
861921 require.True(t, ok)
@@ -830,6 +830,64 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
830 require.False(t, ac.overlays.pickerActive())830 require.False(t, ac.overlays.pickerActive())
831 }831 }
832 832
833+func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) {
834+ d := newTestDaemon(t, nil, stubClock{})
835+ client := newProxyTestTransport()
836+ ac := &attachedClient{}
837+ ac.replaceTransport(client)
838+ first := &session{sessionCore: sessionCore{client: ac}}
839+ second := &session{sessionCore: sessionCore{client: ac}}
840+ ac.setSession(first)
841+
842+ const unstableSnapshots = 100
843+ attempts := 0
844+ d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
845+ attempts++
846+ if attempts >= unstableSnapshots {
847+ return
848+ }
849+ if snapshot == first {
850+ ac.setSession(second)
851+ } else {
852+ ac.setSession(first)
853+ }
854+ }
855+
856+ _, _, ok := d.currentConnectionRole(ac, client)
857+ require.False(t, ok)
858+ require.Less(t, attempts, unstableSnapshots)
859+}
860+
861+func TestConnectionLoopStopsWhenReceiveErrorCleanupMakesNoProgress(t *testing.T) {
862+ d := newTestDaemon(t, nil, stubClock{})
863+ client := newProxyTestTransport()
864+ ac := &attachedClient{}
865+ ac.replaceTransport(client)
866+ local := &session{sessionCore: sessionCore{client: ac}}
867+ ac.setSession(local)
868+
869+ cleanupAttempts := 0
870+ d.beforeClientGoneDetach = func() {
871+ cleanupAttempts++
872+ local.mu.Lock()
873+ local.client = &attachedClient{}
874+ local.mu.Unlock()
875+ }
876+ snapshots := 0
877+ d.afterConnectionSessionSnapshot = func(attachmentSession) {
878+ snapshots++
879+ if snapshots == 100 {
880+ ac.setSession(nil)
881+ }
882+ }
883+ client.recv <- proxyRecv{err: io.EOF}
884+
885+ d.runConnLoop(ac)
886+
887+ require.Equal(t, 1, cleanupAttempts)
888+ require.Equal(t, 1, snapshots)
889+}
890+
833 func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {891 func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
834 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())892 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
835 client := newProxyTestTransport()893 client := newProxyTestTransport()
@@ -843,10 +901,11 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
843 d.remoteDialerFactory = newProxyConstructionFactory(remote)901 d.remoteDialerFactory = newProxyConstructionFactory(remote)
844 d.remoteTransportMode = ports.RemoteTransportUDP902 d.remoteTransportMode = ports.RemoteTransportUDP
845 token := roleEffectForTest(t, local.attachmentToken(ac, client))903 token := roleEffectForTest(t, local.attachmentToken(ac, client))
904+ snapshots := make(chan attachmentSession, 1)
846 transitionErr := make(chan error, 1)905 transitionErr := make(chan error, 1)
847 d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {906 d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
848 d.afterConnectionSessionSnapshot = nil907 d.afterConnectionSessionSnapshot = nil
849- require.Same(t, local, snapshot)908+ snapshots <- snapshot
850 transitionErr <- d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")909 transitionErr <- d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
851 }910 }
852 911
@@ -856,6 +915,7 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
856 close(loopDone)915 close(loopDone)
857 }()916 }()
858 client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}}917 client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}}
918+ require.Same(t, local, awaitTestValue(t, snapshots, "connection role was not snapshotted"))
859 require.NoError(t, awaitTestValue(t, transitionErr, "handoff did not run during role resolution"))919 require.NoError(t, awaitTestValue(t, transitionErr, "handoff did not run during role resolution"))
860 proxy, ok := ac.currentAttachmentSession().(*proxySession)920 proxy, ok := ac.currentAttachmentSession().(*proxySession)
861 require.True(t, ok)921 require.True(t, ok)

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