detached b0018c5

refactor(remote): centralize recovery routes

commit
b0018c5f23c1318ea67a46b69e592425ba72b7fb
author
brice · · 2026-08-08 19:16 UTC
parent
655f64a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +60 -58
M internal/ports/navigation_test.go +12 -8
@@ -44,15 +44,19 @@ func TestNavigationActionWireTable(t *testing.T) {
4444 func TestHelloNavigationValidationTable(t *testing.T) {
4545 base := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
4646 tests := []struct {
47- name string
48- hello Hello
49- valid bool
50- capability NavigationCapabilities
51- overlay StartupOverlay
47+ name string
48+ hello Hello
49+ valid bool
50+ capability NavigationCapabilities
51+ overlay StartupOverlay
52+ wantPayload string
5253 }{
5354 {name: "ordinary", hello: base, valid: true},
5455 {name: "resume route", hello: Hello{Version: ProtocolVersion, Intent: IntentResume, Size: domain.Size{Cols: 80, Rows: 24}}, valid: true},
55- {name: "startup picker requires back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker},
56+ {name: "daemon-owned home picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: true, capability: NavigationCapabilityHomePicker},
57+ {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
58+ {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
59+ {name: "startup picker requires back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001a02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "0201"},
5660 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
5761 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
5862 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
@@ -66,8 +70,8 @@ func TestHelloNavigationValidationTable(t *testing.T) {
6670 require.NoError(t, err)
6771 payload := MarshalHello(tt.hello)
6872 require.NotNil(t, payload)
69- if tt.name == "startup picker requires back" {
70- expected, decodeErr := hex.DecodeString("001a02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "0201")
73+ if tt.wantPayload != "" {
74+ expected, decodeErr := hex.DecodeString(tt.wantPayload)
7175 require.NoError(t, decodeErr)
7276 require.Equal(t, expected, payload)
7377 assertAllPrefixesFail(t, expected, UnmarshalHello)
@@ -44,15 +44,19 @@ func TestNavigationActionWireTable(t *testing.T) {
44 func TestHelloNavigationValidationTable(t *testing.T) {44 func TestHelloNavigationValidationTable(t *testing.T) {
45 base := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}45 base := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
46 tests := []struct {46 tests := []struct {
47- name string47+ name string
48- hello Hello48+ hello Hello
49- valid bool49+ valid bool
50- capability NavigationCapabilities50+ capability NavigationCapabilities
51- overlay StartupOverlay51+ overlay StartupOverlay
52+ wantPayload string
52 }{53 }{
53 {name: "ordinary", hello: base, valid: true},54 {name: "ordinary", hello: base, valid: true},
54 {name: "resume route", hello: Hello{Version: ProtocolVersion, Intent: IntentResume, Size: domain.Size{Cols: 80, Rows: 24}}, valid: true},55 {name: "resume route", hello: Hello{Version: ProtocolVersion, Intent: IntentResume, Size: domain.Size{Cols: 80, Rows: 24}}, valid: true},
55- {name: "startup picker requires back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker},56+ {name: "daemon-owned home picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: true, capability: NavigationCapabilityHomePicker},
57+ {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
58+ {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
59+ {name: "startup picker requires back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001a02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "0201"},
56 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},60 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
57 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},61 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
58 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},62 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
@@ -66,8 +70,8 @@ func TestHelloNavigationValidationTable(t *testing.T) {
66 require.NoError(t, err)70 require.NoError(t, err)
67 payload := MarshalHello(tt.hello)71 payload := MarshalHello(tt.hello)
68 require.NotNil(t, payload)72 require.NotNil(t, payload)
69- if tt.name == "startup picker requires back" {73+ if tt.wantPayload != "" {
70- expected, decodeErr := hex.DecodeString("001a02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "0201")74+ expected, decodeErr := hex.DecodeString(tt.wantPayload)
71 require.NoError(t, decodeErr)75 require.NoError(t, decodeErr)
72 require.Equal(t, expected, payload)76 require.Equal(t, expected, payload)
73 assertAllPrefixesFail(t, expected, UnmarshalHello)77 assertAllPrefixesFail(t, expected, UnmarshalHello)
M internal/usecase/client/client.go +32 -46
@@ -355,6 +355,34 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
355355 rawEntered: &rawEntered,
356356 stage: reconnectStageOfflineRetrying,
357357 }
358+ restoreReturnRoute := func(route attachRoute) {
359+ returnNavigationPending = true
360+ dialer = route.dialer
361+ attemptRequest = route.request
362+ if route.resumeToken != 0 {
363+ attemptRequest.Intent = ports.IntentResume
364+ } else {
365+ attemptRequest.Intent = ports.IntentAttach
366+ }
367+ attemptRequest.StartupOverlay = ports.StartupOverlayNone
368+ resumeToken = route.resumeToken
369+ returnResumeFallback = route.resumeToken != 0
370+ remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
371+ backoff = defaultReconnectBackoff.initial
372+ }
373+ enterHomePicker := func() {
374+ dialer = homeRoute.dialer
375+ attemptRequest = homeRoute.request
376+ attemptRequest.Intent = ports.IntentAttach
377+ attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack
378+ attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker
379+ attemptRequest.RemoteTarget = nil
380+ attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
381+ attemptRequest.Remote = homeRoute.request.Remote || r.remote
382+ resumeToken = 0
383+ remote = syncReconnectRemote(reconnect, homeRoute.request.Remote || r.remote)
384+ backoff = defaultReconnectBackoff.initial
385+ }
358386 var input *terminalInputPump
359387 defer func() {
360388 if input != nil {
@@ -467,17 +495,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
467495 }
468496 returnRoute = &attachRoute{dialer: dialer, request: returnRequest, resumeToken: result.resumeToken}
469497 homeNavigationPending = true
470- dialer = homeRoute.dialer
471- attemptRequest = homeRoute.request
472- attemptRequest.Remote = homeRoute.request.Remote || r.remote
473- attemptRequest.Intent = ports.IntentAttach
474- attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack
475- attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker
476- attemptRequest.RemoteTarget = nil
477- attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
478- remote = syncReconnectRemote(reconnect, homeRoute.request.Remote || r.remote)
479- resumeToken = 0
480- backoff = defaultReconnectBackoff.initial
498+ enterHomePicker()
481499 continue
482500 case ports.NavigationBack:
483501 if attemptRequest.StartupOverlay != ports.StartupOverlaySessionPicker || returnRoute == nil || attemptRequest.NavigationCapabilities&ports.NavigationCapabilityBack == 0 {
@@ -487,18 +505,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
487505 returnRoute = nil
488506 homeNavigationPending = false
489507 returnNavigationPending = true
490- dialer = route.dialer
491- attemptRequest = route.request
492- if route.resumeToken != 0 {
493- attemptRequest.Intent = ports.IntentResume
494- } else {
495- attemptRequest.Intent = ports.IntentAttach
496- }
497- attemptRequest.StartupOverlay = ports.StartupOverlayNone
498- resumeToken = route.resumeToken
499- returnResumeFallback = route.resumeToken != 0
500- remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
501- backoff = defaultReconnectBackoff.initial
508+ restoreReturnRoute(route)
502509 continue
503510 default:
504511 return errors.New("vev: unsupported navigation action")
@@ -570,18 +577,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
570577 returnRoute = nil
571578 homeNavigationPending = false
572579 returnNavigationPending = true
573- dialer = route.dialer
574- attemptRequest = route.request
575- if route.resumeToken != 0 {
576- attemptRequest.Intent = ports.IntentResume
577- } else {
578- attemptRequest.Intent = ports.IntentAttach
579- }
580- attemptRequest.StartupOverlay = ports.StartupOverlayNone
581- resumeToken = route.resumeToken
582- returnResumeFallback = route.resumeToken != 0
583- remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
584- backoff = defaultReconnectBackoff.initial
580+ restoreReturnRoute(route)
585581 continue
586582 }
587583 if returnResumeFallback {
@@ -596,17 +592,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
596592 if returnNavigationPending && !returnResumeFallback && homeRoute != nil {
597593 returnRoute = nil
598594 returnNavigationPending = false
599- dialer = homeRoute.dialer
600- attemptRequest = homeRoute.request
601- attemptRequest.Intent = ports.IntentAttach
602- attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack
603- attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker
604- attemptRequest.RemoteTarget = nil
605- attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
606- attemptRequest.Remote = homeRoute.request.Remote || r.remote
607- resumeToken = 0
608- remote = syncReconnectRemote(reconnect, homeRoute.request.Remote || r.remote)
609- backoff = defaultReconnectBackoff.initial
595+ enterHomePicker()
610596 continue
611597 }
612598 if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {
@@ -355,6 +355,34 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
355 rawEntered: &rawEntered,355 rawEntered: &rawEntered,
356 stage: reconnectStageOfflineRetrying,356 stage: reconnectStageOfflineRetrying,
357 }357 }
358+ restoreReturnRoute := func(route attachRoute) {
359+ returnNavigationPending = true
360+ dialer = route.dialer
361+ attemptRequest = route.request
362+ if route.resumeToken != 0 {
363+ attemptRequest.Intent = ports.IntentResume
364+ } else {
365+ attemptRequest.Intent = ports.IntentAttach
366+ }
367+ attemptRequest.StartupOverlay = ports.StartupOverlayNone
368+ resumeToken = route.resumeToken
369+ returnResumeFallback = route.resumeToken != 0
370+ remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
371+ backoff = defaultReconnectBackoff.initial
372+ }
373+ enterHomePicker := func() {
374+ dialer = homeRoute.dialer
375+ attemptRequest = homeRoute.request
376+ attemptRequest.Intent = ports.IntentAttach
377+ attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack
378+ attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker
379+ attemptRequest.RemoteTarget = nil
380+ attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
381+ attemptRequest.Remote = homeRoute.request.Remote || r.remote
382+ resumeToken = 0
383+ remote = syncReconnectRemote(reconnect, homeRoute.request.Remote || r.remote)
384+ backoff = defaultReconnectBackoff.initial
385+ }
358 var input *terminalInputPump386 var input *terminalInputPump
359 defer func() {387 defer func() {
360 if input != nil {388 if input != nil {
@@ -467,17 +495,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
467 }495 }
468 returnRoute = &attachRoute{dialer: dialer, request: returnRequest, resumeToken: result.resumeToken}496 returnRoute = &attachRoute{dialer: dialer, request: returnRequest, resumeToken: result.resumeToken}
469 homeNavigationPending = true497 homeNavigationPending = true
470- dialer = homeRoute.dialer498+ enterHomePicker()
471- attemptRequest = homeRoute.request
472- attemptRequest.Remote = homeRoute.request.Remote || r.remote
473- attemptRequest.Intent = ports.IntentAttach
474- attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack
475- attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker
476- attemptRequest.RemoteTarget = nil
477- attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
478- remote = syncReconnectRemote(reconnect, homeRoute.request.Remote || r.remote)
479- resumeToken = 0
480- backoff = defaultReconnectBackoff.initial
481 continue499 continue
482 case ports.NavigationBack:500 case ports.NavigationBack:
483 if attemptRequest.StartupOverlay != ports.StartupOverlaySessionPicker || returnRoute == nil || attemptRequest.NavigationCapabilities&ports.NavigationCapabilityBack == 0 {501 if attemptRequest.StartupOverlay != ports.StartupOverlaySessionPicker || returnRoute == nil || attemptRequest.NavigationCapabilities&ports.NavigationCapabilityBack == 0 {
@@ -487,18 +505,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
487 returnRoute = nil505 returnRoute = nil
488 homeNavigationPending = false506 homeNavigationPending = false
489 returnNavigationPending = true507 returnNavigationPending = true
490- dialer = route.dialer508+ restoreReturnRoute(route)
491- attemptRequest = route.request
492- if route.resumeToken != 0 {
493- attemptRequest.Intent = ports.IntentResume
494- } else {
495- attemptRequest.Intent = ports.IntentAttach
496- }
497- attemptRequest.StartupOverlay = ports.StartupOverlayNone
498- resumeToken = route.resumeToken
499- returnResumeFallback = route.resumeToken != 0
500- remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
501- backoff = defaultReconnectBackoff.initial
502 continue509 continue
503 default:510 default:
504 return errors.New("vev: unsupported navigation action")511 return errors.New("vev: unsupported navigation action")
@@ -570,18 +577,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
570 returnRoute = nil577 returnRoute = nil
571 homeNavigationPending = false578 homeNavigationPending = false
572 returnNavigationPending = true579 returnNavigationPending = true
573- dialer = route.dialer580+ restoreReturnRoute(route)
574- attemptRequest = route.request
575- if route.resumeToken != 0 {
576- attemptRequest.Intent = ports.IntentResume
577- } else {
578- attemptRequest.Intent = ports.IntentAttach
579- }
580- attemptRequest.StartupOverlay = ports.StartupOverlayNone
581- resumeToken = route.resumeToken
582- returnResumeFallback = route.resumeToken != 0
583- remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
584- backoff = defaultReconnectBackoff.initial
585 continue581 continue
586 }582 }
587 if returnResumeFallback {583 if returnResumeFallback {
@@ -596,17 +592,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
596 if returnNavigationPending && !returnResumeFallback && homeRoute != nil {592 if returnNavigationPending && !returnResumeFallback && homeRoute != nil {
597 returnRoute = nil593 returnRoute = nil
598 returnNavigationPending = false594 returnNavigationPending = false
599- dialer = homeRoute.dialer595+ enterHomePicker()
600- attemptRequest = homeRoute.request
601- attemptRequest.Intent = ports.IntentAttach
602- attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack
603- attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker
604- attemptRequest.RemoteTarget = nil
605- attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
606- attemptRequest.Remote = homeRoute.request.Remote || r.remote
607- resumeToken = 0
608- remote = syncReconnectRemote(reconnect, homeRoute.request.Remote || r.remote)
609- backoff = defaultReconnectBackoff.initial
610 continue596 continue
611 }597 }
612 if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {598 if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {
M internal/usecase/daemon/attachment_effect_gate.go +14 -2
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "context"
45 "sync"
56 "sync/atomic"
67
@@ -219,6 +220,13 @@ func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken)
219220 // after Welcome has completed so a replacement blocked behind that send can
220221 // publish before readiness or first paint.
221222 func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
223+ return ac.beginCurrentAttachmentEffectContext(context.Background(), sess, tr)
224+}
225+
226+func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Context, sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
227+ if ctx == nil {
228+ ctx = context.Background()
229+ }
222230 if ac == nil || sess == nil || tr == nil {
223231 return attachmentConnectionToken{}, nil, false
224232 }
@@ -237,8 +245,12 @@ func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.T
237245 if g.phase == attachmentEffectsFrozen {
238246 changed := g.changed
239247 g.mu.Unlock()
240- <-changed
241- continue
248+ select {
249+ case <-changed:
250+ continue
251+ case <-ctx.Done():
252+ return token, nil, false
253+ }
242254 }
243255 g.mu.Unlock()
244256 // A stable mismatch is only recoverable when a publication raced token
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
4 "sync"5 "sync"
5 "sync/atomic"6 "sync/atomic"
6 7
@@ -219,6 +220,13 @@ func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken)
219 // after Welcome has completed so a replacement blocked behind that send can220 // after Welcome has completed so a replacement blocked behind that send can
220 // publish before readiness or first paint.221 // publish before readiness or first paint.
221 func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {222 func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
223+ return ac.beginCurrentAttachmentEffectContext(context.Background(), sess, tr)
224+}
225+
226+func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Context, sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
227+ if ctx == nil {
228+ ctx = context.Background()
229+ }
222 if ac == nil || sess == nil || tr == nil {230 if ac == nil || sess == nil || tr == nil {
223 return attachmentConnectionToken{}, nil, false231 return attachmentConnectionToken{}, nil, false
224 }232 }
@@ -237,8 +245,12 @@ func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.T
237 if g.phase == attachmentEffectsFrozen {245 if g.phase == attachmentEffectsFrozen {
238 changed := g.changed246 changed := g.changed
239 g.mu.Unlock()247 g.mu.Unlock()
240- <-changed248+ select {
241- continue249+ case <-changed:
250+ continue
251+ case <-ctx.Done():
252+ return token, nil, false
253+ }
242 }254 }
243 g.mu.Unlock()255 g.mu.Unlock()
244 // A stable mismatch is only recoverable when a publication raced token256 // A stable mismatch is only recoverable when a publication raced token
M internal/usecase/daemon/daemon.go +1 -1
@@ -1183,7 +1183,7 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
11831183 // Release Welcome's effect before discovering post-handshake authority so a
11841184 // replacement blocked behind the send can publish its generation and lease.
11851185 welcomeTicket.End()
1186- postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffect(sess, tr)
1186+ postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffectContext(handshakeCtx, sess, tr)
11871187 if !admitted {
11881188 failAttachment()
11891189 return
@@ -1183,7 +1183,7 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1183 // Release Welcome's effect before discovering post-handshake authority so a1183 // Release Welcome's effect before discovering post-handshake authority so a
1184 // replacement blocked behind the send can publish its generation and lease.1184 // replacement blocked behind the send can publish its generation and lease.
1185 welcomeTicket.End()1185 welcomeTicket.End()
1186- postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffect(sess, tr)1186+ postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffectContext(handshakeCtx, sess, tr)
1187 if !admitted {1187 if !admitted {
1188 failAttachment()1188 failAttachment()
1189 return1189 return
M internal/usecase/daemon/picker.go +1 -1
@@ -879,7 +879,7 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
879879 }
880880 token.effect.bindActionEnd(d, action)
881881 token.effect.End()
882- if token.sess == nil {
882+ if !token.attachmentCurrent() {
883883 return errAttachmentTransition
884884 }
885885 return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
@@ -879,7 +879,7 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
879 }879 }
880 token.effect.bindActionEnd(d, action)880 token.effect.bindActionEnd(d, action)
881 token.effect.End()881 token.effect.End()
882- if token.sess == nil {882+ if !token.attachmentCurrent() {
883 return errAttachmentTransition883 return errAttachmentTransition
884 }884 }
885 return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)885 return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)

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