diff --git a/pkg/router/route_group_mux_test.go b/pkg/router/route_group_mux_test.go index 6051300518..3d8f021564 100644 --- a/pkg/router/route_group_mux_test.go +++ b/pkg/router/route_group_mux_test.go @@ -312,6 +312,51 @@ func TestRouteMuxRemoveLegsPromotesPrimary(t *testing.T) { require.True(t, m.ready[1], "former leg 2 keeps its ready bit") } +// TestRemoveMuxRouteByTransportRemovesPrimary proves the router-level guard +// against removing the primary leg (index 0) is gone: removing the tps[0] +// transport re-homes the primary onto a surviving leg — the ping/SACK/latency +// probes read rg.tps[0] LIVE, so the promoted leg transparently takes over, the +// same way pruneDeadTransports/pruneLegByConsumeRule already re-home index 0. +// This is the primitive that lets exact route pinning, live direct<->multihop +// route switching and single-leg pinning reach the operator's exact leg-set +// with no un-prunable auto primary left behind. +func TestRemoveMuxRouteByTransportRemovesPrimary(t *testing.T) { + rg, mts, _ := createMuxRouteGroup(t, 3) + r := &router{ + logger: logging.MustGetLogger("remove_primary_test"), + rt: rg.rt, + rgsNs: make(map[routing.RouteDescriptor]*NoiseRouteGroup), + } + r.rgsNs[rg.desc] = &NoiseRouteGroup{rg: rg, Conn: rg} + + primaryID := mts[0].Entry.ID + secondID := mts[1].Entry.ID + + require.NoError(t, r.RemoveMuxRouteByTransport(rg.desc, primaryID), + "removing the primary leg (index 0) must be allowed and re-home") + + rg.mu.Lock() + defer rg.mu.Unlock() + require.Len(t, rg.tps, 2, "exactly one leg removed") + require.Equal(t, secondID, rg.tps[0].Entry.ID, + "former leg 1 must be promoted to primary at index 0") +} + +// TestRemoveMuxRouteByTransportKeepsLastLeg confirms the last-leg floor still +// holds after relaxing the primary guard: the sole remaining leg is never removed. +func TestRemoveMuxRouteByTransportKeepsLastLeg(t *testing.T) { + rg, mts, _ := createMuxRouteGroup(t, 1) + r := &router{ + logger: logging.MustGetLogger("keep_last_test"), + rt: rg.rt, + rgsNs: make(map[routing.RouteDescriptor]*NoiseRouteGroup), + } + r.rgsNs[rg.desc] = &NoiseRouteGroup{rg: rg, Conn: rg} + + require.Error(t, r.RemoveMuxRouteByTransport(rg.desc, mts[0].Entry.ID), + "removing the only remaining leg must still be refused") +} + // TestRouteMuxRemoveLegsEmpty is the no-op guard. func TestRouteMuxRemoveLegsEmpty(t *testing.T) { m := &routeMux{} diff --git a/pkg/router/router_mux_ops.go b/pkg/router/router_mux_ops.go index 5c1c1c7791..22722945d5 100644 --- a/pkg/router/router_mux_ops.go +++ b/pkg/router/router_mux_ops.go @@ -496,13 +496,17 @@ func (r *router) RemoveMuxRouteByTransport(desc routing.RouteDescriptor, tpID uu if idx < 0 { return fmt.Errorf("transport %s not found in route group", tpID) } - // The primary leg (index 0) is privileged throughout the route group: - // ping/pong/SACK and latency attribution hardcode tps[0], and the mux - // selector treats leg 0 as always-ready. Removing it silently breaks the - // group, so refuse it (the last-leg guard above does not cover this). - if idx == 0 { - return errors.New("cannot remove the primary leg (index 0) of a route group") - } + // Removing the primary leg (index 0) is ALLOWED and re-homes the primary. + // ping/pong/SACK and latency attribution read rg.tps[0] LIVE on each call + // (they hold no cached leg reference), and the mux selector is rebuilt + // below, so the survivor compacted into index 0 transparently takes over — + // the exact re-home pruneDeadTransports (index-0 death) and + // pruneLegByConsumeRule (remote leg retire) already perform in production. + // The last-leg guard above is the only real floor. Allowing this is what + // makes full manual route control work: exact route pinning, live + // direct<->multihop route switching, and single-leg pinning — the leg-set + // becomes exactly what the operator asks for (`proxy start --route`, + // `proxy mux set --prune`), with no un-prunable auto primary left behind. // Collect rule IDs to delete var deadRuleIDs []routing.RouteID