detached 5ee2a06

fix(protocol): bound attach handoffs

commit
5ee2a0609100007c6e5eb5eeaa4425d97491165c
author
brice · · 2026-08-10 05:06 UTC
parent
5d58f8a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +37 -3
M internal/app/run.go +7 -0
@@ -835,6 +835,8 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti
835835 }, nil
836836 }
837837
838+const maxAttachTargetHandoffs = 32
839+
838840 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
839841 if activeSession != "" {
840842 if remoteTarget == "" && intent == ports.IntentNew {
@@ -911,6 +913,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
911913 }, nil
912914 }
913915
916+ handoffAttempts := 0
914917 for {
915918 var err error
916919 if remoteTarget != "" {
@@ -970,6 +973,10 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
970973 if err := validateRemoteAttachHandoff(handoffErr.Target); err != nil {
971974 return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
972975 }
976+ if handoffAttempts >= maxAttachTargetHandoffs {
977+ return fmt.Errorf("vev: attach handoff exceeded maximum of %d attempts", maxAttachTargetHandoffs)
978+ }
979+ handoffAttempts++
973980 remoteTarget = handoffErr.Target.Endpoint
974981 remoteDisplayOrigin = remoteTarget
975982 routeOrigin = ports.RouteOriginDiscovery
@@ -835,6 +835,8 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti
835 }, nil835 }, nil
836 }836 }
837 837
838+const maxAttachTargetHandoffs = 32
839+
838 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {840 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
839 if activeSession != "" {841 if activeSession != "" {
840 if remoteTarget == "" && intent == ports.IntentNew {842 if remoteTarget == "" && intent == ports.IntentNew {
@@ -911,6 +913,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
911 }, nil913 }, nil
912 }914 }
913 915
916+ handoffAttempts := 0
914 for {917 for {
915 var err error918 var err error
916 if remoteTarget != "" {919 if remoteTarget != "" {
@@ -970,6 +973,10 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
970 if err := validateRemoteAttachHandoff(handoffErr.Target); err != nil {973 if err := validateRemoteAttachHandoff(handoffErr.Target); err != nil {
971 return fmt.Errorf("vev: invalid remote attach handoff: %w", err)974 return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
972 }975 }
976+ if handoffAttempts >= maxAttachTargetHandoffs {
977+ return fmt.Errorf("vev: attach handoff exceeded maximum of %d attempts", maxAttachTargetHandoffs)
978+ }
979+ handoffAttempts++
973 remoteTarget = handoffErr.Target.Endpoint980 remoteTarget = handoffErr.Target.Endpoint
974 remoteDisplayOrigin = remoteTarget981 remoteDisplayOrigin = remoteTarget
975 routeOrigin = ports.RouteOriginDiscovery982 routeOrigin = ports.RouteOriginDiscovery
M internal/app/run_test.go +16 -0
@@ -935,6 +935,22 @@ func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) {
935935 require.NoError(t, err)
936936 }
937937
938+func TestRunAttachWithDepsBoundsRepeatedAttachTargetHandoffs(t *testing.T) {
939+ factory := portsmocks.NewMockRemoteDialerFactory(t)
940+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachTargetHandoffs + 1)
941+
942+ calls := 0
943+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
944+ remoteDialerFactory: factory,
945+ runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
946+ calls++
947+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}}
948+ },
949+ })
950+ require.ErrorContains(t, err, "attach handoff exceeded maximum")
951+ require.Equal(t, maxAttachTargetHandoffs+1, calls)
952+}
953+
938954 func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {
939955 factory := portsmocks.NewMockRemoteDialerFactory(t)
940956 factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
@@ -935,6 +935,22 @@ func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) {
935 require.NoError(t, err)935 require.NoError(t, err)
936 }936 }
937 937
938+func TestRunAttachWithDepsBoundsRepeatedAttachTargetHandoffs(t *testing.T) {
939+ factory := portsmocks.NewMockRemoteDialerFactory(t)
940+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachTargetHandoffs + 1)
941+
942+ calls := 0
943+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
944+ remoteDialerFactory: factory,
945+ runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
946+ calls++
947+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}}
948+ },
949+ })
950+ require.ErrorContains(t, err, "attach handoff exceeded maximum")
951+ require.Equal(t, maxAttachTargetHandoffs+1, calls)
952+}
953+
938 func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {954 func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {
939 factory := portsmocks.NewMockRemoteDialerFactory(t)955 factory := portsmocks.NewMockRemoteDialerFactory(t)
940 factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()956 factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
M internal/ports/wire_test.go +14 -3
@@ -173,16 +173,24 @@ func TestHelloEnvironmentCodec(t *testing.T) {
173173 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
174174 withCount := func(count byte) []byte {
175175 payload := append([]byte(nil), base...)
176- payload[len(payload)-6] = count
176+ // The uint32 environment count occupies bytes len-13 through len-10.
177+ payload[len(payload)-10] = count
177178 return payload
178179 }
180+ withEntries := func(count byte, entries ...byte) []byte {
181+ payload := withCount(count)
182+ // The nine-byte tail follows the environment-count field.
183+ tail := append([]byte(nil), payload[len(payload)-9:]...)
184+ payload = append(payload[:len(payload)-9], entries...)
185+ return append(payload, tail...)
186+ }
179187 tests := []struct {
180188 name string
181189 payload []byte
182190 }{
183191 {name: "truncated count", payload: base[:len(base)-1]},
184- {name: "truncated entry length", payload: append(withCount(1), 0, 0)},
185- {name: "truncated entry body", payload: append(withCount(1), 0, 0, 0, 3, 'x')},
192+ {name: "truncated entry length", payload: withEntries(1, 0, 0)},
193+ {name: "truncated entry body", payload: withEntries(1, 0, 0, 0, 3, 'x')},
186194 {name: "impossible count", payload: withCount(2)},
187195 {name: "trailing garbage", payload: append(append([]byte(nil), base...), 0xff)},
188196 }
@@ -190,6 +198,9 @@ func TestHelloEnvironmentCodec(t *testing.T) {
190198 t.Run(tt.name, func(t *testing.T) {
191199 _, err := UnmarshalHello(tt.payload)
192200 require.Error(t, err)
201+ if tt.name != "trailing garbage" {
202+ require.ErrorIs(t, err, errShortPayload)
203+ }
193204 })
194205 }
195206 }
@@ -173,16 +173,24 @@ func TestHelloEnvironmentCodec(t *testing.T) {
173 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})173 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
174 withCount := func(count byte) []byte {174 withCount := func(count byte) []byte {
175 payload := append([]byte(nil), base...)175 payload := append([]byte(nil), base...)
176- payload[len(payload)-6] = count176+ // The uint32 environment count occupies bytes len-13 through len-10.
177+ payload[len(payload)-10] = count
177 return payload178 return payload
178 }179 }
180+ withEntries := func(count byte, entries ...byte) []byte {
181+ payload := withCount(count)
182+ // The nine-byte tail follows the environment-count field.
183+ tail := append([]byte(nil), payload[len(payload)-9:]...)
184+ payload = append(payload[:len(payload)-9], entries...)
185+ return append(payload, tail...)
186+ }
179 tests := []struct {187 tests := []struct {
180 name string188 name string
181 payload []byte189 payload []byte
182 }{190 }{
183 {name: "truncated count", payload: base[:len(base)-1]},191 {name: "truncated count", payload: base[:len(base)-1]},
184- {name: "truncated entry length", payload: append(withCount(1), 0, 0)},192+ {name: "truncated entry length", payload: withEntries(1, 0, 0)},
185- {name: "truncated entry body", payload: append(withCount(1), 0, 0, 0, 3, 'x')},193+ {name: "truncated entry body", payload: withEntries(1, 0, 0, 0, 3, 'x')},
186 {name: "impossible count", payload: withCount(2)},194 {name: "impossible count", payload: withCount(2)},
187 {name: "trailing garbage", payload: append(append([]byte(nil), base...), 0xff)},195 {name: "trailing garbage", payload: append(append([]byte(nil), base...), 0xff)},
188 }196 }
@@ -190,6 +198,9 @@ func TestHelloEnvironmentCodec(t *testing.T) {
190 t.Run(tt.name, func(t *testing.T) {198 t.Run(tt.name, func(t *testing.T) {
191 _, err := UnmarshalHello(tt.payload)199 _, err := UnmarshalHello(tt.payload)
192 require.Error(t, err)200 require.Error(t, err)
201+ if tt.name != "trailing garbage" {
202+ require.ErrorIs(t, err, errShortPayload)
203+ }
193 })204 })
194 }205 }
195 }206 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close