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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 463 | 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 | 507 | func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) { | |
| 467 | 508 | term := newRunTerminal() | |
| 468 | 509 | 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 { | |||
| 537 | 537 | func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachTarget) AttachRequest { | |
| 538 | 538 | origin := normalizeRouteOrigin(active.Origin, active.Remote) | |
| 539 | 539 | originKey := normalizeRouteOriginKey(active.OriginKey, origin) | |
| 540 | + | environmentPolicy := target.EnvironmentPolicy | |
| 541 | + | if origin == ports.RouteOriginLocal { | |
| 542 | + | environmentPolicy = ports.EnvironmentPolicyClientOwned | |
| 543 | + | } | |
| 540 | 544 | ||
| 541 | 545 | l.mu.RLock() | |
| 542 | 546 | defer l.mu.RUnlock() | |
| @@ -553,7 +557,7 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachT | |||
| 553 | 557 | // original discovery target would make its Hello request a second, | |
| 554 | 558 | // incorrect remote handoff instead of a same-peer session switch. | |
| 555 | 559 | request.RemoteTarget = nil | |
| 556 | - | request.EnvironmentPolicy = target.EnvironmentPolicy | |
| 560 | + | request.EnvironmentPolicy = environmentPolicy | |
| 557 | 561 | request.ExactTarget = target.ExactTarget | |
| 558 | 562 | if target.ExactTarget == nil { | |
| 559 | 563 | request.PreferredTabID = "" | |
| @@ -567,7 +571,7 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachT | |||
| 567 | 571 | request.ExactTarget = target.ExactTarget | |
| 568 | 572 | request.PreferredTabID = "" | |
| 569 | 573 | request.RemoteTarget = nil | |
| 570 | - | request.EnvironmentPolicy = target.EnvironmentPolicy | |
| 574 | + | request.EnvironmentPolicy = environmentPolicy | |
| 571 | 575 | return request | |
| 572 | 576 | } | |
| 573 | 577 | ||
| @@ -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 = nil | 559 | request.RemoteTarget = nil | ||
| 556 | - | request.EnvironmentPolicy = target.EnvironmentPolicy | 560 | + | request.EnvironmentPolicy = environmentPolicy |
| 557 | request.ExactTarget = target.ExactTarget | 561 | 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.ExactTarget | 571 | request.ExactTarget = target.ExactTarget | ||
| 568 | request.PreferredTabID = "" | 572 | request.PreferredTabID = "" | ||
| 569 | request.RemoteTarget = nil | 573 | request.RemoteTarget = nil | ||
| 570 | - | request.EnvironmentPolicy = target.EnvironmentPolicy | 574 | + | request.EnvironmentPolicy = environmentPolicy |
| 571 | return request | 575 | return request | ||
| 572 | } | 576 | } | ||
| 573 | 577 | ||||
M internal/usecase/client/routes_test.go +1 -1
| @@ -143,7 +143,7 @@ func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) { | |||
| 143 | 143 | require.Equal(t, first.target.SessionName, request.SessionName) | |
| 144 | 144 | require.Equal(t, tt.wantExactTarget, request.ExactTarget) | |
| 145 | 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 | } | |
| @@ -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 ( | |||
| 14 | 14 | ||
| 15 | 15 | func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |
| 16 | 16 | 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) | |
| 21 | 22 | }{ | |
| 22 | 23 | { | |
| 23 | - | name: "picker selection", action: "picker-select", | |
| 24 | + | name: "picker selection", releasedAction: "detach", | |
| 24 | 25 | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error { | |
| 25 | 26 | d.enterPicker(source, ac) | |
| 26 | 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 | 32 | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error { | |
| 32 | 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 | 38 | run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error { | |
| 38 | 39 | source.mu.Lock() | |
| 39 | 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 | 51 | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error { | |
| 51 | 52 | d.mu.Lock() | |
| 52 | 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 | 55 | d.mu.Unlock() | |
| 55 | 56 | expectedCreatedAt := int64(9) | |
| 56 | 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 | 70 | run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffectTicket) error { | |
| 70 | 71 | return d.createSessionAndSwitchForAttachment(effect.connectionToken(), "created") | |
| 71 | 72 | }, | |
| @@ -83,7 +84,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 83 | 84 | d, source, old, _, releases := newManualTabSession(t, 2) | |
| 84 | 85 | defer releaseAll(releases) | |
| 85 | 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 | 88 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 88 | 89 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 89 | 90 | }} | |
| @@ -102,7 +103,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 102 | 103 | releaseAction := make(chan struct{}) | |
| 103 | 104 | var admissionEndedOnce sync.Once | |
| 104 | 105 | d.afterActionAttachmentEffectEnded = func(action string) { | |
| 105 | - | if action == tt.action { | |
| 106 | + | if action == tt.releasedAction { | |
| 106 | 107 | admissionEndedOnce.Do(func() { close(admissionEnded) }) | |
| 107 | 108 | <-releaseAction | |
| 108 | 109 | } | |
| @@ -122,7 +123,12 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 122 | 123 | }) | |
| 123 | 124 | require.NoError(t, err) | |
| 124 | 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 | 133 | require.Same(t, source, old.currentSession()) | |
| 128 | 134 | source.mu.Lock() | |
| @@ -143,7 +149,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 143 | 149 | func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) { | |
| 144 | 150 | d, source, old, _ := newManualSessionWithPTYs(t, nil) | |
| 145 | 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 | 153 | d.mu.Unlock() | |
| 148 | 154 | ||
| 149 | 155 | rc := d.attachCoordinator(source, nil, old, true) | |
| @@ -157,7 +163,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing. | |||
| 157 | 163 | release := make(chan struct{}) | |
| 158 | 164 | var endedOnce sync.Once | |
| 159 | 165 | d.afterActionAttachmentEffectEnded = func(action string) { | |
| 160 | - | if action == "picker-stopped" { | |
| 166 | + | if action == "detach" { | |
| 161 | 167 | endedOnce.Do(func() { close(ended) }) | |
| 162 | 168 | <-release | |
| 163 | 169 | } | |
| @@ -180,7 +186,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing. | |||
| 180 | 186 | }) | |
| 181 | 187 | require.NoError(t, err) | |
| 182 | 188 | close(release) | |
| 183 | - | require.Error(t, <-done) | |
| 189 | + | require.NoError(t, <-done) | |
| 184 | 190 | ||
| 185 | 191 | d.mu.Lock() | |
| 186 | 192 | _, 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 string | 17 | + | name string |
| 18 | - | action string | 18 | + | releasedAction string |
| 19 | - | run func(*Daemon, *session, *session, *attachedClient, *attachmentEffectTicket) error | 19 | + | 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.Once | 104 | 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 | <-releaseAction | 108 | <-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.Once | 164 | 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 | <-release | 168 | <-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 ( | |||
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing.T) { | |
| 11 | + | func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testing.T) { | |
| 12 | 12 | tests := []struct { | |
| 13 | 13 | name string | |
| 14 | 14 | rebind func(*testing.T, *Daemon, *session, *attachedClient) | |
| @@ -39,7 +39,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing | |||
| 39 | 39 | for _, tt := range tests { | |
| 40 | 40 | t.Run(tt.name, func(t *testing.T) { | |
| 41 | 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 | 43 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 44 | 44 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 45 | 45 | }} | |
| @@ -57,7 +57,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing | |||
| 57 | 57 | ended := make(chan struct{}) | |
| 58 | 58 | release := make(chan struct{}) | |
| 59 | 59 | d.afterActionAttachmentEffectEnded = func(action string) { | |
| 60 | - | if action == "test-handoff" { | |
| 60 | + | if action == "detach" { | |
| 61 | 61 | close(ended) | |
| 62 | 62 | <-release | |
| 63 | 63 | } | |
| @@ -69,7 +69,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing | |||
| 69 | 69 | awaitTestCompletion(t, ended, "test handoff did not release its role ticket") | |
| 70 | 70 | tt.rebind(t, d, source, ac) | |
| 71 | 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 | 74 | require.Same(t, source, ac.currentSession()) | |
| 75 | 75 | 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 string | 13 | 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 | <-release | 62 | <-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() | ||