fix(remote): complete proxy navigation fallbacks
- commit
- 7dbc84a39d0b1a0600223bf8e392b6cdd8a12199
- author
- brice · · 2026-07-31 21:20 UTC
- parent
- d2f9957
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +38 -4
M internal/usecase/daemon/client_frame_routing.go +2 -0
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "runtime" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/internal/ports" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/command" | |
| @@ -21,6 +22,7 @@ func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) ( | |||
| 21 | 22 | } | |
| 22 | 23 | token := attachmentToken(sess, ac, tr) | |
| 23 | 24 | if token.role == attachmentDetached || ac.currentAttachmentSession() != sess { | |
| 25 | + | runtime.Gosched() | |
| 24 | 26 | continue | |
| 25 | 27 | } | |
| 26 | 28 | return sess, token, true | |
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | + | "runtime" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/vev/internal/ports" | 7 | "github.com/bnema/vev/internal/ports" | ||
| 7 | "github.com/bnema/vev/internal/usecase/command" | 8 | "github.com/bnema/vev/internal/usecase/command" | ||
| @@ -21,6 +22,7 @@ func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) ( | |||||
| 21 | } | 22 | } | ||
| 22 | token := attachmentToken(sess, ac, tr) | 23 | token := attachmentToken(sess, ac, tr) | ||
| 23 | if token.role == attachmentDetached || ac.currentAttachmentSession() != sess { | 24 | if token.role == attachmentDetached || ac.currentAttachmentSession() != sess { | ||
| 25 | + | runtime.Gosched() | |||
| 24 | continue | 26 | continue | ||
| 25 | } | 27 | } | ||
| 26 | return sess, token, true | 28 | return sess, token, true | ||
M internal/usecase/daemon/picker.go +8 -1
| @@ -610,7 +610,14 @@ func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, tar | |||
| 610 | 610 | d.mu.Lock() | |
| 611 | 611 | targetSess, ok := localSession(d.sessions[target.Session]) | |
| 612 | 612 | d.mu.Unlock() | |
| 613 | - | if !ok || targetSess == token.sess { | |
| 613 | + | if !ok { | |
| 614 | + | if !token.activeCurrent() { | |
| 615 | + | return nil | |
| 616 | + | } | |
| 617 | + | d.invalidateRender(token.sess, token.ac, true, "picker.go") | |
| 618 | + | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil) | |
| 619 | + | } | |
| 620 | + | if targetSess == token.sess { | |
| 614 | 621 | return nil | |
| 615 | 622 | } | |
| 616 | 623 | ||
| @@ -610,7 +610,14 @@ func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, tar | |||||
| 610 | d.mu.Lock() | 610 | d.mu.Lock() | ||
| 611 | targetSess, ok := localSession(d.sessions[target.Session]) | 611 | targetSess, ok := localSession(d.sessions[target.Session]) | ||
| 612 | d.mu.Unlock() | 612 | d.mu.Unlock() | ||
| 613 | - | if !ok || targetSess == token.sess { | 613 | + | if !ok { |
| 614 | + | if !token.activeCurrent() { | |||
| 615 | + | return nil | |||
| 616 | + | } | |||
| 617 | + | d.invalidateRender(token.sess, token.ac, true, "picker.go") | |||
| 618 | + | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil) | |||
| 619 | + | } | |||
| 620 | + | if targetSess == token.sess { | |||
| 614 | return nil | 621 | return nil | ||
| 615 | } | 622 | } | ||
| 616 | 623 | ||||
M internal/usecase/daemon/proxy_input_test.go +15 -1
| @@ -708,6 +708,19 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) { | |||
| 708 | 708 | localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport())) | |
| 709 | 709 | require.NoError(t, d.backSessionForRole(localToken)) | |
| 710 | 710 | require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy") | |
| 711 | + | ||
| 712 | + | proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport())) | |
| 713 | + | require.NoError(t, d.switchToTargetForRole(proxyToken, target, sessionHandoffGuard{}, "picker-select")) | |
| 714 | + | require.Same(t, local, ac.currentSession()) | |
| 715 | + | d.backSession(local, ac) | |
| 716 | + | require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity") | |
| 717 | + | } | |
| 718 | + | ||
| 719 | + | func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) { | |
| 720 | + | d, proxy, ac, _, handler := newProxyInputHarness(t) | |
| 721 | + | err := d.switchToTargetForRole(handler.roleToken, picker.Target{Session: "missing", TabIndex: 0}, sessionHandoffGuard{}, "picker-select") | |
| 722 | + | require.Error(t, err) | |
| 723 | + | require.Same(t, proxy, ac.currentAttachmentSession()) | |
| 711 | 724 | } | |
| 712 | 725 | ||
| 713 | 726 | func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) { | |
| @@ -862,7 +875,8 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T) | |||
| 862 | 875 | ||
| 863 | 876 | func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) { | |
| 864 | 877 | d, _, ac, _, handler := newProxyInputHarness(t) | |
| 865 | - | client := ac.transport().(*proxyTestTransport) | |
| 878 | + | client, ok := ac.transport().(*proxyTestTransport) | |
| 879 | + | require.True(t, ok) | |
| 866 | 880 | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 867 | 881 | local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}} | |
| 868 | 882 | publishTiledPaneOwners(local, tb) | |
| @@ -708,6 +708,19 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) { | |||||
| 708 | localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport())) | 708 | localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport())) | ||
| 709 | require.NoError(t, d.backSessionForRole(localToken)) | 709 | require.NoError(t, d.backSessionForRole(localToken)) | ||
| 710 | require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy") | 710 | require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy") | ||
| 711 | + | ||||
| 712 | + | proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport())) | |||
| 713 | + | require.NoError(t, d.switchToTargetForRole(proxyToken, target, sessionHandoffGuard{}, "picker-select")) | |||
| 714 | + | require.Same(t, local, ac.currentSession()) | |||
| 715 | + | d.backSession(local, ac) | |||
| 716 | + | require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity") | |||
| 717 | + | } | |||
| 718 | + | ||||
| 719 | + | func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) { | |||
| 720 | + | d, proxy, ac, _, handler := newProxyInputHarness(t) | |||
| 721 | + | err := d.switchToTargetForRole(handler.roleToken, picker.Target{Session: "missing", TabIndex: 0}, sessionHandoffGuard{}, "picker-select") | |||
| 722 | + | require.Error(t, err) | |||
| 723 | + | require.Same(t, proxy, ac.currentAttachmentSession()) | |||
| 711 | } | 724 | } | ||
| 712 | 725 | ||||
| 713 | func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) { | 726 | func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) { | ||
| @@ -862,7 +875,8 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T) | |||||
| 862 | 875 | ||||
| 863 | func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) { | 876 | func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) { | ||
| 864 | d, _, ac, _, handler := newProxyInputHarness(t) | 877 | d, _, ac, _, handler := newProxyInputHarness(t) | ||
| 865 | - | client := ac.transport().(*proxyTestTransport) | 878 | + | client, ok := ac.transport().(*proxyTestTransport) |
| 879 | + | require.True(t, ok) | |||
| 866 | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | 880 | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | ||
| 867 | local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}} | 881 | local := &session{sessionCore: sessionCore{id: "local", name: "local"}, tabs: []*tab{tb}} | ||
| 868 | publishTiledPaneOwners(local, tb) | 882 | publishTiledPaneOwners(local, tb) | ||
M internal/usecase/daemon/session_back.go +13 -2
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/internal/usecase/picker" | |
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | // backSession toggles this attachment between its current session and the | |
| 6 | 9 | // immediately preceding successfully activated session. | |
| @@ -31,7 +34,15 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) { | |||
| 31 | 34 | d.invalidateRender(current, ac, true, "session_back.go") | |
| 32 | 35 | return | |
| 33 | 36 | } | |
| 34 | - | if err := d.switchToTarget(current, ac, picker.Target{Session: target.core().id, TabIndex: -1}); err != nil { | |
| 37 | + | token := attachmentToken(current, ac, ac.transport()) | |
| 38 | + | effect, admitted := ac.beginRoleEffect(token) | |
| 39 | + | if !admitted { | |
| 40 | + | d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition)) | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | token.effect = effect | |
| 44 | + | defer effect.End() | |
| 45 | + | if err := d.backSessionForRole(token); err != nil { | |
| 35 | 46 | d.reportError(current, err) | |
| 36 | 47 | } | |
| 37 | 48 | } | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/vev/internal/usecase/picker" | 3 | + | import ( |
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 6 | + | ) | |||
| 4 | 7 | ||||
| 5 | // backSession toggles this attachment between its current session and the | 8 | // backSession toggles this attachment between its current session and the | ||
| 6 | // immediately preceding successfully activated session. | 9 | // immediately preceding successfully activated session. | ||
| @@ -31,7 +34,15 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) { | |||||
| 31 | d.invalidateRender(current, ac, true, "session_back.go") | 34 | d.invalidateRender(current, ac, true, "session_back.go") | ||
| 32 | return | 35 | return | ||
| 33 | } | 36 | } | ||
| 34 | - | if err := d.switchToTarget(current, ac, picker.Target{Session: target.core().id, TabIndex: -1}); err != nil { | 37 | + | token := attachmentToken(current, ac, ac.transport()) |
| 38 | + | effect, admitted := ac.beginRoleEffect(token) | |||
| 39 | + | if !admitted { | |||
| 40 | + | d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition)) | |||
| 41 | + | return | |||
| 42 | + | } | |||
| 43 | + | token.effect = effect | |||
| 44 | + | defer effect.End() | |||
| 45 | + | if err := d.backSessionForRole(token); err != nil { | |||
| 35 | d.reportError(current, err) | 46 | d.reportError(current, err) | ||
| 36 | } | 47 | } | ||
| 37 | } | 48 | } | ||