fix(remote): preserve picker proxy handoffs
- commit
- 0ae031af0325095428ad709eb74bc37c36d11021
- author
- brice · · 2026-07-31 20:40 UTC
- parent
- c2951b3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +206 -22
M internal/domain/remote_session.go +9 -2
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package domain | |
| 2 | 2 | ||
| 3 | - | import "encoding/base64" | |
| 3 | + | import ( | |
| 4 | + | "encoding/base64" | |
| 5 | + | "strings" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | // RemoteSessionKey identifies a discovered remote session independently from | |
| 6 | 9 | // its display label. | |
| @@ -27,5 +30,9 @@ func (k RemoteSessionKey) ID() SessionID { | |||
| 27 | 30 | ||
| 28 | 31 | // Display returns the presentation label for this remote session. | |
| 29 | 32 | func (k RemoteSessionKey) Display() string { | |
| 30 | - | return k.Name + "@" + k.Host | |
| 33 | + | host := k.Host | |
| 34 | + | if i := strings.LastIndexByte(host, '@'); i > 0 && i < len(host)-1 { | |
| 35 | + | host = host[i+1:] | |
| 36 | + | } | |
| 37 | + | return k.Name + "@" + host | |
| 31 | 38 | } | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package domain | 1 | package domain | ||
| 2 | 2 | ||||
| 3 | - | import "encoding/base64" | 3 | + | import ( |
| 4 | + | "encoding/base64" | |||
| 5 | + | "strings" | |||
| 6 | + | ) | |||
| 4 | 7 | ||||
| 5 | // RemoteSessionKey identifies a discovered remote session independently from | 8 | // RemoteSessionKey identifies a discovered remote session independently from | ||
| 6 | // its display label. | 9 | // its display label. | ||
| @@ -27,5 +30,9 @@ func (k RemoteSessionKey) ID() SessionID { | |||||
| 27 | 30 | ||||
| 28 | // Display returns the presentation label for this remote session. | 31 | // Display returns the presentation label for this remote session. | ||
| 29 | func (k RemoteSessionKey) Display() string { | 32 | func (k RemoteSessionKey) Display() string { | ||
| 30 | - | return k.Name + "@" + k.Host | 33 | + | host := k.Host |
| 34 | + | if i := strings.LastIndexByte(host, '@'); i > 0 && i < len(host)-1 { | |||
| 35 | + | host = host[i+1:] | |||
| 36 | + | } | |||
| 37 | + | return k.Name + "@" + host | |||
| 31 | } | 38 | } | ||
M internal/domain/remote_session_test.go +20 -6
| @@ -89,12 +89,26 @@ func TestRemoteSessionKeyValidate(t *testing.T) { | |||
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | 91 | func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) { | |
| 92 | - | key := RemoteSessionKey{Host: "arch", Name: "hello"} | |
| 93 | - | ||
| 94 | - | if got, want := key.Display(), "hello@arch"; got != want { | |
| 95 | - | t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, want) | |
| 92 | + | tests := []struct { | |
| 93 | + | host string | |
| 94 | + | want string | |
| 95 | + | }{ | |
| 96 | + | {host: "arch", want: "hello@arch"}, | |
| 97 | + | {host: "test@arch", want: "hello@arch"}, | |
| 98 | + | {host: "test@arch:2222", want: "hello@arch:2222"}, | |
| 99 | + | {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"}, | |
| 100 | + | {host: "test@", want: "hello@test@"}, | |
| 101 | + | {host: "@arch", want: "hello@@arch"}, | |
| 96 | 102 | } | |
| 97 | - | if key.Host != "arch" || key.Name != "hello" { | |
| 98 | - | t.Fatalf("Display mutated routing key: %#v", key) | |
| 103 | + | for _, test := range tests { | |
| 104 | + | t.Run(test.host, func(t *testing.T) { | |
| 105 | + | key := RemoteSessionKey{Host: test.host, Name: "hello"} | |
| 106 | + | if got := key.Display(); got != test.want { | |
| 107 | + | t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want) | |
| 108 | + | } | |
| 109 | + | if key.Host != test.host || key.Name != "hello" { | |
| 110 | + | t.Fatalf("Display mutated routing key: %#v", key) | |
| 111 | + | } | |
| 112 | + | }) | |
| 99 | 113 | } | |
| 100 | 114 | } | |
| @@ -89,12 +89,26 @@ func TestRemoteSessionKeyValidate(t *testing.T) { | |||||
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
| 91 | func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) { | 91 | func TestRemoteSessionKeyDisplayIsPresentationOnly(t *testing.T) { | ||
| 92 | - | key := RemoteSessionKey{Host: "arch", Name: "hello"} | 92 | + | tests := []struct { |
| 93 | - | 93 | + | host string | |
| 94 | - | if got, want := key.Display(), "hello@arch"; got != want { | 94 | + | want string |
| 95 | - | t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, want) | 95 | + | }{ |
| 96 | + | {host: "arch", want: "hello@arch"}, | |||
| 97 | + | {host: "test@arch", want: "hello@arch"}, | |||
| 98 | + | {host: "test@arch:2222", want: "hello@arch:2222"}, | |||
| 99 | + | {host: "test@[2001:db8::1]:2222", want: "hello@[2001:db8::1]:2222"}, | |||
| 100 | + | {host: "test@", want: "hello@test@"}, | |||
| 101 | + | {host: "@arch", want: "hello@@arch"}, | |||
| 96 | } | 102 | } | ||
| 97 | - | if key.Host != "arch" || key.Name != "hello" { | 103 | + | for _, test := range tests { |
| 98 | - | t.Fatalf("Display mutated routing key: %#v", key) | 104 | + | t.Run(test.host, func(t *testing.T) { |
| 105 | + | key := RemoteSessionKey{Host: test.host, Name: "hello"} | |||
| 106 | + | if got := key.Display(); got != test.want { | |||
| 107 | + | t.Fatalf("RemoteSessionKey.Display() = %q, want %q", got, test.want) | |||
| 108 | + | } | |||
| 109 | + | if key.Host != test.host || key.Name != "hello" { | |||
| 110 | + | t.Fatalf("Display mutated routing key: %#v", key) | |||
| 111 | + | } | |||
| 112 | + | }) | |||
| 99 | } | 113 | } | ||
| 100 | } | 114 | } | ||
M internal/usecase/daemon/client_frame_routing.go +15 -6
| @@ -20,28 +20,37 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 20 | 20 | if !ac.currentTransportIs(tr) { | |
| 21 | 21 | return | |
| 22 | 22 | } | |
| 23 | - | sess := ac.currentSession() | |
| 23 | + | sess := ac.currentAttachmentSession() | |
| 24 | 24 | if sess == nil { | |
| 25 | 25 | return | |
| 26 | 26 | } | |
| 27 | 27 | f, err := tr.Recv() | |
| 28 | 28 | if err != nil { | |
| 29 | - | token := sess.attachmentToken(ac, tr) | |
| 29 | + | if !ac.currentTransportIs(tr) { | |
| 30 | + | return | |
| 31 | + | } | |
| 32 | + | sess = ac.currentAttachmentSession() | |
| 33 | + | if sess == nil { | |
| 34 | + | return | |
| 35 | + | } | |
| 36 | + | token := attachmentToken(sess, ac, tr) | |
| 30 | 37 | if token.role == attachmentSnatched { | |
| 31 | 38 | d.parkOrDropSnatchedAttachment(token) | |
| 32 | - | } else { | |
| 33 | - | d.clientGone(sess, ac, tr, false) | |
| 39 | + | } else if proxy, ok := sess.(*proxySession); ok { | |
| 40 | + | d.detachProxyOnSendError(proxy, ac, tr) | |
| 41 | + | } else if local, ok := localSession(sess); ok { | |
| 42 | + | d.clientGone(local, ac, tr, false) | |
| 34 | 43 | } | |
| 35 | 44 | return | |
| 36 | 45 | } | |
| 37 | 46 | if !ac.currentTransportIs(tr) { | |
| 38 | 47 | return | |
| 39 | 48 | } | |
| 40 | - | sess = ac.currentSession() | |
| 49 | + | sess = ac.currentAttachmentSession() | |
| 41 | 50 | if sess == nil { | |
| 42 | 51 | return | |
| 43 | 52 | } | |
| 44 | - | token := sess.attachmentToken(ac, tr) | |
| 53 | + | token := attachmentToken(sess, ac, tr) | |
| 45 | 54 | switch token.role { | |
| 46 | 55 | case attachmentActive: | |
| 47 | 56 | if d.handleActiveClientFrame(token, f) { | |
| @@ -20,28 +20,37 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 20 | if !ac.currentTransportIs(tr) { | 20 | if !ac.currentTransportIs(tr) { | ||
| 21 | return | 21 | return | ||
| 22 | } | 22 | } | ||
| 23 | - | sess := ac.currentSession() | 23 | + | sess := ac.currentAttachmentSession() |
| 24 | if sess == nil { | 24 | if sess == nil { | ||
| 25 | return | 25 | return | ||
| 26 | } | 26 | } | ||
| 27 | f, err := tr.Recv() | 27 | f, err := tr.Recv() | ||
| 28 | if err != nil { | 28 | if err != nil { | ||
| 29 | - | token := sess.attachmentToken(ac, tr) | 29 | + | if !ac.currentTransportIs(tr) { |
| 30 | + | return | |||
| 31 | + | } | |||
| 32 | + | sess = ac.currentAttachmentSession() | |||
| 33 | + | if sess == nil { | |||
| 34 | + | return | |||
| 35 | + | } | |||
| 36 | + | token := attachmentToken(sess, ac, tr) | |||
| 30 | if token.role == attachmentSnatched { | 37 | if token.role == attachmentSnatched { | ||
| 31 | d.parkOrDropSnatchedAttachment(token) | 38 | d.parkOrDropSnatchedAttachment(token) | ||
| 32 | - | } else { | 39 | + | } else if proxy, ok := sess.(*proxySession); ok { |
| 33 | - | d.clientGone(sess, ac, tr, false) | 40 | + | d.detachProxyOnSendError(proxy, ac, tr) |
| 41 | + | } else if local, ok := localSession(sess); ok { | |||
| 42 | + | d.clientGone(local, ac, tr, false) | |||
| 34 | } | 43 | } | ||
| 35 | return | 44 | return | ||
| 36 | } | 45 | } | ||
| 37 | if !ac.currentTransportIs(tr) { | 46 | if !ac.currentTransportIs(tr) { | ||
| 38 | return | 47 | return | ||
| 39 | } | 48 | } | ||
| 40 | - | sess = ac.currentSession() | 49 | + | sess = ac.currentAttachmentSession() |
| 41 | if sess == nil { | 50 | if sess == nil { | ||
| 42 | return | 51 | return | ||
| 43 | } | 52 | } | ||
| 44 | - | token := sess.attachmentToken(ac, tr) | 53 | + | token := attachmentToken(sess, ac, tr) |
| 45 | switch token.role { | 54 | switch token.role { | ||
| 46 | case attachmentActive: | 55 | case attachmentActive: | ||
| 47 | if d.handleActiveClientFrame(token, f) { | 56 | if d.handleActiveClientFrame(token, f) { | ||
M internal/usecase/daemon/picker.go +18 -8
| @@ -578,20 +578,24 @@ type sessionHandoffGuard struct { | |||
| 578 | 578 | // and revalidates the exact initiating token before changing target focus or | |
| 579 | 579 | // attachment ownership. | |
| 580 | 580 | func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error { | |
| 581 | + | return d.switchActiveTargetForRoleGuarded(token, target, sessionHandoffGuard{}, "jump-attention") | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error { | |
| 581 | 585 | if token.sess == nil || token.ac == nil || token.role != attachmentActive { | |
| 582 | 586 | return nil | |
| 583 | 587 | } | |
| 584 | 588 | d.mu.Lock() | |
| 585 | - | targetSess := d.sessions[target.Session] | |
| 589 | + | targetSess, ok := localSession(d.sessions[target.Session]) | |
| 586 | 590 | d.mu.Unlock() | |
| 587 | - | if targetSess == nil || targetSess == token.sess { | |
| 591 | + | if !ok || targetSess == token.sess { | |
| 588 | 592 | return nil | |
| 589 | 593 | } | |
| 590 | 594 | ||
| 591 | 595 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 592 | 596 | source: token.sess, target: targetSess, next: token.ac, | |
| 593 | 597 | expectedRole: attachmentActive, targetRole: attachmentActive, | |
| 594 | - | expectedTransport: token.transport, sourceToken: &token, action: "jump-attention", | |
| 598 | + | expectedTransport: token.transport, sourceToken: &token, action: action, | |
| 595 | 599 | activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true, | |
| 596 | 600 | }) | |
| 597 | 601 | if err != nil { | |
| @@ -602,12 +606,15 @@ func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target pic | |||
| 602 | 606 | } | |
| 603 | 607 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | |
| 604 | 608 | } | |
| 605 | - | if target, ok := localSession(targetSess); ok { | |
| 606 | - | d.touchMRU(target) | |
| 607 | - | } | |
| 608 | - | if source, ok := localSession(token.sess); ok { | |
| 609 | - | token.ac.recordPreviousSession(source) | |
| 609 | + | if guard.closePicker { | |
| 610 | + | fresh, admitted := token.ac.beginRoleEffect(transition.published) | |
| 611 | + | if admitted { | |
| 612 | + | d.closePicker(token.ac) | |
| 613 | + | fresh.End() | |
| 614 | + | } | |
| 610 | 615 | } | |
| 616 | + | d.touchMRU(targetSess) | |
| 617 | + | token.ac.recordPreviousSession(token.sess) | |
| 611 | 618 | d.deferAttachmentTransitionCleanups(transition) | |
| 612 | 619 | d.firstPaintForTransition(transition.published) | |
| 613 | 620 | return nil | |
| @@ -633,6 +640,9 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker. | |||
| 633 | 640 | if target.RemoteKey != nil { | |
| 634 | 641 | return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action) | |
| 635 | 642 | } | |
| 643 | + | if token.sess.isProxy() && !target.Stopped { | |
| 644 | + | return d.switchActiveTargetForRoleGuarded(token, target, guard, action) | |
| 645 | + | } | |
| 636 | 646 | sess, ok := localSession(token.sess) | |
| 637 | 647 | if !ok { | |
| 638 | 648 | return errAttachmentTransition | |
| @@ -578,20 +578,24 @@ type sessionHandoffGuard struct { | |||||
| 578 | // and revalidates the exact initiating token before changing target focus or | 578 | // and revalidates the exact initiating token before changing target focus or | ||
| 579 | // attachment ownership. | 579 | // attachment ownership. | ||
| 580 | func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error { | 580 | func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error { | ||
| 581 | + | return d.switchActiveTargetForRoleGuarded(token, target, sessionHandoffGuard{}, "jump-attention") | |||
| 582 | + | } | |||
| 583 | + | ||||
| 584 | + | func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error { | |||
| 581 | if token.sess == nil || token.ac == nil || token.role != attachmentActive { | 585 | if token.sess == nil || token.ac == nil || token.role != attachmentActive { | ||
| 582 | return nil | 586 | return nil | ||
| 583 | } | 587 | } | ||
| 584 | d.mu.Lock() | 588 | d.mu.Lock() | ||
| 585 | - | targetSess := d.sessions[target.Session] | 589 | + | targetSess, ok := localSession(d.sessions[target.Session]) |
| 586 | d.mu.Unlock() | 590 | d.mu.Unlock() | ||
| 587 | - | if targetSess == nil || targetSess == token.sess { | 591 | + | if !ok || targetSess == token.sess { |
| 588 | return nil | 592 | return nil | ||
| 589 | } | 593 | } | ||
| 590 | 594 | ||||
| 591 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | 595 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | ||
| 592 | source: token.sess, target: targetSess, next: token.ac, | 596 | source: token.sess, target: targetSess, next: token.ac, | ||
| 593 | expectedRole: attachmentActive, targetRole: attachmentActive, | 597 | expectedRole: attachmentActive, targetRole: attachmentActive, | ||
| 594 | - | expectedTransport: token.transport, sourceToken: &token, action: "jump-attention", | 598 | + | expectedTransport: token.transport, sourceToken: &token, action: action, |
| 595 | activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true, | 599 | activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true, | ||
| 596 | }) | 600 | }) | ||
| 597 | if err != nil { | 601 | if err != nil { | ||
| @@ -602,12 +606,15 @@ func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target pic | |||||
| 602 | } | 606 | } | ||
| 603 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | 607 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | ||
| 604 | } | 608 | } | ||
| 605 | - | if target, ok := localSession(targetSess); ok { | 609 | + | if guard.closePicker { |
| 606 | - | d.touchMRU(target) | 610 | + | fresh, admitted := token.ac.beginRoleEffect(transition.published) |
| 607 | - | } | 611 | + | if admitted { |
| 608 | - | if source, ok := localSession(token.sess); ok { | 612 | + | d.closePicker(token.ac) |
| 609 | - | token.ac.recordPreviousSession(source) | 613 | + | fresh.End() |
| 614 | + | } | |||
| 610 | } | 615 | } | ||
| 616 | + | d.touchMRU(targetSess) | |||
| 617 | + | token.ac.recordPreviousSession(token.sess) | |||
| 611 | d.deferAttachmentTransitionCleanups(transition) | 618 | d.deferAttachmentTransitionCleanups(transition) | ||
| 612 | d.firstPaintForTransition(transition.published) | 619 | d.firstPaintForTransition(transition.published) | ||
| 613 | return nil | 620 | return nil | ||
| @@ -633,6 +640,9 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker. | |||||
| 633 | if target.RemoteKey != nil { | 640 | if target.RemoteKey != nil { | ||
| 634 | return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action) | 641 | return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action) | ||
| 635 | } | 642 | } | ||
| 643 | + | if token.sess.isProxy() && !target.Stopped { | |||
| 644 | + | return d.switchActiveTargetForRoleGuarded(token, target, guard, action) | |||
| 645 | + | } | |||
| 636 | sess, ok := localSession(token.sess) | 646 | sess, ok := localSession(token.sess) | ||
| 637 | if !ok { | 647 | if !ok { | ||
| 638 | return errAttachmentTransition | 648 | return errAttachmentTransition | ||
M internal/usecase/daemon/proxy_input_test.go +143 -0
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "io" | |
| 6 | 7 | "log/slog" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "sync/atomic" | |
| @@ -681,3 +682,145 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T) | |||
| 681 | 682 | require.Equal(t, key, proxy.key) | |
| 682 | 683 | t.Cleanup(func() { stopProxy(t, proxy) }) | |
| 683 | 684 | } | |
| 685 | + | ||
| 686 | + | func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) { | |
| 687 | + | d, proxy, ac, _, handler := newProxyInputHarness(t) | |
| 688 | + | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 689 | + | local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}} | |
| 690 | + | publishTiledPaneOwners(local, tb) | |
| 691 | + | d.mu.Lock() | |
| 692 | + | require.True(t, d.registerSessionLocked(local)) | |
| 693 | + | d.mu.Unlock() | |
| 694 | + | ||
| 695 | + | model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)}) | |
| 696 | + | d.publishPicker(proxy, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 697 | + | target, selectable := model.Selected() | |
| 698 | + | require.True(t, selectable) | |
| 699 | + | require.Equal(t, local.id, target.Session) | |
| 700 | + | require.Equal(t, local.name, target.Name) | |
| 701 | + | require.False(t, target.Stopped) | |
| 702 | + | ||
| 703 | + | require.NoError(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 704 | + | require.Same(t, local, ac.currentSession()) | |
| 705 | + | require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker") | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) { | |
| 709 | + | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 710 | + | d.attachCoordinator(local, nil, ac, true) | |
| 711 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 712 | + | remote := newProxyTestTransport() | |
| 713 | + | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| 714 | + | remote.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 715 | + | d.remoteDialerFactory = newProxyConstructionFactory(remote) | |
| 716 | + | d.remoteTransportMode = ports.RemoteTransportUDP | |
| 717 | + | ||
| 718 | + | localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport())) | |
| 719 | + | require.NoError(t, d.switchToTargetForRole(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")) | |
| 720 | + | proxy, ok := ac.currentAttachmentSession().(*proxySession) | |
| 721 | + | require.True(t, ok) | |
| 722 | + | t.Cleanup(func() { stopProxy(t, proxy) }) | |
| 723 | + | ||
| 724 | + | proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport())) | |
| 725 | + | require.NoError(t, d.switchToTargetForRole(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select")) | |
| 726 | + | require.Same(t, local, ac.currentSession()) | |
| 727 | + | ||
| 728 | + | model := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key}) | |
| 729 | + | d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{}) | |
| 730 | + | target, selectable := model.Selected() | |
| 731 | + | require.True(t, selectable) | |
| 732 | + | require.Empty(t, target.Name, "live proxy rows must route only by their structured remote key") | |
| 733 | + | localToken = roleEffectForTest(t, local.attachmentToken(ac, ac.transport())) | |
| 734 | + | require.NoError(t, d.switchToTargetForRole(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 735 | + | require.Same(t, proxy, ac.currentAttachmentSession()) | |
| 736 | + | require.False(t, ac.overlays.pickerActive()) | |
| 737 | + | } | |
| 738 | + | ||
| 739 | + | func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T) { | |
| 740 | + | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 741 | + | client := newProxyTestTransport() | |
| 742 | + | ac.replaceTransport(client) | |
| 743 | + | d.attachCoordinator(local, nil, ac, true) | |
| 744 | + | ||
| 745 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 746 | + | remote := newProxyTestTransport() | |
| 747 | + | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| 748 | + | remote.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 749 | + | d.remoteDialerFactory = newProxyConstructionFactory(remote) | |
| 750 | + | d.remoteTransportMode = ports.RemoteTransportUDP | |
| 751 | + | ||
| 752 | + | loopDone := make(chan struct{}) | |
| 753 | + | go func() { | |
| 754 | + | d.runConnLoop(ac) | |
| 755 | + | close(loopDone) | |
| 756 | + | }() | |
| 757 | + | require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond) | |
| 758 | + | ||
| 759 | + | token := roleEffectForTest(t, local.attachmentToken(ac, client)) | |
| 760 | + | require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")) | |
| 761 | + | proxy, ok := ac.currentAttachmentSession().(*proxySession) | |
| 762 | + | require.True(t, ok) | |
| 763 | + | t.Cleanup(func() { stopProxy(t, proxy) }) | |
| 764 | + | ||
| 765 | + | client.recv <- proxyRecv{err: io.EOF} | |
| 766 | + | awaitTestCompletion(t, loopDone, "connection loop did not return after receive failure") | |
| 767 | + | require.Nil(t, ac.currentAttachmentSession(), "receive failure cleaned the stale local role instead of the current proxy role") | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) { | |
| 771 | + | d, _, ac, _, handler := newProxyInputHarness(t) | |
| 772 | + | client := ac.transport().(*proxyTestTransport) | |
| 773 | + | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 774 | + | local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}} | |
| 775 | + | publishTiledPaneOwners(local, tb) | |
| 776 | + | d.mu.Lock() | |
| 777 | + | require.True(t, d.registerSessionLocked(local)) | |
| 778 | + | d.mu.Unlock() | |
| 779 | + | ||
| 780 | + | loopDone := make(chan struct{}) | |
| 781 | + | go func() { | |
| 782 | + | d.runConnLoop(ac) | |
| 783 | + | close(loopDone) | |
| 784 | + | }() | |
| 785 | + | require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond) | |
| 786 | + | ||
| 787 | + | require.NoError(t, d.switchToTargetForRole(handler.roleToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select")) | |
| 788 | + | require.Same(t, local, ac.currentSession()) | |
| 789 | + | client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}} | |
| 790 | + | awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session") | |
| 791 | + | require.Nil(t, ac.currentAttachmentSession()) | |
| 792 | + | } | |
| 793 | + | ||
| 794 | + | func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) { | |
| 795 | + | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 796 | + | client := newProxyTestTransport() | |
| 797 | + | ac.replaceTransport(client) | |
| 798 | + | d.attachCoordinator(local, nil, ac, true) | |
| 799 | + | ||
| 800 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 801 | + | remote := newProxyTestTransport() | |
| 802 | + | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| 803 | + | remote.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 804 | + | d.remoteDialerFactory = newProxyConstructionFactory(remote) | |
| 805 | + | d.remoteTransportMode = ports.RemoteTransportUDP | |
| 806 | + | ||
| 807 | + | loopDone := make(chan struct{}) | |
| 808 | + | go func() { | |
| 809 | + | d.runConnLoop(ac) | |
| 810 | + | close(loopDone) | |
| 811 | + | }() | |
| 812 | + | require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond) | |
| 813 | + | ||
| 814 | + | token := local.attachmentToken(ac, client) | |
| 815 | + | effect, admitted := ac.beginRoleEffect(token) | |
| 816 | + | require.True(t, admitted) | |
| 817 | + | token.effect = effect | |
| 818 | + | require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")) | |
| 819 | + | proxy, ok := ac.currentAttachmentSession().(*proxySession) | |
| 820 | + | require.True(t, ok) | |
| 821 | + | t.Cleanup(func() { stopProxy(t, proxy) }) | |
| 822 | + | ||
| 823 | + | client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}} | |
| 824 | + | awaitTestCompletion(t, loopDone, "connection loop did not handle input after the proxy handoff") | |
| 825 | + | require.Nil(t, ac.currentAttachmentSession(), "connection loop abandoned the client after the proxy handoff") | |
| 826 | + | } | |
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "io" | |||
| 6 | "log/slog" | 7 | "log/slog" | ||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "sync/atomic" | 9 | "sync/atomic" | ||
| @@ -681,3 +682,145 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T) | |||||
| 681 | require.Equal(t, key, proxy.key) | 682 | require.Equal(t, key, proxy.key) | ||
| 682 | t.Cleanup(func() { stopProxy(t, proxy) }) | 683 | t.Cleanup(func() { stopProxy(t, proxy) }) | ||
| 683 | } | 684 | } | ||
| 685 | + | ||||
| 686 | + | func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) { | |||
| 687 | + | d, proxy, ac, _, handler := newProxyInputHarness(t) | |||
| 688 | + | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 689 | + | local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}} | |||
| 690 | + | publishTiledPaneOwners(local, tb) | |||
| 691 | + | d.mu.Lock() | |||
| 692 | + | require.True(t, d.registerSessionLocked(local)) | |||
| 693 | + | d.mu.Unlock() | |||
| 694 | + | ||||
| 695 | + | model := d.newPickerModel(proxy, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)}) | |||
| 696 | + | d.publishPicker(proxy, ac, model, pickerNavigate, moveSourceLocator{}) | |||
| 697 | + | target, selectable := model.Selected() | |||
| 698 | + | require.True(t, selectable) | |||
| 699 | + | require.Equal(t, local.id, target.Session) | |||
| 700 | + | require.Equal(t, local.name, target.Name) | |||
| 701 | + | require.False(t, target.Stopped) | |||
| 702 | + | ||||
| 703 | + | require.NoError(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{closePicker: true}, "picker-select")) | |||
| 704 | + | require.Same(t, local, ac.currentSession()) | |||
| 705 | + | require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker") | |||
| 706 | + | } | |||
| 707 | + | ||||
| 708 | + | func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) { | |||
| 709 | + | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 710 | + | d.attachCoordinator(local, nil, ac, true) | |||
| 711 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 712 | + | remote := newProxyTestTransport() | |||
| 713 | + | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |||
| 714 | + | remote.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 715 | + | d.remoteDialerFactory = newProxyConstructionFactory(remote) | |||
| 716 | + | d.remoteTransportMode = ports.RemoteTransportUDP | |||
| 717 | + | ||||
| 718 | + | localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport())) | |||
| 719 | + | require.NoError(t, d.switchToTargetForRole(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")) | |||
| 720 | + | proxy, ok := ac.currentAttachmentSession().(*proxySession) | |||
| 721 | + | require.True(t, ok) | |||
| 722 | + | t.Cleanup(func() { stopProxy(t, proxy) }) | |||
| 723 | + | ||||
| 724 | + | proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport())) | |||
| 725 | + | require.NoError(t, d.switchToTargetForRole(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select")) | |||
| 726 | + | require.Same(t, local, ac.currentSession()) | |||
| 727 | + | ||||
| 728 | + | model := d.newPickerModel(local, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key}) | |||
| 729 | + | d.publishPicker(local, ac, model, pickerNavigate, moveSourceLocator{}) | |||
| 730 | + | target, selectable := model.Selected() | |||
| 731 | + | require.True(t, selectable) | |||
| 732 | + | require.Empty(t, target.Name, "live proxy rows must route only by their structured remote key") | |||
| 733 | + | localToken = roleEffectForTest(t, local.attachmentToken(ac, ac.transport())) | |||
| 734 | + | require.NoError(t, d.switchToTargetForRole(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select")) | |||
| 735 | + | require.Same(t, proxy, ac.currentAttachmentSession()) | |||
| 736 | + | require.False(t, ac.overlays.pickerActive()) | |||
| 737 | + | } | |||
| 738 | + | ||||
| 739 | + | func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T) { | |||
| 740 | + | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 741 | + | client := newProxyTestTransport() | |||
| 742 | + | ac.replaceTransport(client) | |||
| 743 | + | d.attachCoordinator(local, nil, ac, true) | |||
| 744 | + | ||||
| 745 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 746 | + | remote := newProxyTestTransport() | |||
| 747 | + | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |||
| 748 | + | remote.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 749 | + | d.remoteDialerFactory = newProxyConstructionFactory(remote) | |||
| 750 | + | d.remoteTransportMode = ports.RemoteTransportUDP | |||
| 751 | + | ||||
| 752 | + | loopDone := make(chan struct{}) | |||
| 753 | + | go func() { | |||
| 754 | + | d.runConnLoop(ac) | |||
| 755 | + | close(loopDone) | |||
| 756 | + | }() | |||
| 757 | + | require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond) | |||
| 758 | + | ||||
| 759 | + | token := roleEffectForTest(t, local.attachmentToken(ac, client)) | |||
| 760 | + | require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")) | |||
| 761 | + | proxy, ok := ac.currentAttachmentSession().(*proxySession) | |||
| 762 | + | require.True(t, ok) | |||
| 763 | + | t.Cleanup(func() { stopProxy(t, proxy) }) | |||
| 764 | + | ||||
| 765 | + | client.recv <- proxyRecv{err: io.EOF} | |||
| 766 | + | awaitTestCompletion(t, loopDone, "connection loop did not return after receive failure") | |||
| 767 | + | require.Nil(t, ac.currentAttachmentSession(), "receive failure cleaned the stale local role instead of the current proxy role") | |||
| 768 | + | } | |||
| 769 | + | ||||
| 770 | + | func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) { | |||
| 771 | + | d, _, ac, _, handler := newProxyInputHarness(t) | |||
| 772 | + | client := ac.transport().(*proxyTestTransport) | |||
| 773 | + | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 774 | + | local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}} | |||
| 775 | + | publishTiledPaneOwners(local, tb) | |||
| 776 | + | d.mu.Lock() | |||
| 777 | + | require.True(t, d.registerSessionLocked(local)) | |||
| 778 | + | d.mu.Unlock() | |||
| 779 | + | ||||
| 780 | + | loopDone := make(chan struct{}) | |||
| 781 | + | go func() { | |||
| 782 | + | d.runConnLoop(ac) | |||
| 783 | + | close(loopDone) | |||
| 784 | + | }() | |||
| 785 | + | require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond) | |||
| 786 | + | ||||
| 787 | + | require.NoError(t, d.switchToTargetForRole(handler.roleToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select")) | |||
| 788 | + | require.Same(t, local, ac.currentSession()) | |||
| 789 | + | client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}} | |||
| 790 | + | awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session") | |||
| 791 | + | require.Nil(t, ac.currentAttachmentSession()) | |||
| 792 | + | } | |||
| 793 | + | ||||
| 794 | + | func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) { | |||
| 795 | + | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 796 | + | client := newProxyTestTransport() | |||
| 797 | + | ac.replaceTransport(client) | |||
| 798 | + | d.attachCoordinator(local, nil, ac, true) | |||
| 799 | + | ||||
| 800 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 801 | + | remote := newProxyTestTransport() | |||
| 802 | + | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |||
| 803 | + | remote.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 804 | + | d.remoteDialerFactory = newProxyConstructionFactory(remote) | |||
| 805 | + | d.remoteTransportMode = ports.RemoteTransportUDP | |||
| 806 | + | ||||
| 807 | + | loopDone := make(chan struct{}) | |||
| 808 | + | go func() { | |||
| 809 | + | d.runConnLoop(ac) | |||
| 810 | + | close(loopDone) | |||
| 811 | + | }() | |||
| 812 | + | require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond) | |||
| 813 | + | ||||
| 814 | + | token := local.attachmentToken(ac, client) | |||
| 815 | + | effect, admitted := ac.beginRoleEffect(token) | |||
| 816 | + | require.True(t, admitted) | |||
| 817 | + | token.effect = effect | |||
| 818 | + | require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")) | |||
| 819 | + | proxy, ok := ac.currentAttachmentSession().(*proxySession) | |||
| 820 | + | require.True(t, ok) | |||
| 821 | + | t.Cleanup(func() { stopProxy(t, proxy) }) | |||
| 822 | + | ||||
| 823 | + | client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}} | |||
| 824 | + | awaitTestCompletion(t, loopDone, "connection loop did not handle input after the proxy handoff") | |||
| 825 | + | require.Nil(t, ac.currentAttachmentSession(), "connection loop abandoned the client after the proxy handoff") | |||
| 826 | + | } | |||
M internal/usecase/daemon/remote_picker.go +1 -0
| @@ -151,6 +151,7 @@ func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker | |||
| 151 | 151 | view.Name += " [expired]" | |
| 152 | 152 | } | |
| 153 | 153 | view.TargetName = "" | |
| 154 | + | view.ExpectedCreatedAt = nil | |
| 154 | 155 | view.RemoteKey = &key | |
| 155 | 156 | view.RemoteAvailability = picker.RemoteFresh | |
| 156 | 157 | view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount)) | |
| @@ -151,6 +151,7 @@ func remoteProxyPickerView(key domain.RemoteSessionKey, snap sessionView) picker | |||||
| 151 | view.Name += " [expired]" | 151 | view.Name += " [expired]" | ||
| 152 | } | 152 | } | ||
| 153 | view.TargetName = "" | 153 | view.TargetName = "" | ||
| 154 | + | view.ExpectedCreatedAt = nil | |||
| 154 | view.RemoteKey = &key | 155 | view.RemoteKey = &key | ||
| 155 | view.RemoteAvailability = picker.RemoteFresh | 156 | view.RemoteAvailability = picker.RemoteFresh | ||
| 156 | view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount)) | 157 | view.RemoteDetail = remotePickerDetail(uint16(snap.tabCount)) | ||