detached 87c2338

fix(remote): preserve route validation and picker effects

commit
87c233886dde4d50e0aba94c3f902bc3b9896fda
author
brice · · 2026-08-08 17:48 UTC
parent
310aed6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +33 -16
M internal/app/run.go +8 -8
@@ -889,6 +889,12 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
889889 seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}
890890 }
891891 pickerHandoff := remoteTarget == ""
892+ pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, policy ports.EnvironmentPolicy) ports.EnvironmentPolicy {
893+ if pickerHandoff && target == nil {
894+ return ports.EnvironmentPolicyDaemonOwned
895+ }
896+ return policy
897+ }
892898 handoff := func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
893899 if err := validateRemoteAttachHandoff(target); err != nil {
894900 return nil, client.AttachRequest{}, fmt.Errorf("vev: invalid remote attach handoff: %w", err)
@@ -915,10 +921,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
915921 if err != nil {
916922 return nil, client.AttachRequest{}, err
917923 }
918- policy := target.EnvironmentPolicy
919- if pickerHandoff && target.RemoteTarget == nil {
920- policy = ports.EnvironmentPolicyDaemonOwned
921- }
924+ policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy)
922925 return dialer, client.AttachRequest{Intent: target.Intent, SessionName: target.Session, Remote: true, RemoteTarget: selection, EnvironmentPolicy: policy}, nil
923926 }
924927
@@ -987,14 +990,11 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
987990 name = handoffErr.Target.Session
988991 intent = handoffErr.Target.Intent
989992 remoteSelection = nil
990- remoteEnvironmentPolicy = handoffErr.Target.EnvironmentPolicy
991- if pickerHandoff && remoteEnvironmentPolicy == ports.EnvironmentPolicyClientOwned {
992- remoteEnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
993- }
994993 if handoffErr.Target.RemoteTarget != nil {
995994 copyTarget := *handoffErr.Target.RemoteTarget
996995 remoteSelection = &copyTarget
997996 }
997+ remoteEnvironmentPolicy = pickerEnvironmentPolicy(remoteSelection, handoffErr.Target.EnvironmentPolicy)
998998 if modeErr != nil {
999999 return modeErr
10001000 }
@@ -889,6 +889,12 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
889 seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}889 seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}
890 }890 }
891 pickerHandoff := remoteTarget == ""891 pickerHandoff := remoteTarget == ""
892+ pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, policy ports.EnvironmentPolicy) ports.EnvironmentPolicy {
893+ if pickerHandoff && target == nil {
894+ return ports.EnvironmentPolicyDaemonOwned
895+ }
896+ return policy
897+ }
892 handoff := func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {898 handoff := func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
893 if err := validateRemoteAttachHandoff(target); err != nil {899 if err := validateRemoteAttachHandoff(target); err != nil {
894 return nil, client.AttachRequest{}, fmt.Errorf("vev: invalid remote attach handoff: %w", err)900 return nil, client.AttachRequest{}, fmt.Errorf("vev: invalid remote attach handoff: %w", err)
@@ -915,10 +921,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
915 if err != nil {921 if err != nil {
916 return nil, client.AttachRequest{}, err922 return nil, client.AttachRequest{}, err
917 }923 }
918- policy := target.EnvironmentPolicy924+ policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy)
919- if pickerHandoff && target.RemoteTarget == nil {
920- policy = ports.EnvironmentPolicyDaemonOwned
921- }
922 return dialer, client.AttachRequest{Intent: target.Intent, SessionName: target.Session, Remote: true, RemoteTarget: selection, EnvironmentPolicy: policy}, nil925 return dialer, client.AttachRequest{Intent: target.Intent, SessionName: target.Session, Remote: true, RemoteTarget: selection, EnvironmentPolicy: policy}, nil
923 }926 }
924 927
@@ -987,14 +990,11 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
987 name = handoffErr.Target.Session990 name = handoffErr.Target.Session
988 intent = handoffErr.Target.Intent991 intent = handoffErr.Target.Intent
989 remoteSelection = nil992 remoteSelection = nil
990- remoteEnvironmentPolicy = handoffErr.Target.EnvironmentPolicy
991- if pickerHandoff && remoteEnvironmentPolicy == ports.EnvironmentPolicyClientOwned {
992- remoteEnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
993- }
994 if handoffErr.Target.RemoteTarget != nil {993 if handoffErr.Target.RemoteTarget != nil {
995 copyTarget := *handoffErr.Target.RemoteTarget994 copyTarget := *handoffErr.Target.RemoteTarget
996 remoteSelection = &copyTarget995 remoteSelection = &copyTarget
997 }996 }
997+ remoteEnvironmentPolicy = pickerEnvironmentPolicy(remoteSelection, handoffErr.Target.EnvironmentPolicy)
998 if modeErr != nil {998 if modeErr != nil {
999 return modeErr999 return modeErr
1000 }1000 }
M internal/ports/navigation_test.go +1 -0
@@ -34,6 +34,7 @@ func TestNavigationActionWireTable(t *testing.T) {
3434 decoded, err := UnmarshalNavigationAction(got)
3535 require.NoError(t, err)
3636 require.Equal(t, tt.action, decoded)
37+ assertAllPrefixesFail(t, got, UnmarshalNavigationAction)
3738 _, err = UnmarshalNavigationAction(append(append([]byte(nil), got...), 0))
3839 require.ErrorIs(t, err, ErrInvalidNavigation)
3940 })
@@ -34,6 +34,7 @@ func TestNavigationActionWireTable(t *testing.T) {
34 decoded, err := UnmarshalNavigationAction(got)34 decoded, err := UnmarshalNavigationAction(got)
35 require.NoError(t, err)35 require.NoError(t, err)
36 require.Equal(t, tt.action, decoded)36 require.Equal(t, tt.action, decoded)
37+ assertAllPrefixesFail(t, got, UnmarshalNavigationAction)
37 _, err = UnmarshalNavigationAction(append(append([]byte(nil), got...), 0))38 _, err = UnmarshalNavigationAction(append(append([]byte(nil), got...), 0))
38 require.ErrorIs(t, err, ErrInvalidNavigation)39 require.ErrorIs(t, err, ErrInvalidNavigation)
39 })40 })
M internal/usecase/client/client.go +7 -4
@@ -387,6 +387,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
387387 }
388388
389389 for {
390+ if err := validateAttachRequest(attemptRequest); err != nil {
391+ return err
392+ }
390393 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)
391394 transport, err := boundedDial(handshakeCtx, dialer)
392395 if err != nil {
@@ -476,13 +479,13 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
476479 homeNavigationPending = true
477480 dialer = homeRoute.dialer
478481 attemptRequest = homeRoute.request
479- attemptRequest.Remote = false
482+ attemptRequest.Remote = homeRoute.request.Remote || r.remote
480483 attemptRequest.Intent = ports.IntentAttach
481484 attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack
482485 attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker
483486 attemptRequest.RemoteTarget = nil
484487 attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
485- remote = syncReconnectRemote(reconnect, false)
488+ remote = syncReconnectRemote(reconnect, homeRoute.request.Remote || r.remote)
486489 resumeToken = 0
487490 backoff = defaultReconnectBackoff.initial
488491 continue
@@ -599,9 +602,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
599602 attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker
600603 attemptRequest.RemoteTarget = nil
601604 attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
602- attemptRequest.Remote = false
605+ attemptRequest.Remote = homeRoute.request.Remote || r.remote
603606 resumeToken = 0
604- remote = syncReconnectRemote(reconnect, false)
607+ remote = syncReconnectRemote(reconnect, homeRoute.request.Remote || r.remote)
605608 backoff = defaultReconnectBackoff.initial
606609 continue
607610 }
@@ -387,6 +387,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
387 }387 }
388 388
389 for {389 for {
390+ if err := validateAttachRequest(attemptRequest); err != nil {
391+ return err
392+ }
390 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)393 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)
391 transport, err := boundedDial(handshakeCtx, dialer)394 transport, err := boundedDial(handshakeCtx, dialer)
392 if err != nil {395 if err != nil {
@@ -476,13 +479,13 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
476 homeNavigationPending = true479 homeNavigationPending = true
477 dialer = homeRoute.dialer480 dialer = homeRoute.dialer
478 attemptRequest = homeRoute.request481 attemptRequest = homeRoute.request
479- attemptRequest.Remote = false482+ attemptRequest.Remote = homeRoute.request.Remote || r.remote
480 attemptRequest.Intent = ports.IntentAttach483 attemptRequest.Intent = ports.IntentAttach
481 attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack484 attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack
482 attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker485 attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker
483 attemptRequest.RemoteTarget = nil486 attemptRequest.RemoteTarget = nil
484 attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned487 attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
485- remote = syncReconnectRemote(reconnect, false)488+ remote = syncReconnectRemote(reconnect, homeRoute.request.Remote || r.remote)
486 resumeToken = 0489 resumeToken = 0
487 backoff = defaultReconnectBackoff.initial490 backoff = defaultReconnectBackoff.initial
488 continue491 continue
@@ -599,9 +602,9 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
599 attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker602 attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker
600 attemptRequest.RemoteTarget = nil603 attemptRequest.RemoteTarget = nil
601 attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned604 attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
602- attemptRequest.Remote = false605+ attemptRequest.Remote = homeRoute.request.Remote || r.remote
603 resumeToken = 0606 resumeToken = 0
604- remote = syncReconnectRemote(reconnect, false)607+ remote = syncReconnectRemote(reconnect, homeRoute.request.Remote || r.remote)
605 backoff = defaultReconnectBackoff.initial608 backoff = defaultReconnectBackoff.initial
606 continue609 continue
607 }610 }
M internal/usecase/client/client_test.go +7 -1
@@ -67,7 +67,13 @@ func (c *reconnectTestClock) fireReconnect(t *testing.T) {
6767 timer.mu.Lock()
6868 if !timer.stopped && !timer.fired {
6969 timer.fired = true
70- timer.ch <- time.Time{}
70+ select {
71+ case timer.ch <- time.Time{}:
72+ default:
73+ timer.fired = false
74+ timer.mu.Unlock()
75+ continue
76+ }
7177 timer.mu.Unlock()
7278 return true
7379 }
@@ -67,7 +67,13 @@ func (c *reconnectTestClock) fireReconnect(t *testing.T) {
67 timer.mu.Lock()67 timer.mu.Lock()
68 if !timer.stopped && !timer.fired {68 if !timer.stopped && !timer.fired {
69 timer.fired = true69 timer.fired = true
70- timer.ch <- time.Time{}70+ select {
71+ case timer.ch <- time.Time{}:
72+ default:
73+ timer.fired = false
74+ timer.mu.Unlock()
75+ continue
76+ }
71 timer.mu.Unlock()77 timer.mu.Unlock()
72 return true78 return true
73 }79 }
M internal/usecase/client/palette_attach_generation_test.go +5 -1
@@ -43,7 +43,11 @@ func (*attachPaletteClock) Now() time.Time { return time.Time{} }
4343 func (c *attachPaletteClock) NewTimer(delay time.Duration) ports.Timer {
4444 timer := &attachPaletteTimer{ch: make(chan time.Time, 1), stopped: make(chan struct{}), duration: delay}
4545 if delay == ports.HandshakeTimeout {
46- c.handshakeTimers <- timer
46+ select {
47+ case c.handshakeTimers <- timer:
48+ default:
49+ panic("unexpected extra handshake timer")
50+ }
4751 } else {
4852 c.timers <- timer
4953 }
@@ -43,7 +43,11 @@ func (*attachPaletteClock) Now() time.Time { return time.Time{} }
43 func (c *attachPaletteClock) NewTimer(delay time.Duration) ports.Timer {43 func (c *attachPaletteClock) NewTimer(delay time.Duration) ports.Timer {
44 timer := &attachPaletteTimer{ch: make(chan time.Time, 1), stopped: make(chan struct{}), duration: delay}44 timer := &attachPaletteTimer{ch: make(chan time.Time, 1), stopped: make(chan struct{}), duration: delay}
45 if delay == ports.HandshakeTimeout {45 if delay == ports.HandshakeTimeout {
46- c.handshakeTimers <- timer46+ select {
47+ case c.handshakeTimers <- timer:
48+ default:
49+ panic("unexpected extra handshake timer")
50+ }
47 } else {51 } else {
48 c.timers <- timer52 c.timers <- timer
49 }53 }
M internal/usecase/daemon/daemon.go +2 -1
@@ -1219,14 +1219,15 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
12191219 failAttachment()
12201220 return
12211221 }
1222- postWelcomeTicket.End()
12231222 if !d.commitResumeClaim(ac) {
1223+ postWelcomeTicket.End()
12241224 failAttachment()
12251225 return
12261226 }
12271227 if ac.startupOverlay == ports.StartupOverlaySessionPicker {
12281228 d.enterPicker(sess, ac)
12291229 }
1230+ postWelcomeTicket.End()
12301231 stopHandshakeTransport()
12311232 finishHandshake()
12321233 d.runConnLoop(ac)
@@ -1219,14 +1219,15 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1219 failAttachment()1219 failAttachment()
1220 return1220 return
1221 }1221 }
1222- postWelcomeTicket.End()
1223 if !d.commitResumeClaim(ac) {1222 if !d.commitResumeClaim(ac) {
1223+ postWelcomeTicket.End()
1224 failAttachment()1224 failAttachment()
1225 return1225 return
1226 }1226 }
1227 if ac.startupOverlay == ports.StartupOverlaySessionPicker {1227 if ac.startupOverlay == ports.StartupOverlaySessionPicker {
1228 d.enterPicker(sess, ac)1228 d.enterPicker(sess, ac)
1229 }1229 }
1230+ postWelcomeTicket.End()
1230 stopHandshakeTransport()1231 stopHandshakeTransport()
1231 finishHandshake()1232 finishHandshake()
1232 d.runConnLoop(ac)1233 d.runConnLoop(ac)
M internal/usecase/daemon/picker.go +3 -1
@@ -373,6 +373,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
373373 d.registerPreviewForSelection(ac)
374374 }
375375 committing := (result.action == '\r' || result.action == '\n') && ok
376+ backNavigationSent := false
376377 if result.exit && !committing {
377378 back := ac.startupOverlay == ports.StartupOverlaySessionPicker && ac.navigationCapabilities&ports.NavigationCapabilityBack != 0
378379 if back && len(effects) != 0 && effects[0] != nil {
@@ -381,6 +382,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
381382 d.invalidateRender(sess, ac, true, "picker.go")
382383 return
383384 }
385+ backNavigationSent = true
384386 }
385387 d.closePicker(ac)
386388 }
@@ -420,7 +422,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
420422 }
421423 return
422424 }
423- if result.exit || result.changed {
425+ if (result.exit || result.changed) && !backNavigationSent {
424426 d.invalidateRender(sess, ac, true, "picker.go")
425427 }
426428 }
@@ -373,6 +373,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
373 d.registerPreviewForSelection(ac)373 d.registerPreviewForSelection(ac)
374 }374 }
375 committing := (result.action == '\r' || result.action == '\n') && ok375 committing := (result.action == '\r' || result.action == '\n') && ok
376+ backNavigationSent := false
376 if result.exit && !committing {377 if result.exit && !committing {
377 back := ac.startupOverlay == ports.StartupOverlaySessionPicker && ac.navigationCapabilities&ports.NavigationCapabilityBack != 0378 back := ac.startupOverlay == ports.StartupOverlaySessionPicker && ac.navigationCapabilities&ports.NavigationCapabilityBack != 0
378 if back && len(effects) != 0 && effects[0] != nil {379 if back && len(effects) != 0 && effects[0] != nil {
@@ -381,6 +382,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
381 d.invalidateRender(sess, ac, true, "picker.go")382 d.invalidateRender(sess, ac, true, "picker.go")
382 return383 return
383 }384 }
385+ backNavigationSent = true
384 }386 }
385 d.closePicker(ac)387 d.closePicker(ac)
386 }388 }
@@ -420,7 +422,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
420 }422 }
421 return423 return
422 }424 }
423- if result.exit || result.changed {425+ if (result.exit || result.changed) && !backNavigationSent {
424 d.invalidateRender(sess, ac, true, "picker.go")426 d.invalidateRender(sess, ac, true, "picker.go")
425 }427 }
426 }428 }

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