detached 655f64a

fix(remote): finish navigation recovery review

commit
655f64a27a63fbfc059e5a01564d9f15a829ecb3
author
brice · · 2026-08-08 19:05 UTC
parent
ab88dd5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +50 -17
M internal/ports/frame_test.go +3 -0
@@ -21,6 +21,9 @@ func TestControlMsgTypes(t *testing.T) {
2121 }
2222
2323 func TestMsgTypeUnique(t *testing.T) {
24+ if MsgNavigationAction != 23 {
25+ t.Fatalf("MsgNavigationAction = %d, want 23", MsgNavigationAction)
26+ }
2427 tests := []struct {
2528 name string
2629 typ MsgType
@@ -21,6 +21,9 @@ func TestControlMsgTypes(t *testing.T) {
21 }21 }
22 22
23 func TestMsgTypeUnique(t *testing.T) {23 func TestMsgTypeUnique(t *testing.T) {
24+ if MsgNavigationAction != 23 {
25+ t.Fatalf("MsgNavigationAction = %d, want 23", MsgNavigationAction)
26+ }
24 tests := []struct {27 tests := []struct {
25 name string28 name string
26 typ MsgType29 typ MsgType
M internal/usecase/client/client.go +6 -2
@@ -570,12 +570,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
570570 returnRoute = nil
571571 homeNavigationPending = false
572572 returnNavigationPending = true
573- returnResumeFallback = true
574573 dialer = route.dialer
575574 attemptRequest = route.request
576- attemptRequest.Intent = ports.IntentResume
575+ if route.resumeToken != 0 {
576+ attemptRequest.Intent = ports.IntentResume
577+ } else {
578+ attemptRequest.Intent = ports.IntentAttach
579+ }
577580 attemptRequest.StartupOverlay = ports.StartupOverlayNone
578581 resumeToken = route.resumeToken
582+ returnResumeFallback = route.resumeToken != 0
579583 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
580584 backoff = defaultReconnectBackoff.initial
581585 continue
@@ -570,12 +570,16 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
570 returnRoute = nil570 returnRoute = nil
571 homeNavigationPending = false571 homeNavigationPending = false
572 returnNavigationPending = true572 returnNavigationPending = true
573- returnResumeFallback = true
574 dialer = route.dialer573 dialer = route.dialer
575 attemptRequest = route.request574 attemptRequest = route.request
576- attemptRequest.Intent = ports.IntentResume575+ if route.resumeToken != 0 {
576+ attemptRequest.Intent = ports.IntentResume
577+ } else {
578+ attemptRequest.Intent = ports.IntentAttach
579+ }
577 attemptRequest.StartupOverlay = ports.StartupOverlayNone580 attemptRequest.StartupOverlay = ports.StartupOverlayNone
578 resumeToken = route.resumeToken581 resumeToken = route.resumeToken
582+ returnResumeFallback = route.resumeToken != 0
579 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)583 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
580 backoff = defaultReconnectBackoff.initial584 backoff = defaultReconnectBackoff.initial
581 continue585 continue
M internal/usecase/daemon/daemon.go +8 -4
@@ -1170,9 +1170,11 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
11701170 failAttachment()
11711171 return
11721172 }
1173- if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1173+ welcomeDone, welcomeErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
11741174 return ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, d.resumeTokenSnapshot(ac)), welcomeTicket)
1175- }); err != nil {
1175+ })
1176+ if welcomeErr != nil {
1177+ <-welcomeDone
11761178 welcomeTicket.End()
11771179 failAttachment()
11781180 return
@@ -1202,10 +1204,12 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
12021204 }
12031205 paintToken := sess.attachmentToken(ac, tr)
12041206 painted := make(chan bool, 1)
1205- if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1207+ paintDone, paintErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
12061208 painted <- d.firstPaintForTransition(paintToken)
12071209 return nil
1208- }); err != nil {
1210+ })
1211+ if paintErr != nil {
1212+ <-paintDone
12091213 postWelcomeTicket.End()
12101214 failAttachment()
12111215 return
@@ -1170,9 +1170,11 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1170 failAttachment()1170 failAttachment()
1171 return1171 return
1172 }1172 }
1173- if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {1173+ welcomeDone, welcomeErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1174 return ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, d.resumeTokenSnapshot(ac)), welcomeTicket)1174 return ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, d.resumeTokenSnapshot(ac)), welcomeTicket)
1175- }); err != nil {1175+ })
1176+ if welcomeErr != nil {
1177+ <-welcomeDone
1176 welcomeTicket.End()1178 welcomeTicket.End()
1177 failAttachment()1179 failAttachment()
1178 return1180 return
@@ -1202,10 +1204,12 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1202 }1204 }
1203 paintToken := sess.attachmentToken(ac, tr)1205 paintToken := sess.attachmentToken(ac, tr)
1204 painted := make(chan bool, 1)1206 painted := make(chan bool, 1)
1205- if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {1207+ paintDone, paintErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1206 painted <- d.firstPaintForTransition(paintToken)1208 painted <- d.firstPaintForTransition(paintToken)
1207 return nil1209 return nil
1208- }); err != nil {1210+ })
1211+ if paintErr != nil {
1212+ <-paintDone
1209 postWelcomeTicket.End()1213 postWelcomeTicket.End()
1210 failAttachment()1214 failAttachment()
1211 return1215 return
M internal/usecase/daemon/handshake.go +19 -5
@@ -75,24 +75,38 @@ func watchHandshakeTransport(ctx context.Context, transport ports.Transport) fun
7575 // handshake context remains live. Closing the transport is the interruption
7676 // mechanism because ports.Transport methods do not accept contexts.
7777 func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, operation func() error) error {
78+ _, err := boundedHandshakeOperationTracked(ctx, transport, operation)
79+ return err
80+}
81+
82+// boundedHandshakeOperationTracked also returns a completion signal for the
83+// operation worker. Callers that own an effect gate must wait for this signal
84+// before releasing the gate after cancellation; the transport operation itself
85+// may still be unwinding after Close interrupts it.
86+func boundedHandshakeOperationTracked(ctx context.Context, transport ports.Transport, operation func() error) (<-chan struct{}, error) {
87+ done := make(chan struct{})
7888 if err := ctx.Err(); err != nil {
89+ close(done)
7990 _ = transport.Close()
80- return err
91+ return done, err
8192 }
8293 completed := make(chan error, 1)
83- go func() { completed <- operation() }()
94+ go func() {
95+ defer close(done)
96+ completed <- operation()
97+ }()
8498 select {
8599 case err := <-completed:
86100 if ctxErr := ctx.Err(); ctxErr != nil {
87101 _ = transport.Close()
88- return ctxErr
102+ return done, ctxErr
89103 }
90- return err
104+ return done, err
91105 case <-ctx.Done():
92106 _ = transport.Close()
93107 // The result channel is buffered, so the operation can publish its
94108 // completion after Close without keeping this cancellation path stuck.
95- return ctx.Err()
109+ return done, ctx.Err()
96110 }
97111 }
98112
@@ -75,24 +75,38 @@ func watchHandshakeTransport(ctx context.Context, transport ports.Transport) fun
75 // handshake context remains live. Closing the transport is the interruption75 // handshake context remains live. Closing the transport is the interruption
76 // mechanism because ports.Transport methods do not accept contexts.76 // mechanism because ports.Transport methods do not accept contexts.
77 func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, operation func() error) error {77 func boundedHandshakeOperation(ctx context.Context, transport ports.Transport, operation func() error) error {
78+ _, err := boundedHandshakeOperationTracked(ctx, transport, operation)
79+ return err
80+}
81+
82+// boundedHandshakeOperationTracked also returns a completion signal for the
83+// operation worker. Callers that own an effect gate must wait for this signal
84+// before releasing the gate after cancellation; the transport operation itself
85+// may still be unwinding after Close interrupts it.
86+func boundedHandshakeOperationTracked(ctx context.Context, transport ports.Transport, operation func() error) (<-chan struct{}, error) {
87+ done := make(chan struct{})
78 if err := ctx.Err(); err != nil {88 if err := ctx.Err(); err != nil {
89+ close(done)
79 _ = transport.Close()90 _ = transport.Close()
80- return err91+ return done, err
81 }92 }
82 completed := make(chan error, 1)93 completed := make(chan error, 1)
83- go func() { completed <- operation() }()94+ go func() {
95+ defer close(done)
96+ completed <- operation()
97+ }()
84 select {98 select {
85 case err := <-completed:99 case err := <-completed:
86 if ctxErr := ctx.Err(); ctxErr != nil {100 if ctxErr := ctx.Err(); ctxErr != nil {
87 _ = transport.Close()101 _ = transport.Close()
88- return ctxErr102+ return done, ctxErr
89 }103 }
90- return err104+ return done, err
91 case <-ctx.Done():105 case <-ctx.Done():
92 _ = transport.Close()106 _ = transport.Close()
93 // The result channel is buffered, so the operation can publish its107 // The result channel is buffered, so the operation can publish its
94 // completion after Close without keeping this cancellation path stuck.108 // completion after Close without keeping this cancellation path stuck.
95- return ctx.Err()109+ return done, ctx.Err()
96 }110 }
97 }111 }
98 112
M internal/usecase/daemon/picker.go +10 -4
@@ -890,15 +890,21 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
890890 // the target is sent, the local attachment is detached and the client opens a
891891 // fresh connection to the owning daemon.
892892 func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
893+ failUnavailable := func() error {
894+ if token.attachmentCurrent() {
895+ d.notifyRemotePickerUnavailable(token.sess, target)
896+ }
897+ return errAttachmentTransition
898+ }
893899 var handoff ports.AttachTarget
894900 if target.RemoteTarget != nil {
895901 remoteTarget := *target.RemoteTarget
896902 if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
897- return errAttachmentTransition
903+ return failUnavailable()
898904 }
899905 if target.RemoteKey != nil {
900906 if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
901- return errAttachmentTransition
907+ return failUnavailable()
902908 }
903909 }
904910 handoff = ports.AttachTarget{
@@ -910,13 +916,13 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionT
910916 }
911917 } else {
912918 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
913- return errAttachmentTransition
919+ return failUnavailable()
914920 }
915921 handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}
916922 }
917923 payload := ports.MarshalAttachTarget(handoff)
918924 if payload == nil {
919- return errAttachmentTransition
925+ return failUnavailable()
920926 }
921927 if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
922928 return err
@@ -890,15 +890,21 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
890 // the target is sent, the local attachment is detached and the client opens a890 // the target is sent, the local attachment is detached and the client opens a
891 // fresh connection to the owning daemon.891 // fresh connection to the owning daemon.
892 func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {892 func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
893+ failUnavailable := func() error {
894+ if token.attachmentCurrent() {
895+ d.notifyRemotePickerUnavailable(token.sess, target)
896+ }
897+ return errAttachmentTransition
898+ }
893 var handoff ports.AttachTarget899 var handoff ports.AttachTarget
894 if target.RemoteTarget != nil {900 if target.RemoteTarget != nil {
895 remoteTarget := *target.RemoteTarget901 remoteTarget := *target.RemoteTarget
896 if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {902 if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
897- return errAttachmentTransition903+ return failUnavailable()
898 }904 }
899 if target.RemoteKey != nil {905 if target.RemoteKey != nil {
900 if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {906 if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
901- return errAttachmentTransition907+ return failUnavailable()
902 }908 }
903 }909 }
904 handoff = ports.AttachTarget{910 handoff = ports.AttachTarget{
@@ -910,13 +916,13 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionT
910 }916 }
911 } else {917 } else {
912 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {918 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
913- return errAttachmentTransition919+ return failUnavailable()
914 }920 }
915 handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}921 handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}
916 }922 }
917 payload := ports.MarshalAttachTarget(handoff)923 payload := ports.MarshalAttachTarget(handoff)
918 if payload == nil {924 if payload == nil {
919- return errAttachmentTransition925+ return failUnavailable()
920 }926 }
921 if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {927 if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
922 return err928 return err
M internal/usecase/daemon/remote_attach_test.go +1 -1
@@ -71,7 +71,7 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
7171 require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
7272 select {
7373 case frame := <-sends:
74- t.Fatalf("rejected handoff sent frame type %d", frame.Type)
74+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type, "rejected handoff must not send an attach target")
7575 default:
7676 }
7777 }
@@ -71,7 +71,7 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
71 require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)71 require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
72 select {72 select {
73 case frame := <-sends:73 case frame := <-sends:
74- t.Fatalf("rejected handoff sent frame type %d", frame.Type)74+ require.NotEqual(t, ports.MsgAttachTarget, frame.Type, "rejected handoff must not send an attach target")
75 default:75 default:
76 }76 }
77 }77 }
M internal/usecase/daemon/remote_route_test.go +2 -0
@@ -37,6 +37,8 @@ func TestFinishRouteAttachRollsBackCreatedSession(t *testing.T) {
3737 Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: defaultSize,
3838 RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
3939 }
40+ // finishRouteAttach follows the locked-caller contract: it releases d.mu
41+ // on both the success and error paths, so retain the lock across the call.
4042 d.mu.Lock()
4143 _, err = d.finishRouteAttach(sess, &closeTrackingTransport{}, defaultSize, terminalEnv{}, hello, true, true)
4244 var protocol *protoErr
@@ -37,6 +37,8 @@ func TestFinishRouteAttachRollsBackCreatedSession(t *testing.T) {
37 Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: defaultSize,37 Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: defaultSize,
38 RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,38 RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
39 }39 }
40+ // finishRouteAttach follows the locked-caller contract: it releases d.mu
41+ // on both the success and error paths, so retain the lock across the call.
40 d.mu.Lock()42 d.mu.Lock()
41 _, err = d.finishRouteAttach(sess, &closeTrackingTransport{}, defaultSize, terminalEnv{}, hello, true, true)43 _, err = d.finishRouteAttach(sess, &closeTrackingTransport{}, defaultSize, terminalEnv{}, hello, true, true)
42 var protocol *protoErr44 var protocol *protoErr
M internal/usecase/daemon/resume.go +1 -1
@@ -193,7 +193,7 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
193193 }
194194
195195 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
196- if !ac.resumeCapable {
196+ if sess == nil || ac == nil || !ac.resumeCapable {
197197 return false
198198 }
199199 d.mu.Lock()
@@ -193,7 +193,7 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
193 }193 }
194 194
195 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {195 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
196- if !ac.resumeCapable {196+ if sess == nil || ac == nil || !ac.resumeCapable {
197 return false197 return false
198 }198 }
199 d.mu.Lock()199 d.mu.Lock()

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