detached 17d2014

fix(routes): guard navigation commits and welcome framing

commit
17d2014ea9d47761aff1a71e33e0be0a4b634b2a
author
brice · · 2026-08-09 19:10 UTC
parent
d91224b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +73 -48
M internal/usecase/client/client.go +58 -44
@@ -344,6 +344,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
344344 returnNavigationPending := false
345345 routeNavigationPending := false
346346 routeNavigationResumeFallback := false
347+ var routeNavigationSelection *routeNavigationSelection
347348 var routeNavigationFallback *attachRoute
348349 var routeNavigationAction *ports.RouteNavigationAction
349350 backoff := defaultReconnectBackoff.initial
@@ -496,23 +497,24 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
496497 linkEvents = reporter.LinkEvents()
497498 }
498499 result := (&attachAttempt{
499- runner: r,
500- dialer: dialer,
501- connection: connection,
502- remote: remote,
503- handshakeCtx: handshakeCtx,
504- handshakeTimedOut: timedOut,
505- finishHandshake: finishHandshake,
506- stopHandshakeTransport: stopHandshakeTransport,
507- request: attemptRequest,
508- resumeToken: resumeToken,
509- clientID: processClientID,
510- milestones: &ms,
511- themeState: themeState,
512- enterRaw: enterRaw,
513- reconnect: reconnect,
514- linkEvents: linkEvents,
515- rememberRemoteHost: rememberRemoteHost,
500+ runner: r,
501+ dialer: dialer,
502+ connection: connection,
503+ remote: remote,
504+ handshakeCtx: handshakeCtx,
505+ handshakeTimedOut: timedOut,
506+ finishHandshake: finishHandshake,
507+ stopHandshakeTransport: stopHandshakeTransport,
508+ request: attemptRequest,
509+ resumeToken: resumeToken,
510+ routeNavigationSelection: routeNavigationSelection,
511+ clientID: processClientID,
512+ milestones: &ms,
513+ themeState: themeState,
514+ enterRaw: enterRaw,
515+ reconnect: reconnect,
516+ linkEvents: linkEvents,
517+ rememberRemoteHost: rememberRemoteHost,
516518 terminalInput: func() *terminalInputPump {
517519 return input
518520 },
@@ -541,6 +543,13 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
541543 }
542544 attemptRequest.ExactTarget = &identity.Target
543545 }
546+ if result.err == nil && result.welcomed && routeNavigationPending {
547+ routeNavigationPending = false
548+ routeNavigationSelection = nil
549+ routeNavigationResumeFallback = false
550+ routeNavigationFallback = nil
551+ routeNavigationAction = nil
552+ }
544553 if result.routeAction != nil {
545554 if r.ledger == nil {
546555 return errors.New("vev: route ledger unavailable")
@@ -572,6 +581,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
572581 }
573582 resumeToken = selection.selected.resumeToken
574583 routeNavigationPending = true
584+ routeNavigationSelection = &selection
575585 routeNavigationResumeFallback = selection.selected.resumeToken != 0
576586 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
577587 backoff = defaultReconnectBackoff.initial
@@ -647,12 +657,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
647657 continue
648658 }
649659 if result.err == nil {
650- if result.welcomed && routeNavigationPending {
651- routeNavigationPending = false
652- routeNavigationResumeFallback = false
653- routeNavigationFallback = nil
654- routeNavigationAction = nil
655- }
656660 if result.welcomed && returnNavigationPending {
657661 returnNavigationPending = false
658662 returnResumeFallback = false
@@ -691,6 +695,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
691695 route := *routeNavigationFallback
692696 routeNavigationFallback = nil
693697 routeNavigationPending = false
698+ routeNavigationSelection = nil
694699 routeNavigationResumeFallback = false
695700 returnNavigationPending = true
696701 restoreReturnRoute(route)
@@ -860,25 +865,26 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
860865 }
861866
862867 type attachAttempt struct {
863- runner *Runner
864- dialer ports.Dialer
865- connection *SessionConnection
866- remote bool
867- transport ports.Transport
868- handshakeCtx context.Context
869- handshakeTimedOut <-chan struct{}
870- finishHandshake func()
871- stopHandshakeTransport func()
872- request AttachRequest
873- resumeToken uint64
874- clientID [16]byte
875- milestones *milestones
876- themeState *terminalThemeState
877- enterRaw func() error
878- reconnect *reconnectUI
879- linkEvents <-chan ports.LinkEvent
880- rememberRemoteHost func()
881- terminalInput func() *terminalInputPump
868+ runner *Runner
869+ dialer ports.Dialer
870+ connection *SessionConnection
871+ remote bool
872+ transport ports.Transport
873+ handshakeCtx context.Context
874+ handshakeTimedOut <-chan struct{}
875+ finishHandshake func()
876+ stopHandshakeTransport func()
877+ request AttachRequest
878+ resumeToken uint64
879+ routeNavigationSelection *routeNavigationSelection
880+ clientID [16]byte
881+ milestones *milestones
882+ themeState *terminalThemeState
883+ enterRaw func() error
884+ reconnect *reconnectUI
885+ linkEvents <-chan ports.LinkEvent
886+ rememberRemoteHost func()
887+ terminalInput func() *terminalInputPump
882888 }
883889
884890 type attachResult struct {
@@ -1084,8 +1090,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10841090 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {
10851091 return welcomedResult(errRouteTargetChanged)
10861092 }
1087- if _, err := a.runner.ledger.commitAttach(routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)); err != nil {
1088- return welcomedResult(fmt.Errorf("vev: committing route identity: %w", err))
1093+ candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)
1094+ var commitErr error
1095+ if a.routeNavigationSelection != nil {
1096+ selection := *a.routeNavigationSelection
1097+ commitErr = a.runner.ledger.commitTransition(selection.selected.identity, selection.snapshotGeneration, selection.active, candidate)
1098+ } else {
1099+ _, commitErr = a.runner.ledger.commitAttach(candidate)
1100+ }
1101+ if commitErr != nil {
1102+ return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))
10891103 }
10901104 payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
10911105 if err != nil {
@@ -344,6 +344,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
344 returnNavigationPending := false344 returnNavigationPending := false
345 routeNavigationPending := false345 routeNavigationPending := false
346 routeNavigationResumeFallback := false346 routeNavigationResumeFallback := false
347+ var routeNavigationSelection *routeNavigationSelection
347 var routeNavigationFallback *attachRoute348 var routeNavigationFallback *attachRoute
348 var routeNavigationAction *ports.RouteNavigationAction349 var routeNavigationAction *ports.RouteNavigationAction
349 backoff := defaultReconnectBackoff.initial350 backoff := defaultReconnectBackoff.initial
@@ -496,23 +497,24 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
496 linkEvents = reporter.LinkEvents()497 linkEvents = reporter.LinkEvents()
497 }498 }
498 result := (&attachAttempt{499 result := (&attachAttempt{
499- runner: r,500+ runner: r,
500- dialer: dialer,501+ dialer: dialer,
501- connection: connection,502+ connection: connection,
502- remote: remote,503+ remote: remote,
503- handshakeCtx: handshakeCtx,504+ handshakeCtx: handshakeCtx,
504- handshakeTimedOut: timedOut,505+ handshakeTimedOut: timedOut,
505- finishHandshake: finishHandshake,506+ finishHandshake: finishHandshake,
506- stopHandshakeTransport: stopHandshakeTransport,507+ stopHandshakeTransport: stopHandshakeTransport,
507- request: attemptRequest,508+ request: attemptRequest,
508- resumeToken: resumeToken,509+ resumeToken: resumeToken,
509- clientID: processClientID,510+ routeNavigationSelection: routeNavigationSelection,
510- milestones: &ms,511+ clientID: processClientID,
511- themeState: themeState,512+ milestones: &ms,
512- enterRaw: enterRaw,513+ themeState: themeState,
513- reconnect: reconnect,514+ enterRaw: enterRaw,
514- linkEvents: linkEvents,515+ reconnect: reconnect,
515- rememberRemoteHost: rememberRemoteHost,516+ linkEvents: linkEvents,
517+ rememberRemoteHost: rememberRemoteHost,
516 terminalInput: func() *terminalInputPump {518 terminalInput: func() *terminalInputPump {
517 return input519 return input
518 },520 },
@@ -541,6 +543,13 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
541 }543 }
542 attemptRequest.ExactTarget = &identity.Target544 attemptRequest.ExactTarget = &identity.Target
543 }545 }
546+ if result.err == nil && result.welcomed && routeNavigationPending {
547+ routeNavigationPending = false
548+ routeNavigationSelection = nil
549+ routeNavigationResumeFallback = false
550+ routeNavigationFallback = nil
551+ routeNavigationAction = nil
552+ }
544 if result.routeAction != nil {553 if result.routeAction != nil {
545 if r.ledger == nil {554 if r.ledger == nil {
546 return errors.New("vev: route ledger unavailable")555 return errors.New("vev: route ledger unavailable")
@@ -572,6 +581,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
572 }581 }
573 resumeToken = selection.selected.resumeToken582 resumeToken = selection.selected.resumeToken
574 routeNavigationPending = true583 routeNavigationPending = true
584+ routeNavigationSelection = &selection
575 routeNavigationResumeFallback = selection.selected.resumeToken != 0585 routeNavigationResumeFallback = selection.selected.resumeToken != 0
576 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)586 remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
577 backoff = defaultReconnectBackoff.initial587 backoff = defaultReconnectBackoff.initial
@@ -647,12 +657,6 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
647 continue657 continue
648 }658 }
649 if result.err == nil {659 if result.err == nil {
650- if result.welcomed && routeNavigationPending {
651- routeNavigationPending = false
652- routeNavigationResumeFallback = false
653- routeNavigationFallback = nil
654- routeNavigationAction = nil
655- }
656 if result.welcomed && returnNavigationPending {660 if result.welcomed && returnNavigationPending {
657 returnNavigationPending = false661 returnNavigationPending = false
658 returnResumeFallback = false662 returnResumeFallback = false
@@ -691,6 +695,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
691 route := *routeNavigationFallback695 route := *routeNavigationFallback
692 routeNavigationFallback = nil696 routeNavigationFallback = nil
693 routeNavigationPending = false697 routeNavigationPending = false
698+ routeNavigationSelection = nil
694 routeNavigationResumeFallback = false699 routeNavigationResumeFallback = false
695 returnNavigationPending = true700 returnNavigationPending = true
696 restoreReturnRoute(route)701 restoreReturnRoute(route)
@@ -860,25 +865,26 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
860 }865 }
861 866
862 type attachAttempt struct {867 type attachAttempt struct {
863- runner *Runner868+ runner *Runner
864- dialer ports.Dialer869+ dialer ports.Dialer
865- connection *SessionConnection870+ connection *SessionConnection
866- remote bool871+ remote bool
867- transport ports.Transport872+ transport ports.Transport
868- handshakeCtx context.Context873+ handshakeCtx context.Context
869- handshakeTimedOut <-chan struct{}874+ handshakeTimedOut <-chan struct{}
870- finishHandshake func()875+ finishHandshake func()
871- stopHandshakeTransport func()876+ stopHandshakeTransport func()
872- request AttachRequest877+ request AttachRequest
873- resumeToken uint64878+ resumeToken uint64
874- clientID [16]byte879+ routeNavigationSelection *routeNavigationSelection
875- milestones *milestones880+ clientID [16]byte
876- themeState *terminalThemeState881+ milestones *milestones
877- enterRaw func() error882+ themeState *terminalThemeState
878- reconnect *reconnectUI883+ enterRaw func() error
879- linkEvents <-chan ports.LinkEvent884+ reconnect *reconnectUI
880- rememberRemoteHost func()885+ linkEvents <-chan ports.LinkEvent
881- terminalInput func() *terminalInputPump886+ rememberRemoteHost func()
887+ terminalInput func() *terminalInputPump
882 }888 }
883 889
884 type attachResult struct {890 type attachResult struct {
@@ -1084,8 +1090,16 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1084 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {1090 if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {
1085 return welcomedResult(errRouteTargetChanged)1091 return welcomedResult(errRouteTargetChanged)
1086 }1092 }
1087- if _, err := a.runner.ledger.commitAttach(routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)); err != nil {1093+ candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)
1088- return welcomedResult(fmt.Errorf("vev: committing route identity: %w", err))1094+ var commitErr error
1095+ if a.routeNavigationSelection != nil {
1096+ selection := *a.routeNavigationSelection
1097+ commitErr = a.runner.ledger.commitTransition(selection.selected.identity, selection.snapshotGeneration, selection.active, candidate)
1098+ } else {
1099+ _, commitErr = a.runner.ledger.commitAttach(candidate)
1100+ }
1101+ if commitErr != nil {
1102+ return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))
1089 }1103 }
1090 payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())1104 payload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
1091 if err != nil {1105 if err != nil {
M internal/usecase/daemon/daemon.go +5 -1
@@ -1172,7 +1172,11 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
11721172 return
11731173 }
11741174 welcomeDone, welcomeErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1175- return ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, d.resumeTokenSnapshot(ac)), welcomeTicket)
1175+ frame, err := frameWelcome(sess, d.resumeTokenSnapshot(ac))
1176+ if err != nil {
1177+ return err
1178+ }
1179+ return ac.sendExpectedTransportForAttachment(expected, frame, welcomeTicket)
11761180 })
11771181 if welcomeErr != nil {
11781182 <-welcomeDone
@@ -1172,7 +1172,11 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1172 return1172 return
1173 }1173 }
1174 welcomeDone, welcomeErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {1174 welcomeDone, welcomeErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1175- return ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, d.resumeTokenSnapshot(ac)), welcomeTicket)1175+ frame, err := frameWelcome(sess, d.resumeTokenSnapshot(ac))
1176+ if err != nil {
1177+ return err
1178+ }
1179+ return ac.sendExpectedTransportForAttachment(expected, frame, welcomeTicket)
1176 })1180 })
1177 if welcomeErr != nil {1181 if welcomeErr != nil {
1178 <-welcomeDone1182 <-welcomeDone
M internal/usecase/daemon/wire.go +6 -2
@@ -6,7 +6,7 @@ import (
66 "github.com/bnema/vev/internal/ports"
77 )
88
9-func frameWelcome(s *session, resumeToken uint64) ports.Frame {
9+func frameWelcome(s *session, resumeToken uint64) (ports.Frame, error) {
1010 s.mu.Lock()
1111 var identity *ports.CommittedRouteIdentity
1212 if s.incarnation != (domain.SessionLifecycleID{}) {
@@ -24,7 +24,11 @@ func frameWelcome(s *session, resumeToken uint64) ports.Frame {
2424 }
2525 s.mu.Unlock()
2626 w.ResumeToken = resumeToken
27- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
27+ payload := ports.MarshalWelcome(w)
28+ if payload == nil {
29+ return ports.Frame{}, ports.ErrInvalidRouteWire
30+ }
31+ return ports.Frame{Type: ports.MsgWelcome, Payload: payload}, nil
2832 }
2933
3034 func frameError(code uint16, text string) ports.Frame {
@@ -6,7 +6,7 @@ import (
6 "github.com/bnema/vev/internal/ports"6 "github.com/bnema/vev/internal/ports"
7 )7 )
8 8
9-func frameWelcome(s *session, resumeToken uint64) ports.Frame {9+func frameWelcome(s *session, resumeToken uint64) (ports.Frame, error) {
10 s.mu.Lock()10 s.mu.Lock()
11 var identity *ports.CommittedRouteIdentity11 var identity *ports.CommittedRouteIdentity
12 if s.incarnation != (domain.SessionLifecycleID{}) {12 if s.incarnation != (domain.SessionLifecycleID{}) {
@@ -24,7 +24,11 @@ func frameWelcome(s *session, resumeToken uint64) ports.Frame {
24 }24 }
25 s.mu.Unlock()25 s.mu.Unlock()
26 w.ResumeToken = resumeToken26 w.ResumeToken = resumeToken
27- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}27+ payload := ports.MarshalWelcome(w)
28+ if payload == nil {
29+ return ports.Frame{}, ports.ErrInvalidRouteWire
30+ }
31+ return ports.Frame{Type: ports.MsgWelcome, Payload: payload}, nil
28 }32 }
29 33
30 func frameError(code uint16, text string) ports.Frame {34 func frameError(code uint16, text string) ports.Frame {
M internal/usecase/daemon/wire_metadata_test.go +4 -1
@@ -23,7 +23,10 @@ func TestFrameWelcomeSnapshotsSessionMetadataUnderLock(t *testing.T) {
2323 })
2424
2525 for range 1000 {
26- frame := frameWelcome(sess, ac.resumeToken)
26+ frame, frameErr := frameWelcome(sess, ac.resumeToken)
27+ if frameErr != nil {
28+ t.Fatalf("frameWelcome() error = %v", frameErr)
29+ }
2730 welcome, err := ports.UnmarshalWelcome(frame.Payload)
2831 if err != nil {
2932 t.Fatalf("UnmarshalWelcome() error = %v", err)
@@ -23,7 +23,10 @@ func TestFrameWelcomeSnapshotsSessionMetadataUnderLock(t *testing.T) {
23 })23 })
24 24
25 for range 1000 {25 for range 1000 {
26- frame := frameWelcome(sess, ac.resumeToken)26+ frame, frameErr := frameWelcome(sess, ac.resumeToken)
27+ if frameErr != nil {
28+ t.Fatalf("frameWelcome() error = %v", frameErr)
29+ }
27 welcome, err := ports.UnmarshalWelcome(frame.Payload)30 welcome, err := ports.UnmarshalWelcome(frame.Payload)
28 if err != nil {31 if err != nil {
29 t.Fatalf("UnmarshalWelcome() error = %v", err)32 t.Fatalf("UnmarshalWelcome() error = %v", err)

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