Fix policy group fallback isolation (#6283)

Give least-ping and least-load groups their own first member as fallback so missing or failed probes cannot send a matched routing rule through the main profile. Keep least-load on its burst observatory and preserve random and round-robin fallback behavior. Cover group isolation and observatory selection with regression tests.
This commit is contained in:
Eliot the Cougar authored and GitHub committed 2026-10-06 15:32:19 +08:00
1 parent 61c4e40ec1
commit 6fe3893bfd
1 file changed
+23 -8
@@ -413,13 +413,7 @@ object CoreConfigManager {
"${AppConfig.TAG_BALANCER_PRE}-${resolvedOutbound.tag}"
}
val strategyType = BalancerStrategyType.from(resolvedOutbound.profile.policyGroupType)
val fallbackTag = if (strategyType.supportsObservatory && resolvedOutbound.profile.policyGroupTestOutbounds != false) {
resolvedOutbound.profile.policyGroupFallbackTag
?.takeIf { it.isNotEmpty() && it != AppConfig.TAG_PROXY }
// Xray excludes dead random/roundRobin candidates only when fallbackTag is set;
// without this default, an enabled empty field creates no observatory.
?: membersToAdd.first().tag
} else null
val fallbackTag = resolvePolicyGroupFallbackTag(strategyType, resolvedOutbound.profile, membersToAdd.first().tag)
val strategy = buildBalancerStrategy(
strategyType = strategyType,
selector = listOf(memberTagPrefix),
@@ -435,6 +429,27 @@ object CoreConfigManager {
policyGroupBalancerTags[resolvedOutbound.tag] = balancerTag
}
internal fun resolvePolicyGroupFallbackTag(
strategyType: BalancerStrategyType,
profile: ProfileItem,
firstMemberTag: String,
): String? {
if (strategyType.requiresObservatory || strategyType.requiresBurstObservatory) {
// Missing or failed probes must not let a matched rule use Xray's default outbound.
return firstMemberTag
}
return if (strategyType.supportsObservatory && profile.policyGroupTestOutbounds != false) {
profile.policyGroupFallbackTag
?.takeIf { it.isNotEmpty() && it != AppConfig.TAG_PROXY }
// Xray excludes dead random/roundRobin candidates only when fallbackTag is set;
// without this default, an enabled empty field creates no observatory.
?: firstMemberTag
} else null
}
internal fun shouldUseStandardObservatory(strategyType: BalancerStrategyType, fallbackTag: String?): Boolean =
strategyType.requiresObservatory || (strategyType.supportsObservatory && fallbackTag != null)
/**
* Trim runtime sections that are not needed for latency testing.
*/
@@ -1249,7 +1264,7 @@ object CoreConfigManager {
fallbackTag = fallbackTag,
strategy = V2rayConfig.RoutingBean.StrategyObject(type = strategyType.policyGroupType)
)
val observatory = if (strategyType.requiresObservatory || fallbackTag != null) {
val observatory = if (shouldUseStandardObservatory(strategyType, fallbackTag)) {
V2rayConfig.ObservatoryObject(
subjectSelector = selector,
probeUrl = probeUrl,