detached 7739dc9

fix(app): handle local picker handoffs

commit
7739dc9ef05c3f237b14bb835e60f43d6bba5b57
author
brice · · 2026-08-03 13:19 UTC
committer
brice · · 2026-08-04 07:16 UTC
parent
dea913c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +101 -37
M internal/app/run.go +59 -35
@@ -821,6 +821,12 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti
821821 ), nil
822822 }
823823
824+type attachHandoffKey struct {
825+ endpoint string
826+ session string
827+ intent uint8
828+}
829+
824830 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
825831 if activeSession != "" {
826832 if remoteTarget == "" && intent == ports.IntentNew {
@@ -833,22 +839,32 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
833839 if runClient == nil {
834840 runClient = runClientWithDeps
835841 }
842+ localDialer := deps.localDialer
843+ if localDialer == nil {
844+ localDialer = defaultLocalDialer
845+ }
846+ mode, modeErr := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
847+ factory := deps.remoteDialerFactory
848+ seenHandoffs := make(map[attachHandoffKey]struct{})
836849 if remoteTarget != "" {
837- mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
838- if err != nil {
839- return err
850+ if modeErr != nil {
851+ return modeErr
840852 }
841- factory := deps.remoteDialerFactory
842853 if factory == nil {
843854 factory = defaultRemoteDialerFactory()
844855 }
845- for {
856+ seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}
857+ }
858+
859+ for {
860+ var err error
861+ if remoteTarget != "" {
846862 if log != nil {
847863 log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
848864 }
849- dialer, err := factory.DialerForRemote(remoteTarget, name, mode, log)
850- if err != nil {
851- return err
865+ dialer, dialErr := factory.DialerForRemote(remoteTarget, name, mode, log)
866+ if dialErr != nil {
867+ return dialErr
852868 }
853869 err = runClient(ctx, client.Dependencies{
854870 Dialer: dialer,
@@ -860,37 +876,45 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
860876 RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
861877 Remote: true,
862878 }, client.AttachRequest{Intent: intent, SessionName: name})
863- var handoff *client.AttachTargetError
864- if !errors.As(err, &handoff) {
865- return err
866- }
867- if handoff == nil {
868- return fmt.Errorf("vev: invalid remote attach handoff")
869- }
870- if err := validateRemoteAttachHandoff(handoff.Target); err != nil {
871- return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
879+ } else {
880+ if log != nil {
881+ log.Info("attaching to local session", "intent", intent, "name", name)
872882 }
873- remoteTarget = handoff.Target.Endpoint
874- name = handoff.Target.Session
875- intent = handoff.Target.Intent
883+ err = runClient(ctx, client.Dependencies{
884+ Dialer: localDialer(),
885+ Terminal: term.New(),
886+ Clock: clock.New(),
887+ Logger: log,
888+ RuntimeObserver: deps.runtimeObserver,
889+ Remote: false,
890+ }, client.AttachRequest{Intent: intent, SessionName: name})
876891 }
877- }
878892
879- localDialer := deps.localDialer
880- if localDialer == nil {
881- localDialer = defaultLocalDialer
893+ var handoff *client.AttachTargetError
894+ if !errors.As(err, &handoff) {
895+ return err
896+ }
897+ if handoff == nil {
898+ return fmt.Errorf("vev: invalid remote attach handoff")
899+ }
900+ if err := validateRemoteAttachHandoff(handoff.Target); err != nil {
901+ return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
902+ }
903+ next := attachHandoffKey{endpoint: handoff.Target.Endpoint, session: handoff.Target.Session, intent: handoff.Target.Intent}
904+ if _, seen := seenHandoffs[next]; seen {
905+ return fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session)
906+ }
907+ seenHandoffs[next] = struct{}{}
908+ remoteTarget = handoff.Target.Endpoint
909+ name = handoff.Target.Session
910+ intent = handoff.Target.Intent
911+ if modeErr != nil {
912+ return modeErr
913+ }
914+ if factory == nil {
915+ factory = defaultRemoteDialerFactory()
916+ }
882917 }
883- if log != nil {
884- log.Info("attaching to local session", "intent", intent, "name", name)
885- }
886- return runClient(ctx, client.Dependencies{
887- Dialer: localDialer(),
888- Terminal: term.New(),
889- Clock: clock.New(),
890- Logger: log,
891- RuntimeObserver: deps.runtimeObserver,
892- Remote: false,
893- }, client.AttachRequest{Intent: intent, SessionName: name})
894918 }
895919
896920 const daemonStopTimeout = 2 * time.Second
@@ -821,6 +821,12 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti
821 ), nil821 ), nil
822 }822 }
823 823
824+type attachHandoffKey struct {
825+ endpoint string
826+ session string
827+ intent uint8
828+}
829+
824 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {830 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
825 if activeSession != "" {831 if activeSession != "" {
826 if remoteTarget == "" && intent == ports.IntentNew {832 if remoteTarget == "" && intent == ports.IntentNew {
@@ -833,22 +839,32 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
833 if runClient == nil {839 if runClient == nil {
834 runClient = runClientWithDeps840 runClient = runClientWithDeps
835 }841 }
842+ localDialer := deps.localDialer
843+ if localDialer == nil {
844+ localDialer = defaultLocalDialer
845+ }
846+ mode, modeErr := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
847+ factory := deps.remoteDialerFactory
848+ seenHandoffs := make(map[attachHandoffKey]struct{})
836 if remoteTarget != "" {849 if remoteTarget != "" {
837- mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)850+ if modeErr != nil {
838- if err != nil {851+ return modeErr
839- return err
840 }852 }
841- factory := deps.remoteDialerFactory
842 if factory == nil {853 if factory == nil {
843 factory = defaultRemoteDialerFactory()854 factory = defaultRemoteDialerFactory()
844 }855 }
845- for {856+ seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}
857+ }
858+
859+ for {
860+ var err error
861+ if remoteTarget != "" {
846 if log != nil {862 if log != nil {
847 log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))863 log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
848 }864 }
849- dialer, err := factory.DialerForRemote(remoteTarget, name, mode, log)865+ dialer, dialErr := factory.DialerForRemote(remoteTarget, name, mode, log)
850- if err != nil {866+ if dialErr != nil {
851- return err867+ return dialErr
852 }868 }
853 err = runClient(ctx, client.Dependencies{869 err = runClient(ctx, client.Dependencies{
854 Dialer: dialer,870 Dialer: dialer,
@@ -860,37 +876,45 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
860 RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),876 RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
861 Remote: true,877 Remote: true,
862 }, client.AttachRequest{Intent: intent, SessionName: name})878 }, client.AttachRequest{Intent: intent, SessionName: name})
863- var handoff *client.AttachTargetError879+ } else {
864- if !errors.As(err, &handoff) {880+ if log != nil {
865- return err881+ log.Info("attaching to local session", "intent", intent, "name", name)
866- }
867- if handoff == nil {
868- return fmt.Errorf("vev: invalid remote attach handoff")
869- }
870- if err := validateRemoteAttachHandoff(handoff.Target); err != nil {
871- return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
872 }882 }
873- remoteTarget = handoff.Target.Endpoint883+ err = runClient(ctx, client.Dependencies{
874- name = handoff.Target.Session884+ Dialer: localDialer(),
875- intent = handoff.Target.Intent885+ Terminal: term.New(),
886+ Clock: clock.New(),
887+ Logger: log,
888+ RuntimeObserver: deps.runtimeObserver,
889+ Remote: false,
890+ }, client.AttachRequest{Intent: intent, SessionName: name})
876 }891 }
877- }
878 892
879- localDialer := deps.localDialer893+ var handoff *client.AttachTargetError
880- if localDialer == nil {894+ if !errors.As(err, &handoff) {
881- localDialer = defaultLocalDialer895+ return err
896+ }
897+ if handoff == nil {
898+ return fmt.Errorf("vev: invalid remote attach handoff")
899+ }
900+ if err := validateRemoteAttachHandoff(handoff.Target); err != nil {
901+ return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
902+ }
903+ next := attachHandoffKey{endpoint: handoff.Target.Endpoint, session: handoff.Target.Session, intent: handoff.Target.Intent}
904+ if _, seen := seenHandoffs[next]; seen {
905+ return fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session)
906+ }
907+ seenHandoffs[next] = struct{}{}
908+ remoteTarget = handoff.Target.Endpoint
909+ name = handoff.Target.Session
910+ intent = handoff.Target.Intent
911+ if modeErr != nil {
912+ return modeErr
913+ }
914+ if factory == nil {
915+ factory = defaultRemoteDialerFactory()
916+ }
882 }917 }
883- if log != nil {
884- log.Info("attaching to local session", "intent", intent, "name", name)
885- }
886- return runClient(ctx, client.Dependencies{
887- Dialer: localDialer(),
888- Terminal: term.New(),
889- Clock: clock.New(),
890- Logger: log,
891- RuntimeObserver: deps.runtimeObserver,
892- Remote: false,
893- }, client.AttachRequest{Intent: intent, SessionName: name})
894 }918 }
895 919
896 const daemonStopTimeout = 2 * time.Second920 const daemonStopTimeout = 2 * time.Second
M internal/app/run_test.go +42 -2
@@ -872,7 +872,7 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
872872 func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {
873873 factory := portsmocks.NewMockRemoteDialerFactory(t)
874874 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
875- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
875+ factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
876876 var calls int
877877 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
878878 remoteDialerFactory: factory,
@@ -880,7 +880,7 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.
880880 calls++
881881 require.NotNil(t, deps.Dialer)
882882 if calls == 1 {
883- return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}}
883+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
884884 }
885885 return nil
886886 },
@@ -889,6 +889,46 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.
889889 require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")
890890 }
891891
892+func TestRunAttachWithDepsRejectsHandoffLoop(t *testing.T) {
893+ factory := portsmocks.NewMockRemoteDialerFactory(t)
894+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
895+
896+ calls := 0
897+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
898+ remoteDialerFactory: factory,
899+ runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
900+ calls++
901+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}}
902+ },
903+ })
904+ require.ErrorContains(t, err, "attach handoff loop")
905+ require.Equal(t, 1, calls)
906+}
907+
908+func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {
909+ factory := portsmocks.NewMockRemoteDialerFactory(t)
910+ factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
911+
912+ localCalls, remoteCalls := 0, 0
913+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "", "", nil, runAttachDeps{
914+ localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
915+ remoteDialerFactory: factory,
916+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
917+ if deps.Remote {
918+ remoteCalls++
919+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked"}, request)
920+ return nil
921+ }
922+ localCalls++
923+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, request)
924+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
925+ },
926+ })
927+ require.NoError(t, err)
928+ require.Equal(t, 1, localCalls, "the failed local picker attach must not retry locally")
929+ require.Equal(t, 1, remoteCalls, "the selected target must be dialed and attached exactly once")
930+}
931+
892932 func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {
893933 tests := []struct {
894934 name string
@@ -872,7 +872,7 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
872 func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {872 func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {
873 factory := portsmocks.NewMockRemoteDialerFactory(t)873 factory := portsmocks.NewMockRemoteDialerFactory(t)
874 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()874 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
875- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()875+ factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
876 var calls int876 var calls int
877 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{877 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
878 remoteDialerFactory: factory,878 remoteDialerFactory: factory,
@@ -880,7 +880,7 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.
880 calls++880 calls++
881 require.NotNil(t, deps.Dialer)881 require.NotNil(t, deps.Dialer)
882 if calls == 1 {882 if calls == 1 {
883- return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}}883+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
884 }884 }
885 return nil885 return nil
886 },886 },
@@ -889,6 +889,46 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.
889 require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")889 require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")
890 }890 }
891 891
892+func TestRunAttachWithDepsRejectsHandoffLoop(t *testing.T) {
893+ factory := portsmocks.NewMockRemoteDialerFactory(t)
894+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
895+
896+ calls := 0
897+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
898+ remoteDialerFactory: factory,
899+ runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
900+ calls++
901+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}}
902+ },
903+ })
904+ require.ErrorContains(t, err, "attach handoff loop")
905+ require.Equal(t, 1, calls)
906+}
907+
908+func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {
909+ factory := portsmocks.NewMockRemoteDialerFactory(t)
910+ factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
911+
912+ localCalls, remoteCalls := 0, 0
913+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "", "", nil, runAttachDeps{
914+ localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
915+ remoteDialerFactory: factory,
916+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
917+ if deps.Remote {
918+ remoteCalls++
919+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked"}, request)
920+ return nil
921+ }
922+ localCalls++
923+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, request)
924+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
925+ },
926+ })
927+ require.NoError(t, err)
928+ require.Equal(t, 1, localCalls, "the failed local picker attach must not retry locally")
929+ require.Equal(t, 1, remoteCalls, "the selected target must be dialed and attached exactly once")
930+}
931+
892 func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {932 func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {
893 tests := []struct {933 tests := []struct {
894 name string934 name string

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 2

Tags 13

↑↓ moveenter openesc close