test(daemon): cover remote ownership recovery
- commit
- e6d3912950227eedeb1e9c5b8e8f8af51f32e630
- author
- brice · · 2026-08-07 05:54 UTC
- parent
- f1b7847
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +233 -0
A internal/usecase/daemon/remote_attachment_geometry_test.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func attachRemoteGeometryClient(t *testing.T, view *remoteView, size domain.Size) (*attachedClient, chan ports.Frame) { | |
| 13 | + | t.Helper() | |
| 14 | + | transport, sends := newCapturingTransport(t) | |
| 15 | + | ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: size} | |
| 16 | + | ac.initOverlays() | |
| 17 | + | ac.setAttachmentOwner(view) | |
| 18 | + | view.mu.Lock() | |
| 19 | + | require.True(t, view.registerAttachmentLocked(ac)) | |
| 20 | + | view.mu.Unlock() | |
| 21 | + | return ac, sends | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func resizeRemoteGeometryAttachment(t *testing.T, d *Daemon, view *remoteView, ac *attachedClient, size domain.Size, remoteSends chan ports.Frame) domain.Size { | |
| 25 | + | t.Helper() | |
| 26 | + | token := attachmentOwnerToken(view, ac, ac.transport()) | |
| 27 | + | require.True(t, token.current()) | |
| 28 | + | require.True(t, d.resizeAttachmentForLease(token, size)) | |
| 29 | + | ||
| 30 | + | frame := awaitFrame(t, remoteSends, ports.MsgResize) | |
| 31 | + | resize, err := ports.UnmarshalResize(frame.Payload) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | require.Equal(t, contentSize(size), resize.Size) | |
| 34 | + | return resize.Size | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func remoteViewScreenSize(t *testing.T, view *remoteView) domain.Size { | |
| 38 | + | t.Helper() | |
| 39 | + | view.mu.Lock() | |
| 40 | + | defer view.mu.Unlock() | |
| 41 | + | return domain.Size{Cols: view.screen.Frame.Width, Rows: view.screen.Frame.Height} | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestRemoteViewContentGeometryFollowsLatestValidAttachmentClaim(t *testing.T) { | |
| 45 | + | d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t) | |
| 46 | + | t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) }) | |
| 47 | + | first, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 80, Rows: 24}) | |
| 48 | + | second, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 120, Rows: 40}) | |
| 49 | + | ||
| 50 | + | // A smaller, later claim is authoritative even when a larger peer remains | |
| 51 | + | // attached; shared remote content follows claim order, not max dimensions. | |
| 52 | + | resizeRemoteGeometryAttachment(t, d, view, first, domain.Size{Cols: 120, Rows: 40}, remoteTransport.sent) | |
| 53 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 38}, remoteViewScreenSize(t, view)) | |
| 54 | + | resizeRemoteGeometryAttachment(t, d, view, second, domain.Size{Cols: 90, Rows: 30}, remoteTransport.sent) | |
| 55 | + | require.Equal(t, domain.Size{Cols: 90, Rows: 28}, remoteViewScreenSize(t, view)) | |
| 56 | + | ||
| 57 | + | // Invalid claims do not displace the most recent valid geometry. | |
| 58 | + | token := attachmentOwnerToken(view, first, first.transport()) | |
| 59 | + | require.False(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 0, Rows: 0})) | |
| 60 | + | require.Equal(t, domain.Size{Cols: 90, Rows: 28}, remoteViewScreenSize(t, view)) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestNonAuthoritativeRemoteAttachmentRenderCannotResizeOrMutateSharedVT(t *testing.T) { | |
| 64 | + | d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t) | |
| 65 | + | t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) }) | |
| 66 | + | nonAuthoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 60, Rows: 20}) | |
| 67 | + | authoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 110, Rows: 35}) | |
| 68 | + | resizeRemoteGeometryAttachment(t, d, view, authoritative, domain.Size{Cols: 120, Rows: 40}, remoteTransport.sent) | |
| 69 | + | ||
| 70 | + | view.mu.Lock() | |
| 71 | + | view.screen.Write([]byte("shared remote state")) | |
| 72 | + | before := screenLineText(view.screen, 0) | |
| 73 | + | view.mu.Unlock() | |
| 74 | + | ||
| 75 | + | state, ok := d.captureRemoteViewRenderState(view, nonAuthoritative, renderCaptureRequest{}) | |
| 76 | + | require.True(t, ok) | |
| 77 | + | require.Len(t, state.panes, 1) | |
| 78 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 38}, domain.Size{Cols: state.panes[0].frame.Width, Rows: state.panes[0].frame.Height}) | |
| 79 | + | ||
| 80 | + | // The composed snapshot is detached from the persistent remote VT too. | |
| 81 | + | state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X'}) | |
| 82 | + | view.mu.Lock() | |
| 83 | + | after := screenLineText(view.screen, 0) | |
| 84 | + | size := domain.Size{Cols: view.screen.Frame.Width, Rows: view.screen.Frame.Height} | |
| 85 | + | view.mu.Unlock() | |
| 86 | + | require.Equal(t, before, after) | |
| 87 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 38}, size) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestRemoteResizeForwardingUsesAuthoritativeContentSize(t *testing.T) { | |
| 91 | + | d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t) | |
| 92 | + | t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) }) | |
| 93 | + | authoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 80, Rows: 24}) | |
| 94 | + | peer, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 160, Rows: 50}) | |
| 95 | + | ||
| 96 | + | // The authoritative attachment's window is smaller than its peer's. The | |
| 97 | + | // forwarded remote resize must describe the authoritative content region, | |
| 98 | + | // exactly once past the local chrome boundary. | |
| 99 | + | want := domain.Size{Cols: 100, Rows: 28} | |
| 100 | + | got := resizeRemoteGeometryAttachment(t, d, view, authoritative, domain.Size{Cols: 100, Rows: 30}, remoteTransport.sent) | |
| 101 | + | require.Equal(t, want, got) | |
| 102 | + | require.Equal(t, want, remoteViewScreenSize(t, view)) | |
| 103 | + | ||
| 104 | + | // Rendering the larger peer is attachment-local and cannot replace the | |
| 105 | + | // authoritative content size used by the remote link. | |
| 106 | + | state, ok := d.captureRemoteViewRenderState(view, peer, renderCaptureRequest{}) | |
| 107 | + | require.True(t, ok) | |
| 108 | + | require.Equal(t, want, domain.Size{Cols: state.panes[0].frame.Width, Rows: state.panes[0].frame.Height}) | |
| 109 | + | require.Equal(t, want, remoteViewScreenSize(t, view)) | |
| 110 | + | } | |
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func attachRemoteGeometryClient(t *testing.T, view *remoteView, size domain.Size) (*attachedClient, chan ports.Frame) { | |||
| 13 | + | t.Helper() | |||
| 14 | + | transport, sends := newCapturingTransport(t) | |||
| 15 | + | ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: size} | |||
| 16 | + | ac.initOverlays() | |||
| 17 | + | ac.setAttachmentOwner(view) | |||
| 18 | + | view.mu.Lock() | |||
| 19 | + | require.True(t, view.registerAttachmentLocked(ac)) | |||
| 20 | + | view.mu.Unlock() | |||
| 21 | + | return ac, sends | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func resizeRemoteGeometryAttachment(t *testing.T, d *Daemon, view *remoteView, ac *attachedClient, size domain.Size, remoteSends chan ports.Frame) domain.Size { | |||
| 25 | + | t.Helper() | |||
| 26 | + | token := attachmentOwnerToken(view, ac, ac.transport()) | |||
| 27 | + | require.True(t, token.current()) | |||
| 28 | + | require.True(t, d.resizeAttachmentForLease(token, size)) | |||
| 29 | + | ||||
| 30 | + | frame := awaitFrame(t, remoteSends, ports.MsgResize) | |||
| 31 | + | resize, err := ports.UnmarshalResize(frame.Payload) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | require.Equal(t, contentSize(size), resize.Size) | |||
| 34 | + | return resize.Size | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func remoteViewScreenSize(t *testing.T, view *remoteView) domain.Size { | |||
| 38 | + | t.Helper() | |||
| 39 | + | view.mu.Lock() | |||
| 40 | + | defer view.mu.Unlock() | |||
| 41 | + | return domain.Size{Cols: view.screen.Frame.Width, Rows: view.screen.Frame.Height} | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestRemoteViewContentGeometryFollowsLatestValidAttachmentClaim(t *testing.T) { | |||
| 45 | + | d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t) | |||
| 46 | + | t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) }) | |||
| 47 | + | first, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 80, Rows: 24}) | |||
| 48 | + | second, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 120, Rows: 40}) | |||
| 49 | + | ||||
| 50 | + | // A smaller, later claim is authoritative even when a larger peer remains | |||
| 51 | + | // attached; shared remote content follows claim order, not max dimensions. | |||
| 52 | + | resizeRemoteGeometryAttachment(t, d, view, first, domain.Size{Cols: 120, Rows: 40}, remoteTransport.sent) | |||
| 53 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 38}, remoteViewScreenSize(t, view)) | |||
| 54 | + | resizeRemoteGeometryAttachment(t, d, view, second, domain.Size{Cols: 90, Rows: 30}, remoteTransport.sent) | |||
| 55 | + | require.Equal(t, domain.Size{Cols: 90, Rows: 28}, remoteViewScreenSize(t, view)) | |||
| 56 | + | ||||
| 57 | + | // Invalid claims do not displace the most recent valid geometry. | |||
| 58 | + | token := attachmentOwnerToken(view, first, first.transport()) | |||
| 59 | + | require.False(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 0, Rows: 0})) | |||
| 60 | + | require.Equal(t, domain.Size{Cols: 90, Rows: 28}, remoteViewScreenSize(t, view)) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestNonAuthoritativeRemoteAttachmentRenderCannotResizeOrMutateSharedVT(t *testing.T) { | |||
| 64 | + | d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t) | |||
| 65 | + | t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) }) | |||
| 66 | + | nonAuthoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 60, Rows: 20}) | |||
| 67 | + | authoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 110, Rows: 35}) | |||
| 68 | + | resizeRemoteGeometryAttachment(t, d, view, authoritative, domain.Size{Cols: 120, Rows: 40}, remoteTransport.sent) | |||
| 69 | + | ||||
| 70 | + | view.mu.Lock() | |||
| 71 | + | view.screen.Write([]byte("shared remote state")) | |||
| 72 | + | before := screenLineText(view.screen, 0) | |||
| 73 | + | view.mu.Unlock() | |||
| 74 | + | ||||
| 75 | + | state, ok := d.captureRemoteViewRenderState(view, nonAuthoritative, renderCaptureRequest{}) | |||
| 76 | + | require.True(t, ok) | |||
| 77 | + | require.Len(t, state.panes, 1) | |||
| 78 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 38}, domain.Size{Cols: state.panes[0].frame.Width, Rows: state.panes[0].frame.Height}) | |||
| 79 | + | ||||
| 80 | + | // The composed snapshot is detached from the persistent remote VT too. | |||
| 81 | + | state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X'}) | |||
| 82 | + | view.mu.Lock() | |||
| 83 | + | after := screenLineText(view.screen, 0) | |||
| 84 | + | size := domain.Size{Cols: view.screen.Frame.Width, Rows: view.screen.Frame.Height} | |||
| 85 | + | view.mu.Unlock() | |||
| 86 | + | require.Equal(t, before, after) | |||
| 87 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 38}, size) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestRemoteResizeForwardingUsesAuthoritativeContentSize(t *testing.T) { | |||
| 91 | + | d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t) | |||
| 92 | + | t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) }) | |||
| 93 | + | authoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 80, Rows: 24}) | |||
| 94 | + | peer, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 160, Rows: 50}) | |||
| 95 | + | ||||
| 96 | + | // The authoritative attachment's window is smaller than its peer's. The | |||
| 97 | + | // forwarded remote resize must describe the authoritative content region, | |||
| 98 | + | // exactly once past the local chrome boundary. | |||
| 99 | + | want := domain.Size{Cols: 100, Rows: 28} | |||
| 100 | + | got := resizeRemoteGeometryAttachment(t, d, view, authoritative, domain.Size{Cols: 100, Rows: 30}, remoteTransport.sent) | |||
| 101 | + | require.Equal(t, want, got) | |||
| 102 | + | require.Equal(t, want, remoteViewScreenSize(t, view)) | |||
| 103 | + | ||||
| 104 | + | // Rendering the larger peer is attachment-local and cannot replace the | |||
| 105 | + | // authoritative content size used by the remote link. | |||
| 106 | + | state, ok := d.captureRemoteViewRenderState(view, peer, renderCaptureRequest{}) | |||
| 107 | + | require.True(t, ok) | |||
| 108 | + | require.Equal(t, want, domain.Size{Cols: state.panes[0].frame.Width, Rows: state.panes[0].frame.Height}) | |||
| 109 | + | require.Equal(t, want, remoteViewScreenSize(t, view)) | |||
| 110 | + | } | |||
M internal/usecase/daemon/remote_link_test.go +56 -0
| @@ -669,6 +669,62 @@ func TestRemoteLinkFailedAutoReconnectMarksViewUnavailableWithoutDetachingLocalO | |||
| 669 | 669 | d.shutdownAll(ports.ReasonServerShutdown) | |
| 670 | 670 | } | |
| 671 | 671 | ||
| 672 | + | func TestStaleFailedReconnectCannotDegradeReplacementGeneration(t *testing.T) { | |
| 673 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 674 | + | target := remoteLinkTestTarget() | |
| 675 | + | size := domain.Size{Cols: 80, Rows: 24} | |
| 676 | + | firstTransport := newRemoteLinkTestTransport() | |
| 677 | + | candidateTransport := newRemoteLinkTestTransport() | |
| 678 | + | enqueueRemoteLinkHandshake(t, firstTransport, target, size) | |
| 679 | + | factory := &remoteLinkSequenceFactory{dialers: []ports.Dialer{ | |
| 680 | + | &remoteLinkTestDialer{transport: firstTransport}, | |
| 681 | + | &remoteLinkTestDialer{transport: candidateTransport}, | |
| 682 | + | }} | |
| 683 | + | d.remoteDialerFactory = factory | |
| 684 | + | ||
| 685 | + | view, err := d.openRemoteView(context.Background(), target, size) | |
| 686 | + | require.NoError(t, err) | |
| 687 | + | awaitFrame(t, firstTransport.sent, ports.MsgHello) | |
| 688 | + | awaitFrame(t, firstTransport.sent, ports.MsgAck) | |
| 689 | + | attachment := &attachedClient{tr: &closeTrackingTransport{}} | |
| 690 | + | attachment.setAttachmentOwner(view) | |
| 691 | + | view.mu.Lock() | |
| 692 | + | require.True(t, view.registerAttachmentLocked(attachment)) | |
| 693 | + | oldLink := view.link | |
| 694 | + | view.mu.Unlock() | |
| 695 | + | ||
| 696 | + | firstTransport.recv <- remoteLinkTestReceive{err: io.EOF} | |
| 697 | + | awaitFrame(t, candidateTransport.sent, ports.MsgHello) | |
| 698 | + | ||
| 699 | + | // A newer publication wins while the single automatic attempt is still | |
| 700 | + | // waiting for its candidate handshake. Its later failure must not turn the | |
| 701 | + | // replacement view unavailable or start a second retry. | |
| 702 | + | replacementTransport := newRemoteLinkTestTransport() | |
| 703 | + | view.mu.Lock() | |
| 704 | + | view.linkGeneration++ | |
| 705 | + | replacement := &remoteLink{ | |
| 706 | + | view: view, generation: view.linkGeneration, target: target, transport: replacementTransport, | |
| 707 | + | cancel: func() {}, done: make(chan struct{}), active: true, | |
| 708 | + | } | |
| 709 | + | view.link = replacement | |
| 710 | + | view.linkState = remoteViewLinkHealthy | |
| 711 | + | view.reconnectGeneration++ | |
| 712 | + | view.mu.Unlock() | |
| 713 | + | candidateTransport.recv <- remoteLinkTestReceive{err: io.EOF} | |
| 714 | + | awaitTestCompletion(t, candidateTransport.closed, "stale reconnect candidate was not closed") | |
| 715 | + | ||
| 716 | + | view.mu.Lock() | |
| 717 | + | require.Same(t, replacement, view.link) | |
| 718 | + | require.Equal(t, remoteViewLinkHealthy, view.linkState) | |
| 719 | + | view.mu.Unlock() | |
| 720 | + | require.Same(t, view, attachment.currentAttachmentOwner()) | |
| 721 | + | require.Equal(t, int32(2), factory.calls.Load(), "a stale failed reconnect must not start another retry") | |
| 722 | + | ||
| 723 | + | d.markRemoteLinkUnavailable(oldLink) | |
| 724 | + | require.Equal(t, int32(2), factory.calls.Load(), "a retired link cannot start another retry") | |
| 725 | + | d.shutdownAll(ports.ReasonServerShutdown) | |
| 726 | + | } | |
| 727 | + | ||
| 672 | 728 | func TestOpenRemoteViewFailedReconnectPreservesViewUntilValidatedPublication(t *testing.T) { | |
| 673 | 729 | d := newTestDaemon(t, nil, stubClock{}) | |
| 674 | 730 | target := remoteLinkTestTarget() | |
| @@ -669,6 +669,62 @@ func TestRemoteLinkFailedAutoReconnectMarksViewUnavailableWithoutDetachingLocalO | |||||
| 669 | d.shutdownAll(ports.ReasonServerShutdown) | 669 | d.shutdownAll(ports.ReasonServerShutdown) | ||
| 670 | } | 670 | } | ||
| 671 | 671 | ||||
| 672 | + | func TestStaleFailedReconnectCannotDegradeReplacementGeneration(t *testing.T) { | |||
| 673 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 674 | + | target := remoteLinkTestTarget() | |||
| 675 | + | size := domain.Size{Cols: 80, Rows: 24} | |||
| 676 | + | firstTransport := newRemoteLinkTestTransport() | |||
| 677 | + | candidateTransport := newRemoteLinkTestTransport() | |||
| 678 | + | enqueueRemoteLinkHandshake(t, firstTransport, target, size) | |||
| 679 | + | factory := &remoteLinkSequenceFactory{dialers: []ports.Dialer{ | |||
| 680 | + | &remoteLinkTestDialer{transport: firstTransport}, | |||
| 681 | + | &remoteLinkTestDialer{transport: candidateTransport}, | |||
| 682 | + | }} | |||
| 683 | + | d.remoteDialerFactory = factory | |||
| 684 | + | ||||
| 685 | + | view, err := d.openRemoteView(context.Background(), target, size) | |||
| 686 | + | require.NoError(t, err) | |||
| 687 | + | awaitFrame(t, firstTransport.sent, ports.MsgHello) | |||
| 688 | + | awaitFrame(t, firstTransport.sent, ports.MsgAck) | |||
| 689 | + | attachment := &attachedClient{tr: &closeTrackingTransport{}} | |||
| 690 | + | attachment.setAttachmentOwner(view) | |||
| 691 | + | view.mu.Lock() | |||
| 692 | + | require.True(t, view.registerAttachmentLocked(attachment)) | |||
| 693 | + | oldLink := view.link | |||
| 694 | + | view.mu.Unlock() | |||
| 695 | + | ||||
| 696 | + | firstTransport.recv <- remoteLinkTestReceive{err: io.EOF} | |||
| 697 | + | awaitFrame(t, candidateTransport.sent, ports.MsgHello) | |||
| 698 | + | ||||
| 699 | + | // A newer publication wins while the single automatic attempt is still | |||
| 700 | + | // waiting for its candidate handshake. Its later failure must not turn the | |||
| 701 | + | // replacement view unavailable or start a second retry. | |||
| 702 | + | replacementTransport := newRemoteLinkTestTransport() | |||
| 703 | + | view.mu.Lock() | |||
| 704 | + | view.linkGeneration++ | |||
| 705 | + | replacement := &remoteLink{ | |||
| 706 | + | view: view, generation: view.linkGeneration, target: target, transport: replacementTransport, | |||
| 707 | + | cancel: func() {}, done: make(chan struct{}), active: true, | |||
| 708 | + | } | |||
| 709 | + | view.link = replacement | |||
| 710 | + | view.linkState = remoteViewLinkHealthy | |||
| 711 | + | view.reconnectGeneration++ | |||
| 712 | + | view.mu.Unlock() | |||
| 713 | + | candidateTransport.recv <- remoteLinkTestReceive{err: io.EOF} | |||
| 714 | + | awaitTestCompletion(t, candidateTransport.closed, "stale reconnect candidate was not closed") | |||
| 715 | + | ||||
| 716 | + | view.mu.Lock() | |||
| 717 | + | require.Same(t, replacement, view.link) | |||
| 718 | + | require.Equal(t, remoteViewLinkHealthy, view.linkState) | |||
| 719 | + | view.mu.Unlock() | |||
| 720 | + | require.Same(t, view, attachment.currentAttachmentOwner()) | |||
| 721 | + | require.Equal(t, int32(2), factory.calls.Load(), "a stale failed reconnect must not start another retry") | |||
| 722 | + | ||||
| 723 | + | d.markRemoteLinkUnavailable(oldLink) | |||
| 724 | + | require.Equal(t, int32(2), factory.calls.Load(), "a retired link cannot start another retry") | |||
| 725 | + | d.shutdownAll(ports.ReasonServerShutdown) | |||
| 726 | + | } | |||
| 727 | + | ||||
| 672 | func TestOpenRemoteViewFailedReconnectPreservesViewUntilValidatedPublication(t *testing.T) { | 728 | func TestOpenRemoteViewFailedReconnectPreservesViewUntilValidatedPublication(t *testing.T) { | ||
| 673 | d := newTestDaemon(t, nil, stubClock{}) | 729 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 674 | target := remoteLinkTestTarget() | 730 | target := remoteLinkTestTarget() | ||
M internal/usecase/daemon/remote_view_tabs.go +3 -0
| @@ -135,6 +135,9 @@ func remoteViewRelativeTabCommand(view *remoteView, link *remoteLink, generation | |||
| 135 | 135 | return "previous-tab", nil | |
| 136 | 136 | } | |
| 137 | 137 | ||
| 138 | + | // sendRemoteViewCommand is intentionally narrower than the remote daemon's | |
| 139 | + | // attached-command executor. It is not palette forwarding: stable remote | |
| 140 | + | // metadata only supports the two no-argument relative tab operations. | |
| 138 | 141 | func (d *Daemon) sendRemoteViewCommand(ctx context.Context, link *remoteLink, generation uint64, slug string) error { | |
| 139 | 142 | if d == nil || link == nil || link.commands == nil { | |
| 140 | 143 | return errRemoteViewUnavailable | |
| @@ -135,6 +135,9 @@ func remoteViewRelativeTabCommand(view *remoteView, link *remoteLink, generation | |||||
| 135 | return "previous-tab", nil | 135 | return "previous-tab", nil | ||
| 136 | } | 136 | } | ||
| 137 | 137 | ||||
| 138 | + | // sendRemoteViewCommand is intentionally narrower than the remote daemon's | |||
| 139 | + | // attached-command executor. It is not palette forwarding: stable remote | |||
| 140 | + | // metadata only supports the two no-argument relative tab operations. | |||
| 138 | func (d *Daemon) sendRemoteViewCommand(ctx context.Context, link *remoteLink, generation uint64, slug string) error { | 141 | func (d *Daemon) sendRemoteViewCommand(ctx context.Context, link *remoteLink, generation uint64, slug string) error { | ||
| 139 | if d == nil || link == nil || link.commands == nil { | 142 | if d == nil || link == nil || link.commands == nil { | ||
| 140 | return errRemoteViewUnavailable | 143 | return errRemoteViewUnavailable | ||
A internal/usecase/daemon/remote_view_tabs_test.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestSendRemoteViewCommandAllowsOnlyRelativeTabCommands(t *testing.T) { | |
| 12 | + | for _, slug := range []string{"next-tab", "previous-tab"} { | |
| 13 | + | t.Run(slug, func(t *testing.T) { | |
| 14 | + | d, _, link, transport := newRemoteMetadataLinkFixture(t) | |
| 15 | + | link.commands = NewCommandRequestTracker() | |
| 16 | + | ||
| 17 | + | done := make(chan error, 1) | |
| 18 | + | go func() { | |
| 19 | + | done <- d.sendRemoteViewCommand(context.Background(), link, link.generation, slug) | |
| 20 | + | }() | |
| 21 | + | ||
| 22 | + | frame := awaitFrame(t, transport.sent, ports.MsgCommand) | |
| 23 | + | request, err := ports.UnmarshalCommandRequest(frame.Payload) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | require.Equal(t, ports.ProtocolVersion, request.Version) | |
| 26 | + | require.NotZero(t, request.RequestID) | |
| 27 | + | require.True(t, request.Attached) | |
| 28 | + | require.False(t, request.Self) | |
| 29 | + | require.Equal(t, slug, request.Slug) | |
| 30 | + | require.Empty(t, request.Args) | |
| 31 | + | require.Empty(t, request.TargetSession) | |
| 32 | + | require.Empty(t, request.TargetTab) | |
| 33 | + | require.Empty(t, request.TargetPane) | |
| 34 | + | ||
| 35 | + | resultPayload := ports.MarshalCommandResult(ports.CommandResult{RequestID: request.RequestID, OK: true}) | |
| 36 | + | require.NoError(t, d.handleRemoteLinkFrame(link, ports.Frame{Type: ports.MsgCommandResult, Payload: resultPayload})) | |
| 37 | + | require.NoError(t, awaitTestValue(t, done, "relative remote tab command did not complete")) | |
| 38 | + | require.Zero(t, link.commands.PendingCount()) | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestSendRemoteViewCommandRejectsOperationsOutsideRelativeTabAllowlist(t *testing.T) { | |
| 44 | + | for _, slug := range []string{ | |
| 45 | + | "new-session", | |
| 46 | + | "focus-pane-left", | |
| 47 | + | "resize-pane", | |
| 48 | + | "move-tab", | |
| 49 | + | } { | |
| 50 | + | t.Run(slug, func(t *testing.T) { | |
| 51 | + | d, _, link, transport := newRemoteMetadataLinkFixture(t) | |
| 52 | + | link.commands = NewCommandRequestTracker() | |
| 53 | + | ||
| 54 | + | err := d.sendRemoteViewCommand(context.Background(), link, link.generation, slug) | |
| 55 | + | require.ErrorContains(t, err, "is not allowed") | |
| 56 | + | require.Zero(t, link.commands.PendingCount()) | |
| 57 | + | select { | |
| 58 | + | case frame := <-transport.sent: | |
| 59 | + | t.Fatalf("unallowlisted remote operation sent frame type %d", frame.Type) | |
| 60 | + | default: | |
| 61 | + | } | |
| 62 | + | }) | |
| 63 | + | } | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestSendRemoteViewCommandAllowsOnlyRelativeTabCommands(t *testing.T) { | |||
| 12 | + | for _, slug := range []string{"next-tab", "previous-tab"} { | |||
| 13 | + | t.Run(slug, func(t *testing.T) { | |||
| 14 | + | d, _, link, transport := newRemoteMetadataLinkFixture(t) | |||
| 15 | + | link.commands = NewCommandRequestTracker() | |||
| 16 | + | ||||
| 17 | + | done := make(chan error, 1) | |||
| 18 | + | go func() { | |||
| 19 | + | done <- d.sendRemoteViewCommand(context.Background(), link, link.generation, slug) | |||
| 20 | + | }() | |||
| 21 | + | ||||
| 22 | + | frame := awaitFrame(t, transport.sent, ports.MsgCommand) | |||
| 23 | + | request, err := ports.UnmarshalCommandRequest(frame.Payload) | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | require.Equal(t, ports.ProtocolVersion, request.Version) | |||
| 26 | + | require.NotZero(t, request.RequestID) | |||
| 27 | + | require.True(t, request.Attached) | |||
| 28 | + | require.False(t, request.Self) | |||
| 29 | + | require.Equal(t, slug, request.Slug) | |||
| 30 | + | require.Empty(t, request.Args) | |||
| 31 | + | require.Empty(t, request.TargetSession) | |||
| 32 | + | require.Empty(t, request.TargetTab) | |||
| 33 | + | require.Empty(t, request.TargetPane) | |||
| 34 | + | ||||
| 35 | + | resultPayload := ports.MarshalCommandResult(ports.CommandResult{RequestID: request.RequestID, OK: true}) | |||
| 36 | + | require.NoError(t, d.handleRemoteLinkFrame(link, ports.Frame{Type: ports.MsgCommandResult, Payload: resultPayload})) | |||
| 37 | + | require.NoError(t, awaitTestValue(t, done, "relative remote tab command did not complete")) | |||
| 38 | + | require.Zero(t, link.commands.PendingCount()) | |||
| 39 | + | }) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestSendRemoteViewCommandRejectsOperationsOutsideRelativeTabAllowlist(t *testing.T) { | |||
| 44 | + | for _, slug := range []string{ | |||
| 45 | + | "new-session", | |||
| 46 | + | "focus-pane-left", | |||
| 47 | + | "resize-pane", | |||
| 48 | + | "move-tab", | |||
| 49 | + | } { | |||
| 50 | + | t.Run(slug, func(t *testing.T) { | |||
| 51 | + | d, _, link, transport := newRemoteMetadataLinkFixture(t) | |||
| 52 | + | link.commands = NewCommandRequestTracker() | |||
| 53 | + | ||||
| 54 | + | err := d.sendRemoteViewCommand(context.Background(), link, link.generation, slug) | |||
| 55 | + | require.ErrorContains(t, err, "is not allowed") | |||
| 56 | + | require.Zero(t, link.commands.PendingCount()) | |||
| 57 | + | select { | |||
| 58 | + | case frame := <-transport.sent: | |||
| 59 | + | t.Fatalf("unallowlisted remote operation sent frame type %d", frame.Type) | |||
| 60 | + | default: | |||
| 61 | + | } | |||
| 62 | + | }) | |||
| 63 | + | } | |||
| 64 | + | } | |||