detached b8a37b3

fix(navigation): preserve local-only session handoffs

commit
b8a37b313f33a42a2f88ec45a679ce3eafbac320
author
brice · · 2026-08-11 12:47 UTC
committer
brice · · 2026-08-11 12:56 UTC
parent
2bffe86
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +74 -23
M internal/usecase/client/client_test.go +41 -0
@@ -463,6 +463,47 @@ func TestAttachPublishesCommittedRouteSnapshot(t *testing.T) {
463463 require.Empty(t, snapshot.Entries, "the active route is metadata-only")
464464 }
465465
466+func TestLocalOnlyHandoffBetweenLocalSessionsKeepsClientRunning(t *testing.T) {
467+ term := newRunTerminal()
468+ defer term.in.unblock()
469+
470+ firstLifecycle := domain.SessionLifecycleID{1}
471+ secondLifecycle := domain.SessionLifecycleID{2}
472+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
473+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
474+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
475+ CommittedIdentity: &ports.CommittedRouteIdentity{
476+ Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name},
477+ },
478+ }))
479+ }
480+ secondTarget := ports.ExactSessionTarget{LifecycleID: secondLifecycle, SessionName: "second"}
481+ first := &recordingTransport{recvs: []recvItem{
482+ {f: welcome("first", firstLifecycle)},
483+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
484+ Session: "second", Intent: ports.IntentAttach, ExactTarget: &secondTarget,
485+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
486+ }))},
487+ }}
488+ second := &recordingTransport{recvs: []recvItem{
489+ {f: welcome("second", secondLifecycle)},
490+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
491+ }}
492+ dialer := &sequenceDialer{trs: []ports.Transport{first, second}}
493+
494+ err := runTestClient(context.Background(), testDependencies(dialer, term, realClock{}, nil, nil), client.AttachRequest{
495+ Intent: ports.IntentAttach, SessionName: "first",
496+ Origin: ports.RouteOriginLocal, OriginKey: "local",
497+ EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
498+ })
499+
500+ require.NoError(t, err)
501+ hello := helloFromSend(t, second)
502+ require.Equal(t, "second", hello.Name)
503+ require.Equal(t, ports.EnvironmentPolicyClientOwned, hello.EnvironmentPolicy)
504+ require.Nil(t, hello.RemoteTarget)
505+}
506+
466507 func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
467508 term := newRunTerminal()
468509 defer term.in.unblock()
@@ -463,6 +463,47 @@ func TestAttachPublishesCommittedRouteSnapshot(t *testing.T) {
463 require.Empty(t, snapshot.Entries, "the active route is metadata-only")463 require.Empty(t, snapshot.Entries, "the active route is metadata-only")
464 }464 }
465 465
466+func TestLocalOnlyHandoffBetweenLocalSessionsKeepsClientRunning(t *testing.T) {
467+ term := newRunTerminal()
468+ defer term.in.unblock()
469+
470+ firstLifecycle := domain.SessionLifecycleID{1}
471+ secondLifecycle := domain.SessionLifecycleID{2}
472+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
473+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
474+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
475+ CommittedIdentity: &ports.CommittedRouteIdentity{
476+ Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name},
477+ },
478+ }))
479+ }
480+ secondTarget := ports.ExactSessionTarget{LifecycleID: secondLifecycle, SessionName: "second"}
481+ first := &recordingTransport{recvs: []recvItem{
482+ {f: welcome("first", firstLifecycle)},
483+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
484+ Session: "second", Intent: ports.IntentAttach, ExactTarget: &secondTarget,
485+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
486+ }))},
487+ }}
488+ second := &recordingTransport{recvs: []recvItem{
489+ {f: welcome("second", secondLifecycle)},
490+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
491+ }}
492+ dialer := &sequenceDialer{trs: []ports.Transport{first, second}}
493+
494+ err := runTestClient(context.Background(), testDependencies(dialer, term, realClock{}, nil, nil), client.AttachRequest{
495+ Intent: ports.IntentAttach, SessionName: "first",
496+ Origin: ports.RouteOriginLocal, OriginKey: "local",
497+ EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
498+ })
499+
500+ require.NoError(t, err)
501+ hello := helloFromSend(t, second)
502+ require.Equal(t, "second", hello.Name)
503+ require.Equal(t, ports.EnvironmentPolicyClientOwned, hello.EnvironmentPolicy)
504+ require.Nil(t, hello.RemoteTarget)
505+}
506+
466 func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {507 func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
467 term := newRunTerminal()508 term := newRunTerminal()
468 defer term.in.unblock()509 defer term.in.unblock()
M internal/usecase/client/routes.go +6 -2
@@ -537,6 +537,10 @@ func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {
537537 func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachTarget) AttachRequest {
538538 origin := normalizeRouteOrigin(active.Origin, active.Remote)
539539 originKey := normalizeRouteOriginKey(active.OriginKey, origin)
540+ environmentPolicy := target.EnvironmentPolicy
541+ if origin == ports.RouteOriginLocal {
542+ environmentPolicy = ports.EnvironmentPolicyClientOwned
543+ }
540544
541545 l.mu.RLock()
542546 defer l.mu.RUnlock()
@@ -553,7 +557,7 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachT
553557 // original discovery target would make its Hello request a second,
554558 // incorrect remote handoff instead of a same-peer session switch.
555559 request.RemoteTarget = nil
556- request.EnvironmentPolicy = target.EnvironmentPolicy
560+ request.EnvironmentPolicy = environmentPolicy
557561 request.ExactTarget = target.ExactTarget
558562 if target.ExactTarget == nil {
559563 request.PreferredTabID = ""
@@ -567,7 +571,7 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachT
567571 request.ExactTarget = target.ExactTarget
568572 request.PreferredTabID = ""
569573 request.RemoteTarget = nil
570- request.EnvironmentPolicy = target.EnvironmentPolicy
574+ request.EnvironmentPolicy = environmentPolicy
571575 return request
572576 }
573577
@@ -537,6 +537,10 @@ func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {
537 func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachTarget) AttachRequest {537 func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachTarget) AttachRequest {
538 origin := normalizeRouteOrigin(active.Origin, active.Remote)538 origin := normalizeRouteOrigin(active.Origin, active.Remote)
539 originKey := normalizeRouteOriginKey(active.OriginKey, origin)539 originKey := normalizeRouteOriginKey(active.OriginKey, origin)
540+ environmentPolicy := target.EnvironmentPolicy
541+ if origin == ports.RouteOriginLocal {
542+ environmentPolicy = ports.EnvironmentPolicyClientOwned
543+ }
540 544
541 l.mu.RLock()545 l.mu.RLock()
542 defer l.mu.RUnlock()546 defer l.mu.RUnlock()
@@ -553,7 +557,7 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachT
553 // original discovery target would make its Hello request a second,557 // original discovery target would make its Hello request a second,
554 // incorrect remote handoff instead of a same-peer session switch.558 // incorrect remote handoff instead of a same-peer session switch.
555 request.RemoteTarget = nil559 request.RemoteTarget = nil
556- request.EnvironmentPolicy = target.EnvironmentPolicy560+ request.EnvironmentPolicy = environmentPolicy
557 request.ExactTarget = target.ExactTarget561 request.ExactTarget = target.ExactTarget
558 if target.ExactTarget == nil {562 if target.ExactTarget == nil {
559 request.PreferredTabID = ""563 request.PreferredTabID = ""
@@ -567,7 +571,7 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachT
567 request.ExactTarget = target.ExactTarget571 request.ExactTarget = target.ExactTarget
568 request.PreferredTabID = ""572 request.PreferredTabID = ""
569 request.RemoteTarget = nil573 request.RemoteTarget = nil
570- request.EnvironmentPolicy = target.EnvironmentPolicy574+ request.EnvironmentPolicy = environmentPolicy
571 return request575 return request
572 }576 }
573 577
M internal/usecase/client/routes_test.go +1 -1
@@ -143,7 +143,7 @@ func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) {
143143 require.Equal(t, first.target.SessionName, request.SessionName)
144144 require.Equal(t, tt.wantExactTarget, request.ExactTarget)
145145 require.Equal(t, tt.wantTab, request.PreferredTabID)
146- require.Equal(t, ports.EnvironmentPolicyDaemonOwned, request.EnvironmentPolicy)
146+ require.Equal(t, ports.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
147147 })
148148 }
149149 }
@@ -143,7 +143,7 @@ func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) {
143 require.Equal(t, first.target.SessionName, request.SessionName)143 require.Equal(t, first.target.SessionName, request.SessionName)
144 require.Equal(t, tt.wantExactTarget, request.ExactTarget)144 require.Equal(t, tt.wantExactTarget, request.ExactTarget)
145 require.Equal(t, tt.wantTab, request.PreferredTabID)145 require.Equal(t, tt.wantTab, request.PreferredTabID)
146- require.Equal(t, ports.EnvironmentPolicyDaemonOwned, request.EnvironmentPolicy)146+ require.Equal(t, ports.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
147 })147 })
148 }148 }
149 }149 }
M internal/usecase/daemon/navigation_attachment_handoff_test.go +22 -16
@@ -14,26 +14,27 @@ import (
1414
1515 func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
1616 tests := []struct {
17- name string
18- action string
19- run func(*Daemon, *session, *session, *attachedClient, *attachmentEffectTicket) error
20- check func(*testing.T, *Daemon, *session, *session)
17+ name string
18+ releasedAction string
19+ wantErr bool
20+ run func(*Daemon, *session, *session, *attachedClient, *attachmentEffectTicket) error
21+ check func(*testing.T, *Daemon, *session, *session)
2122 }{
2223 {
23- name: "picker selection", action: "picker-select",
24+ name: "picker selection", releasedAction: "detach",
2425 run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
2526 d.enterPicker(source, ac)
2627 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")
2728 },
2829 },
2930 {
30- name: "palette session", action: "palette-session",
31+ name: "palette session", releasedAction: "detach",
3132 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
3233 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
3334 },
3435 },
3536 {
36- name: "session overflow", action: "overflow-session",
37+ name: "session overflow", releasedAction: "detach",
3738 run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
3839 source.mu.Lock()
3940 source.name = "alpha"
@@ -46,11 +47,11 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
4647 },
4748 },
4849 {
49- name: "stopped session resume", action: "stopped-session",
50+ name: "stopped session resume", releasedAction: "detach",
5051 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
5152 d.mu.Lock()
5253 delete(d.sessions, target.id)
53- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionDown}
54+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, incarnation: domain.IncarnationID{2}, state: ports.SessionDown}
5455 d.mu.Unlock()
5556 expectedCreatedAt := int64(9)
5657 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
@@ -65,7 +66,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
6566 },
6667 },
6768 {
68- name: "transition prompt creation", action: "create-session",
69+ name: "transition prompt creation", releasedAction: "create-session", wantErr: true,
6970 run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffectTicket) error {
7071 return d.createSessionAndSwitchForAttachment(effect.connectionToken(), "created")
7172 },
@@ -83,7 +84,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
8384 d, source, old, _, releases := newManualTabSession(t, 2)
8485 defer releaseAll(releases)
8586 d.ptys = newFactorySeq(t)
86- target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
87+ target := &session{sessionCore: sessionCore{id: "target", name: "target", incarnation: domain.SessionLifecycleID{1}}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
8788 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
8889 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
8990 }}
@@ -102,7 +103,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
102103 releaseAction := make(chan struct{})
103104 var admissionEndedOnce sync.Once
104105 d.afterActionAttachmentEffectEnded = func(action string) {
105- if action == tt.action {
106+ if action == tt.releasedAction {
106107 admissionEndedOnce.Do(func() { close(admissionEnded) })
107108 <-releaseAction
108109 }
@@ -122,7 +123,12 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
122123 })
123124 require.NoError(t, err)
124125 close(releaseAction)
125- require.Error(t, <-actionDone)
126+ actionErr := <-actionDone
127+ if tt.wantErr {
128+ require.Error(t, actionErr)
129+ } else {
130+ require.NoError(t, actionErr)
131+ }
126132
127133 require.Same(t, source, old.currentSession())
128134 source.mu.Lock()
@@ -143,7 +149,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
143149 func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) {
144150 d, source, old, _ := newManualSessionWithPTYs(t, nil)
145151 d.mu.Lock()
146- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionDown}
152+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, incarnation: domain.IncarnationID{3}, state: ports.SessionDown}
147153 d.mu.Unlock()
148154
149155 rc := d.attachCoordinator(source, nil, old, true)
@@ -157,7 +163,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
157163 release := make(chan struct{})
158164 var endedOnce sync.Once
159165 d.afterActionAttachmentEffectEnded = func(action string) {
160- if action == "picker-stopped" {
166+ if action == "detach" {
161167 endedOnce.Do(func() { close(ended) })
162168 <-release
163169 }
@@ -180,7 +186,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
180186 })
181187 require.NoError(t, err)
182188 close(release)
183- require.Error(t, <-done)
189+ require.NoError(t, <-done)
184190
185191 d.mu.Lock()
186192 _, stillStopped := d.stopped["stopped"]
@@ -14,26 +14,27 @@ import (
14 14
15 func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {15 func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
16 tests := []struct {16 tests := []struct {
17- name string17+ name string
18- action string18+ releasedAction string
19- run func(*Daemon, *session, *session, *attachedClient, *attachmentEffectTicket) error19+ wantErr bool
20- check func(*testing.T, *Daemon, *session, *session)20+ run func(*Daemon, *session, *session, *attachedClient, *attachmentEffectTicket) error
21+ check func(*testing.T, *Daemon, *session, *session)
21 }{22 }{
22 {23 {
23- name: "picker selection", action: "picker-select",24+ name: "picker selection", releasedAction: "detach",
24 run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {25 run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
25 d.enterPicker(source, ac)26 d.enterPicker(source, ac)
26 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")27 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")
27 },28 },
28 },29 },
29 {30 {
30- name: "palette session", action: "palette-session",31+ name: "palette session", releasedAction: "detach",
31 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {32 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
32 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")33 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
33 },34 },
34 },35 },
35 {36 {
36- name: "session overflow", action: "overflow-session",37+ name: "session overflow", releasedAction: "detach",
37 run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {38 run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
38 source.mu.Lock()39 source.mu.Lock()
39 source.name = "alpha"40 source.name = "alpha"
@@ -46,11 +47,11 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
46 },47 },
47 },48 },
48 {49 {
49- name: "stopped session resume", action: "stopped-session",50+ name: "stopped session resume", releasedAction: "detach",
50 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {51 run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
51 d.mu.Lock()52 d.mu.Lock()
52 delete(d.sessions, target.id)53 delete(d.sessions, target.id)
53- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionDown}54+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, incarnation: domain.IncarnationID{2}, state: ports.SessionDown}
54 d.mu.Unlock()55 d.mu.Unlock()
55 expectedCreatedAt := int64(9)56 expectedCreatedAt := int64(9)
56 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")57 return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
@@ -65,7 +66,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
65 },66 },
66 },67 },
67 {68 {
68- name: "transition prompt creation", action: "create-session",69+ name: "transition prompt creation", releasedAction: "create-session", wantErr: true,
69 run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffectTicket) error {70 run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffectTicket) error {
70 return d.createSessionAndSwitchForAttachment(effect.connectionToken(), "created")71 return d.createSessionAndSwitchForAttachment(effect.connectionToken(), "created")
71 },72 },
@@ -83,7 +84,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
83 d, source, old, _, releases := newManualTabSession(t, 2)84 d, source, old, _, releases := newManualTabSession(t, 2)
84 defer releaseAll(releases)85 defer releaseAll(releases)
85 d.ptys = newFactorySeq(t)86 d.ptys = newFactorySeq(t)
86- target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{87+ target := &session{sessionCore: sessionCore{id: "target", name: "target", incarnation: domain.SessionLifecycleID{1}}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
87 newTab(nil, domain.Size{Cols: 80, Rows: 23}),88 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
88 newTab(nil, domain.Size{Cols: 80, Rows: 23}),89 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
89 }}90 }}
@@ -102,7 +103,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
102 releaseAction := make(chan struct{})103 releaseAction := make(chan struct{})
103 var admissionEndedOnce sync.Once104 var admissionEndedOnce sync.Once
104 d.afterActionAttachmentEffectEnded = func(action string) {105 d.afterActionAttachmentEffectEnded = func(action string) {
105- if action == tt.action {106+ if action == tt.releasedAction {
106 admissionEndedOnce.Do(func() { close(admissionEnded) })107 admissionEndedOnce.Do(func() { close(admissionEnded) })
107 <-releaseAction108 <-releaseAction
108 }109 }
@@ -122,7 +123,12 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
122 })123 })
123 require.NoError(t, err)124 require.NoError(t, err)
124 close(releaseAction)125 close(releaseAction)
125- require.Error(t, <-actionDone)126+ actionErr := <-actionDone
127+ if tt.wantErr {
128+ require.Error(t, actionErr)
129+ } else {
130+ require.NoError(t, actionErr)
131+ }
126 132
127 require.Same(t, source, old.currentSession())133 require.Same(t, source, old.currentSession())
128 source.mu.Lock()134 source.mu.Lock()
@@ -143,7 +149,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
143 func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) {149 func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) {
144 d, source, old, _ := newManualSessionWithPTYs(t, nil)150 d, source, old, _ := newManualSessionWithPTYs(t, nil)
145 d.mu.Lock()151 d.mu.Lock()
146- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, state: ports.SessionDown}152+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 7, incarnation: domain.IncarnationID{3}, state: ports.SessionDown}
147 d.mu.Unlock()153 d.mu.Unlock()
148 154
149 rc := d.attachCoordinator(source, nil, old, true)155 rc := d.attachCoordinator(source, nil, old, true)
@@ -157,7 +163,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
157 release := make(chan struct{})163 release := make(chan struct{})
158 var endedOnce sync.Once164 var endedOnce sync.Once
159 d.afterActionAttachmentEffectEnded = func(action string) {165 d.afterActionAttachmentEffectEnded = func(action string) {
160- if action == "picker-stopped" {166+ if action == "detach" {
161 endedOnce.Do(func() { close(ended) })167 endedOnce.Do(func() { close(ended) })
162 <-release168 <-release
163 }169 }
@@ -180,7 +186,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
180 })186 })
181 require.NoError(t, err)187 require.NoError(t, err)
182 close(release)188 close(release)
183- require.Error(t, <-done)189+ require.NoError(t, <-done)
184 190
185 d.mu.Lock()191 d.mu.Lock()
186 _, stillStopped := d.stopped["stopped"]192 _, stillStopped := d.stopped["stopped"]
M internal/usecase/daemon/navigation_attachment_incarnation_test.go +4 -4
@@ -8,7 +8,7 @@ import (
88 "github.com/stretchr/testify/require"
99 )
1010
11-func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing.T) {
11+func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testing.T) {
1212 tests := []struct {
1313 name string
1414 rebind func(*testing.T, *Daemon, *session, *attachedClient)
@@ -39,7 +39,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
3939 for _, tt := range tests {
4040 t.Run(tt.name, func(t *testing.T) {
4141 d, source, ac, _ := newManualSessionWithPTYs(t, nil)
42- target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
42+ target := &session{sessionCore: sessionCore{id: "target", name: "target", incarnation: domain.SessionLifecycleID{1}}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
4343 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
4444 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
4545 }}
@@ -57,7 +57,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
5757 ended := make(chan struct{})
5858 release := make(chan struct{})
5959 d.afterActionAttachmentEffectEnded = func(action string) {
60- if action == "test-handoff" {
60+ if action == "detach" {
6161 close(ended)
6262 <-release
6363 }
@@ -69,7 +69,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
6969 awaitTestCompletion(t, ended, "test handoff did not release its role ticket")
7070 tt.rebind(t, d, source, ac)
7171 close(release)
72- require.ErrorIs(t, awaitTestValue(t, done, "test handoff did not finish"), errAttachmentTransition)
72+ require.NoError(t, awaitTestValue(t, done, "test handoff did not finish"))
7373
7474 require.Same(t, source, ac.currentSession())
7575 target.mu.Lock()
@@ -8,7 +8,7 @@ import (
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 )9 )
10 10
11-func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing.T) {11+func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testing.T) {
12 tests := []struct {12 tests := []struct {
13 name string13 name string
14 rebind func(*testing.T, *Daemon, *session, *attachedClient)14 rebind func(*testing.T, *Daemon, *session, *attachedClient)
@@ -39,7 +39,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
39 for _, tt := range tests {39 for _, tt := range tests {
40 t.Run(tt.name, func(t *testing.T) {40 t.Run(tt.name, func(t *testing.T) {
41 d, source, ac, _ := newManualSessionWithPTYs(t, nil)41 d, source, ac, _ := newManualSessionWithPTYs(t, nil)
42- target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{42+ target := &session{sessionCore: sessionCore{id: "target", name: "target", incarnation: domain.SessionLifecycleID{1}}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
43 newTab(nil, domain.Size{Cols: 80, Rows: 23}),43 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
44 newTab(nil, domain.Size{Cols: 80, Rows: 23}),44 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
45 }}45 }}
@@ -57,7 +57,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
57 ended := make(chan struct{})57 ended := make(chan struct{})
58 release := make(chan struct{})58 release := make(chan struct{})
59 d.afterActionAttachmentEffectEnded = func(action string) {59 d.afterActionAttachmentEffectEnded = func(action string) {
60- if action == "test-handoff" {60+ if action == "detach" {
61 close(ended)61 close(ended)
62 <-release62 <-release
63 }63 }
@@ -69,7 +69,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
69 awaitTestCompletion(t, ended, "test handoff did not release its role ticket")69 awaitTestCompletion(t, ended, "test handoff did not release its role ticket")
70 tt.rebind(t, d, source, ac)70 tt.rebind(t, d, source, ac)
71 close(release)71 close(release)
72- require.ErrorIs(t, awaitTestValue(t, done, "test handoff did not finish"), errAttachmentTransition)72+ require.NoError(t, awaitTestValue(t, done, "test handoff did not finish"))
73 73
74 require.Same(t, source, ac.currentSession())74 require.Same(t, source, ac.currentSession())
75 target.mu.Lock()75 target.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