1
   2
   3
   4
   5
   6
   7
   8
   9
  10
  11
  12
  13
  14
  15
  16
  17
  18
  19
  20
  21
  22
  23
  24
  25
  26
  27
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
#!/bin/bash
# SPDX-License-Identifier: GPL-2.0
#
# Test local ECMP path re-selection on TCP retransmission timeout and PLB.
#
# Two namespaces connected by two parallel veth pairs with a 2-way ECMP
# route.  When a TCP path is blocked (via tc drop) or congested (via
# netem ECN marking), the kernel rehashes the connection via
# sk_rethink_txhash() + __sk_dst_reset(), causing the next route lookup
# to select the other ECMP path.
#
# Expected runtime: ~60 seconds.  Most time is spent waiting for TCP
# retransmission timeouts (1-7s per test) and running multi-round
# consistency checks (10 rounds each).  The large slowwait/connect-timeout
# values (30-120s) are worst-case bounds for CI; a correctly functioning
# kernel reaches each check well before the timeout expires.

source lib.sh

SUBNETS=(a b)
PORT=9900
: "${ECMP_REBUILD_ROUNDS:=10}"

# alloc_ports NAME [COUNT]: set NAME to the next free port and reserve
# COUNT ports (default 1) from a shared counter.  Each test allocates its
# own port(s) where it runs, so a retry or a newly added test never
# collides; the per-round tests reserve ECMP_REBUILD_ROUNDS each.
NEXT_PORT=$PORT
alloc_ports()
{
	printf -v "$1" '%d' "$NEXT_PORT"
	NEXT_PORT=$((NEXT_PORT + ${2:-1}))
}

ALL_TESTS="
	test_ecmp_syn_rehash
	test_ecmp_synack_rehash
	test_ecmp_midstream_rehash
	test_ecmp_midstream_ack_rehash
	test_ecmp_plb_rehash
	test_ecmp_hash_policy1_no_rehash
	test_ecmp_no_flowlabel_leak
	test_ecmp_dst_rebuild_consistency
	test_ecmp_syncookie_path_consistency
"

link_tx_packets_get()
{
	local ns=$1; shift
	local dev=$1; shift

	ip netns exec "$ns" cat "/sys/class/net/$dev/statistics/tx_packets"
}

# Return the number of packets matched by the tc filter action on a device.
# When tc drops packets via "action drop", the device's tx_packets is not
# incremented (packet never reaches veth_xmit), but the tc action maintains
# its own counter.
tc_filter_pkt_count()
{
	local ns=$1; shift
	local dev=$1; shift

	ip netns exec "$ns" tc -s filter show dev "$dev" parent 1: 2>/dev/null |
		awk '/Sent .* pkt/ {
			for (i=1; i<=NF; i++)
				if ($i == "pkt") { print $(i-1); exit }
		}'
}

# Read a TcpExt counter from /proc/net/netstat in a namespace.
# Returns 0 if the counter is not found.
get_netstat_counter()
{
	local ns=$1; shift
	local field=$1; shift
	local val

	# shellcheck disable=SC2016
	val=$(ip netns exec "$ns" awk -v key="$field" '
		/^TcpExt:/ {
			if (!h) { split($0, n); h=1 }
			else {
				split($0, v)
				for (i in n)
					if (n[i] == key) print v[i]
			}
		}
	' /proc/net/netstat)
	echo "${val:-0}"
}

# Apply netem ECN marking: CE-mark all ECT packets instead of dropping them.
mark_ecn()
{
	local ns=$1; shift
	local dev=$1; shift

	ip netns exec "$ns" tc qdisc add dev "$dev" root netem loss 100% ecn
}

# Block TCP (IPv6 next-header = 6) egress, allowing ICMPv6 through.
block_tcp()
{
	local ns=$1; shift
	local dev=$1; shift

	ip netns exec "$ns" tc qdisc add dev "$dev" root handle 1: prio
	ip netns exec "$ns" tc filter add dev "$dev" parent 1: \
		protocol ipv6 prio 1 u32 match u8 0x06 0xff at 6 action drop
}

unblock_tcp()
{
	local ns=$1; shift
	local dev=$1; shift

	ip netns exec "$ns" tc qdisc del dev "$dev" root 2>/dev/null
}

# Return success when a device's TX counter exceeds a baseline value.
dev_tx_packets_above()
{
	local ns=$1; shift
	local dev=$1; shift
	local baseline=$1; shift

	local cur
	cur=$(link_tx_packets_get "$ns" "$dev")
	[ "$cur" -gt "$baseline" ]
}

# Return success when both devices have dropped at least one TCP packet.
both_devs_attempted()
{
	local ns=$1; shift
	local dev0=$1; shift
	local dev1=$1; shift

	local c0 c1
	c0=$(tc_filter_pkt_count "$ns" "$dev0")
	c1=$(tc_filter_pkt_count "$ns" "$dev1")
	[ "${c0:-0}" -ge 1 ] && [ "${c1:-0}" -ge 1 ]
}

link_tx_packets_total()
{
	local ns=$1; shift
	local dev0=${1:-veth0a}; shift 2>/dev/null
	local dev1=${1:-veth1a}

	echo $(( $(link_tx_packets_get "$ns" "$dev0") +
		 $(link_tx_packets_get "$ns" "$dev1") ))
}

# (Re)install the ECMP multipath routes between NS1 and NS2.  $1 is the
# ip route operation ("add" to create, "change" to replace).  If $2 is
# given it names a congestion control to pin on both routes via "congctl";
# because dctcp carries TCP_CONG_NEEDS_ECN, this also tags the route with
# DST_FEATURE_ECN_CA, which makes the server negotiate ECN without the
# listener itself having to run dctcp.  The nexthop topology lives here
# only, so a test can re-pin the routes and restore them with one call.
install_ecmp_routes()
{
	local op=$1 cc=$2
	local -a cc_attr=()

	[ -n "$cc" ] && cc_attr=(congctl "$cc")

	ip -n "$NS1" -6 route "$op" fd00:ff::2/128 "${cc_attr[@]}" \
		nexthop via fd00:a::2 dev veth0a \
		nexthop via fd00:b::2 dev veth1a

	ip -n "$NS2" -6 route "$op" fd00:ff::1/128 "${cc_attr[@]}" \
		nexthop via fd00:a::1 dev veth0b \
		nexthop via fd00:b::1 dev veth1b
}

setup()
{
	setup_ns NS1 NS2

	local ns
	for ns in "$NS1" "$NS2"; do
		ip netns exec "$ns" sysctl -qw net.ipv6.conf.all.accept_dad=0
		ip netns exec "$ns" sysctl -qw net.ipv6.conf.default.accept_dad=0
		ip netns exec "$ns" sysctl -qw net.ipv6.conf.all.forwarding=1
		ip netns exec "$ns" sysctl -qw net.core.txrehash=1
	done

	local i sub
	for i in 0 1; do
		sub=${SUBNETS[$i]}
		ip link add "veth${i}a" type veth peer name "veth${i}b"
		ip link set "veth${i}a" netns "$NS1"
		ip link set "veth${i}b" netns "$NS2"
		ip -n "$NS1" addr add "fd00:${sub}::1/64" dev "veth${i}a"
		ip -n "$NS2" addr add "fd00:${sub}::2/64" dev "veth${i}b"
		ip -n "$NS1" link set "veth${i}a" up
		ip -n "$NS2" link set "veth${i}b" up
	done

	ip -n "$NS1" addr add fd00:ff::1/128 dev lo
	ip -n "$NS2" addr add fd00:ff::2/128 dev lo

	# Allow many SYN retries at 1-second intervals (linear, no
	# exponential backoff) so the rehash test has enough attempts
	# to exercise both ECMP paths.
	if ! ip netns exec "$NS1" sysctl -qw \
	     net.ipv4.tcp_syn_linear_timeouts=25; then
		echo "SKIP: tcp_syn_linear_timeouts not supported"
		return "$ksft_skip"
	fi
	ip netns exec "$NS1" sysctl -qw net.ipv4.tcp_syn_retries=25

	# Keep the server's request socket alive during the blocking
	# period so SYN/ACK retransmits continue.
	ip netns exec "$NS2" sysctl -qw net.ipv4.tcp_synack_retries=25

	install_ecmp_routes add

	for i in 0 1; do
		sub=${SUBNETS[$i]}
		ip netns exec "$NS1" \
			ping -6 -c1 -W5 "fd00:${sub}::2" &>/dev/null
		ip netns exec "$NS2" \
			ping -6 -c1 -W5 "fd00:${sub}::1" &>/dev/null
	done

	if ! ip netns exec "$NS1" ping -6 -c1 -W5 fd00:ff::2 &>/dev/null; then
		echo "Basic connectivity check failed"
		return "$ksft_skip"
	fi
}

# Block ALL paths, start a connection, wait until SYNs have been dropped
# on both interfaces (proving rehash steered the SYN to a new path), then
# unblock so the connection completes.
test_ecmp_syn_rehash()
{
	RET=0
	local port
	alloc_ports port

	block_tcp "$NS1" veth0a
	defer unblock_tcp "$NS1" veth0a
	block_tcp "$NS1" veth1a
	defer unblock_tcp "$NS1" veth1a

	ip netns exec "$NS2" socat \
		"TCP6-LISTEN:$port,bind=[fd00:ff::2],reuseaddr,fork" \
		EXEC:"echo ESTABLISH_OK" &
	defer kill_process $!

	wait_local_port_listen "$NS2" "$port" tcp

	local rehash_before
	rehash_before=$(get_netstat_counter "$NS1" TcpTimeoutRehash)

	# Start the connection in the background; it will retry SYNs at
	# 1-second intervals until an unblocked path is found.
	# Use -u (unidirectional) to only receive from the server;
	# sending data back would risk SIGPIPE if the server's EXEC
	# child has already exited.
	local tmpfile
	tmpfile=$(mktemp)
	defer rm -f "$tmpfile"

	ip netns exec "$NS1" socat -u \
		"TCP6:[fd00:ff::2]:$port,bind=[fd00:ff::1],connect-timeout=60" \
		STDOUT >"$tmpfile" 2>&1 &
	local client_pid=$!
	defer kill_process "$client_pid"

	# Wait until both paths have seen at least one dropped SYN.
	# This proves sk_rethink_txhash() rehashed the connection from
	# one ECMP path to the other.
	slowwait 30 both_devs_attempted "$NS1" veth0a veth1a > /dev/null
	check_err $? "SYNs did not appear on both paths (rehash not working)"
	if [ "$RET" -ne 0 ]; then
		log_test "Local ECMP SYN rehash: establish with blocked paths"
		return
	fi

	# Unblock both paths and let the next SYN retransmit succeed.
	unblock_tcp "$NS1" veth0a
	unblock_tcp "$NS1" veth1a

	local rc=0
	wait "$client_pid" || rc=$?

	local result
	result=$(cat "$tmpfile" 2>/dev/null)

	if [[ "$result" != *"ESTABLISH_OK"* ]]; then
		check_err 1 "connection failed after unblocking (rc=$rc): $result"
	fi

	local rehash_after
	rehash_after=$(get_netstat_counter "$NS1" TcpTimeoutRehash)
	if [ "$rehash_after" -le "$rehash_before" ]; then
		check_err 1 "TcpTimeoutRehash counter did not increment"
	fi

	log_test "Local ECMP SYN rehash: establish with blocked paths"
}

# Block the server's return paths so SYN/ACKs are dropped.  The client
# retransmits SYNs at 1-second intervals; each duplicate SYN arriving at
# the server triggers tcp_rtx_synack() which re-rolls txhash, so the
# retransmitted SYN/ACK selects a different ECMP return path.
test_ecmp_synack_rehash()
{
	RET=0
	local port
	alloc_ports port

	block_tcp "$NS2" veth0b
	defer unblock_tcp "$NS2" veth0b
	block_tcp "$NS2" veth1b
	defer unblock_tcp "$NS2" veth1b

	ip netns exec "$NS2" socat \
		"TCP6-LISTEN:$port,bind=[fd00:ff::2],reuseaddr,fork" \
		EXEC:"echo SYNACK_OK" &
	defer kill_process $!

	wait_local_port_listen "$NS2" "$port" tcp

	# Start the connection; SYNs reach the server (client egress is
	# open) but SYN/ACKs are dropped on the server's return path.
	local tmpfile
	tmpfile=$(mktemp)
	defer rm -f "$tmpfile"

	ip netns exec "$NS1" socat -u \
		"TCP6:[fd00:ff::2]:$port,bind=[fd00:ff::1],connect-timeout=60" \
		STDOUT >"$tmpfile" 2>&1 &
	local client_pid=$!
	defer kill_process "$client_pid"

	# Wait until both server-side interfaces have dropped at least
	# one SYN/ACK, proving the server rehashed its return path.
	slowwait 30 both_devs_attempted "$NS2" veth0b veth1b > /dev/null
	check_err $? "SYN/ACKs did not appear on both return paths"
	if [ "$RET" -ne 0 ]; then
		log_test "Local ECMP SYN/ACK rehash: blocked return path"
		return
	fi

	# Unblock and let the connection complete.
	unblock_tcp "$NS2" veth0b
	unblock_tcp "$NS2" veth1b

	local rc=0
	wait "$client_pid" || rc=$?

	local result
	result=$(cat "$tmpfile" 2>/dev/null)

	if [[ "$result" != *"SYNACK_OK"* ]]; then
		check_err 1 "connection failed after unblocking (rc=$rc): $result"
	fi

	log_test "Local ECMP SYN/ACK rehash: blocked return path"
}

# Establish a data transfer with both paths open, then block the
# active path.  Verify that data appears on the previously inactive
# path (proving RTO triggered a rehash) and that TcpTimeoutRehash
# incremented.
#
# With 2-way ECMP each rehash may pick the same path, so a single
# attempt can occasionally fail.  Retry once for robustness.

# Single attempt at the midstream rehash check.  Returns 0 on success.
ecmp_midstream_rehash_attempt()
{
	local port=$1; shift
	local reason=""

	ip netns exec "$NS2" socat -u \
		"TCP6-LISTEN:$port,bind=[fd00:ff::2],reuseaddr" - >/dev/null &
	local server_pid=$!

	wait_local_port_listen "$NS2" "$port" tcp

	local base_tx0 base_tx1
	base_tx0=$(link_tx_packets_get "$NS1" veth0a)
	base_tx1=$(link_tx_packets_get "$NS1" veth1a)

	# Continuous data source; timeout caps overall test duration and
	# must exceed the slowwait below so data keeps flowing.
	ip netns exec "$NS1" timeout 90 socat -u \
		OPEN:/dev/zero \
		"TCP6:[fd00:ff::2]:$port,bind=[fd00:ff::1]" &>/dev/null &
	local client_pid=$!

	# Wait for enough packets to identify the active path.
	if ! busywait "$BUSYWAIT_TIMEOUT" until_counter_is \
			">= $((base_tx0 + base_tx1 + 10))" \
		link_tx_packets_total "$NS1" > /dev/null; then
		kill "$client_pid" "$server_pid" 2>/dev/null
		wait "$client_pid" "$server_pid" 2>/dev/null
		echo "no TX activity"
		return 1
	fi

	# Find the active path and block it.
	local current_tx0 current_tx1 active_idx inactive_idx
	current_tx0=$(link_tx_packets_get "$NS1" veth0a)
	current_tx1=$(link_tx_packets_get "$NS1" veth1a)
	if [ $((current_tx0 - base_tx0)) -ge $((current_tx1 - base_tx1)) ]; then
		active_idx=0; inactive_idx=1
	else
		active_idx=1; inactive_idx=0
	fi

	local rehash_before
	rehash_before=$(get_netstat_counter "$NS1" TcpTimeoutRehash)
	# Suppress __dst_negative_advice() in tcp_write_timeout() so
	# that __sk_dst_reset() is the only dst-invalidation mechanism
	# on the RTO path.
	local saved_retries1
	saved_retries1=$(ip netns exec "$NS1" sysctl -n net.ipv4.tcp_retries1)
	ip netns exec "$NS1" sysctl -qw net.ipv4.tcp_retries1=255

	block_tcp "$NS1" "veth${active_idx}a"

	# Capture baseline after block_tcp returns.  block_tcp adds a
	# prio qdisc then a tc filter; between those two steps the
	# qdisc's CAN_BYPASS fast-path lets packets through unfiltered.
	local inactive_before
	inactive_before=$(link_tx_packets_get "$NS1" "veth${inactive_idx}a")

	# Wait for meaningful data on the previously inactive path,
	# proving RTO triggered a rehash and data actually moved.
	if ! slowwait 60 dev_tx_packets_above \
		"$NS1" "veth${inactive_idx}a" "$((inactive_before + 100))" \
		> /dev/null; then
		reason="no data on alternate path"
	fi

	local rehash_after
	rehash_after=$(get_netstat_counter "$NS1" TcpTimeoutRehash)
	if [ "$rehash_after" -le "$rehash_before" ]; then
		reason="${reason:+$reason; }TcpTimeoutRehash did not increment"
	fi

	unblock_tcp "$NS1" "veth${active_idx}a"
	ip netns exec "$NS1" sysctl -qw \
		net.ipv4.tcp_retries1="$saved_retries1"
	kill "$client_pid" "$server_pid" 2>/dev/null
	wait "$client_pid" "$server_pid" 2>/dev/null
	if [ -n "$reason" ]; then
		echo "$reason"
		return 1
	fi
	return 0
}

test_ecmp_midstream_rehash()
{
	RET=0
	local port retry_port
	alloc_ports port
	alloc_ports retry_port

	local fail_reason
	if ! ecmp_midstream_rehash_attempt "$port" >/dev/null; then
		fail_reason=$(ecmp_midstream_rehash_attempt "$retry_port")
		check_err $? "$fail_reason"
	fi

	log_test "Local ECMP midstream rehash: block active path"
}

# Single attempt at the ACK rehash check.  Returns 0 on success.
ecmp_ack_rehash_attempt()
{
	local port=$1; shift
	local reason=""

	ip netns exec "$NS2" socat -u \
		"TCP6-LISTEN:$port,bind=[fd00:ff::2],reuseaddr" - >/dev/null &
	local server_pid=$!

	wait_local_port_listen "$NS2" "$port" tcp

	local base_tx0 base_tx1
	base_tx0=$(link_tx_packets_get "$NS2" veth0b)
	base_tx1=$(link_tx_packets_get "$NS2" veth1b)

	# Continuous data source from NS1 to NS2.  Cap the send buffer
	# so in-flight data stays below the receiver's advertised window.
	# Without this, the sender can exhaust the receiver's window and
	# enter persist mode (zero-window probing) instead of RTO when
	# ACKs are blocked, and persist probes do not trigger flowlabel
	# rehash.
	ip netns exec "$NS1" timeout 120 socat -u \
		OPEN:/dev/zero \
		"TCP6:[fd00:ff::2]:$port,bind=[fd00:ff::1],sndbuf=16384" \
		&>/dev/null &
	local client_pid=$!

	# Wait for enough server TX (ACKs) to identify the active return path.
	if ! busywait "$BUSYWAIT_TIMEOUT" until_counter_is \
			">= $((base_tx0 + base_tx1 + 10))" \
		link_tx_packets_total "$NS2" veth0b veth1b > /dev/null; then
		kill "$client_pid" "$server_pid" 2>/dev/null
		wait "$client_pid" "$server_pid" 2>/dev/null
		echo "no server TX activity"
		return 1
	fi

	local cur_tx0 cur_tx1 active_dev inactive_dev
	cur_tx0=$(link_tx_packets_get "$NS2" veth0b)
	cur_tx1=$(link_tx_packets_get "$NS2" veth1b)
	if [ $((cur_tx0 - base_tx0)) -ge $((cur_tx1 - base_tx1)) ]; then
		active_dev=veth0b; inactive_dev=veth1b
	else
		active_dev=veth1b; inactive_dev=veth0b
	fi

	local rehash_before
	rehash_before=$(get_netstat_counter "$NS2" TcpDuplicateDataRehash)

	# Block the inactive return path first (no effect on current
	# ACK flow), then block the active path.  This avoids counting
	# normal ACK drops as rehash evidence.
	block_tcp "$NS2" "$inactive_dev"
	local inactive_before
	inactive_before=$(tc_filter_pkt_count "$NS2" "$inactive_dev")
	block_tcp "$NS2" "$active_dev"

	# NS1 will RTO (no ACKs), retransmit with new flowlabel.
	# NS2 detects the flowlabel change via tcp_rcv_spurious_retrans(),
	# rehashes, and NS2's ACKs try the previously inactive return
	# path.  One successful rehash is sufficient.
	if ! slowwait 60 until_counter_is \
			">= $((${inactive_before:-0} + 1))" \
		tc_filter_pkt_count "$NS2" "$inactive_dev" > /dev/null; then
		reason="no ACKs on alternate return path after blocking"
	fi

	local rehash_after
	rehash_after=$(get_netstat_counter "$NS2" TcpDuplicateDataRehash)
	if [ "$rehash_after" -le "$rehash_before" ]; then
		reason="${reason:+$reason; }TcpDuplicateDataRehash did not increment"
	fi

	unblock_tcp "$NS2" "$active_dev"
	unblock_tcp "$NS2" "$inactive_dev"
	kill "$client_pid" "$server_pid" 2>/dev/null
	wait "$client_pid" "$server_pid" 2>/dev/null
	if [ -n "$reason" ]; then
		echo "$reason"
		return 1
	fi
	return 0
}

# Block the receiver's (NS2) ACK return paths while data flows from
# NS1 to NS2.  The sender (NS1) times out and retransmits with a new
# flowlabel; the receiver detects the changed flowlabel via
# tcp_rcv_spurious_retrans() and rehashes its own txhash so that its
# ACKs try a different ECMP return path.
#
# With 2-way ECMP each rehash may pick the same path, so a single
# attempt can occasionally fail.  Retry once for robustness.
test_ecmp_midstream_ack_rehash()
{
	RET=0
	local port retry_port
	alloc_ports port
	alloc_ports retry_port

	local fail_reason
	if ! ecmp_ack_rehash_attempt "$port" >/dev/null; then
		fail_reason=$(ecmp_ack_rehash_attempt "$retry_port")
		check_err $? "$fail_reason"
	fi

	log_test "Local ECMP midstream ACK rehash: blocked return path"
}

# Establish a DCTCP data transfer with PLB enabled, then ECN-mark both
# paths.  Sustained CE marking triggers PLB to call sk_rethink_txhash()
# + __sk_dst_reset(), bouncing the connection between ECMP paths.
# Verify data appears on both paths and that TCPPLBRehash incremented.
test_ecmp_plb_rehash()
{
	RET=0
	local port
	alloc_ports port

	# PLB needs DCTCP, a restricted congestion control.  Adding it to
	# the host-global tcp_allowed_congestion_control would relax the
	# restricted-CC policy for the whole host (there is no per-netns
	# allowed set).  Instead pin dctcp on the test routes with
	# "congctl": the route's RTAX_CC_ALGO is honoured on both the
	# connect and accept paths without the restricted-CC check, and a
	# dctcp route also carries DST_FEATURE_ECN_CA so the server
	# negotiates ECN -- all confined to the test namespaces.
	local available
	available=$(ip netns exec "$NS1" sysctl -n \
		net.ipv4.tcp_available_congestion_control)
	if ! echo "$available" | grep -qw dctcp; then
		log_test_skip "Local ECMP PLB rehash: DCTCP not available"
		return "$ksft_skip"
	fi
	install_ecmp_routes change dctcp
	defer install_ecmp_routes change

	# Save NS1 sysctls before modifying them.
	local saved_ecn1 saved_plb_enabled saved_plb_rounds
	local saved_plb_thresh saved_plb_suspend
	saved_ecn1=$(ip netns exec "$NS1" sysctl -n net.ipv4.tcp_ecn)
	saved_plb_enabled=$(ip netns exec "$NS1" sysctl -n net.ipv4.tcp_plb_enabled)
	saved_plb_rounds=$(ip netns exec "$NS1" sysctl -n net.ipv4.tcp_plb_rehash_rounds)
	saved_plb_thresh=$(ip netns exec "$NS1" sysctl -n net.ipv4.tcp_plb_cong_thresh)
	saved_plb_suspend=$(ip netns exec "$NS1" sysctl -n net.ipv4.tcp_plb_suspend_rto_sec)

	# Enable ECN and PLB on the sender; dctcp comes from the route.
	ip netns exec "$NS1" sysctl -qw net.ipv4.tcp_ecn=1
	ip netns exec "$NS1" sysctl -qw net.ipv4.tcp_plb_enabled=1
	ip netns exec "$NS1" sysctl -qw net.ipv4.tcp_plb_rehash_rounds=3
	ip netns exec "$NS1" sysctl -qw net.ipv4.tcp_plb_cong_thresh=1
	ip netns exec "$NS1" sysctl -qw net.ipv4.tcp_plb_suspend_rto_sec=0
	defer ip netns exec "$NS1" sysctl -qw net.ipv4.tcp_ecn="$saved_ecn1"
	defer ip netns exec "$NS1" sysctl -qw net.ipv4.tcp_plb_enabled="$saved_plb_enabled"
	defer ip netns exec "$NS1" sysctl -qw net.ipv4.tcp_plb_rehash_rounds="$saved_plb_rounds"
	defer ip netns exec "$NS1" sysctl -qw net.ipv4.tcp_plb_cong_thresh="$saved_plb_thresh"
	defer ip netns exec "$NS1" sysctl -qw net.ipv4.tcp_plb_suspend_rto_sec="$saved_plb_suspend"

	ip netns exec "$NS2" socat -u \
		"TCP6-LISTEN:$port,bind=[fd00:ff::2],reuseaddr" - >/dev/null &
	defer kill_process $!

	wait_local_port_listen "$NS2" "$port" tcp

	local base_tx0 base_tx1
	base_tx0=$(link_tx_packets_get "$NS1" veth0a)
	base_tx1=$(link_tx_packets_get "$NS1" veth1a)

	ip netns exec "$NS1" timeout 90 socat -u \
		OPEN:/dev/zero \
		"TCP6:[fd00:ff::2]:$port,bind=[fd00:ff::1]" &>/dev/null &
	local client_pid=$!
	defer kill_process "$client_pid"

	# Wait for data to start flowing before applying ECN marking.
	busywait "$BUSYWAIT_TIMEOUT" until_counter_is \
			">= $((base_tx0 + base_tx1 + 10))" \
		link_tx_packets_total "$NS1" > /dev/null
	check_err $? "no TX activity detected"
	if [ "$RET" -ne 0 ]; then
		log_test "Local ECMP PLB rehash: ECN-marked path"
		return
	fi

	# Snapshot TX counters and rehash stats before ECN marking.
	local pre_ecn_tx0 pre_ecn_tx1
	pre_ecn_tx0=$(link_tx_packets_get "$NS1" veth0a)
	pre_ecn_tx1=$(link_tx_packets_get "$NS1" veth1a)

	local plb_before rto_before
	plb_before=$(get_netstat_counter "$NS1" TCPPLBRehash)
	rto_before=$(get_netstat_counter "$NS1" TcpTimeoutRehash)

	# CE-mark all data on both paths.  PLB detects sustained
	# congestion and rehashes, bouncing traffic between paths.
	mark_ecn "$NS1" veth0a
	defer unblock_tcp "$NS1" veth0a	# removes the marking rule
	mark_ecn "$NS1" veth1a
	defer unblock_tcp "$NS1" veth1a	# removes the marking rule

	# Wait for meaningful data on both paths, proving PLB rehashed
	# the connection and traffic actually moved.  Require at least
	# 100 packets beyond the baseline to rule out stray control
	# packets (ND, etc.) satisfying the check.
	slowwait 60 dev_tx_packets_above \
		"$NS1" veth0a "$((pre_ecn_tx0 + 100))" > /dev/null
	check_err $? "no data on veth0a after ECN marking"

	slowwait 60 dev_tx_packets_above \
		"$NS1" veth1a "$((pre_ecn_tx1 + 100))" > /dev/null
	check_err $? "no data on veth1a after ECN marking"

	local plb_after rto_after
	plb_after=$(get_netstat_counter "$NS1" TCPPLBRehash)
	rto_after=$(get_netstat_counter "$NS1" TcpTimeoutRehash)
	if [ "$plb_after" -le "$plb_before" ]; then
		check_err 1 "TCPPLBRehash counter did not increment"
	fi
	if [ "$rto_after" -gt "$rto_before" ]; then
		check_err 1 "TcpTimeoutRehash incremented; rehash was RTO-driven, not PLB"
	fi

	log_test "Local ECMP PLB rehash: ECN-marked path"
}

# Verify that hash policy 1 (L3+L4 symmetric) preserves the ECMP path
# across rehash.  Policy 1 computes a deterministic hash from the
# 5-tuple, so mp_hash stays 0 and rt6_multipath_hash() always selects
# the same path regardless of txhash changes.
test_ecmp_hash_policy1_no_rehash()
{
	RET=0
	local port
	alloc_ports port

	local saved_policy
	saved_policy=$(ip netns exec "$NS1" sysctl -n \
		net.ipv6.fib_multipath_hash_policy)
	ip netns exec "$NS1" sysctl -qw net.ipv6.fib_multipath_hash_policy=1
	defer ip netns exec "$NS1" sysctl -qw \
		net.ipv6.fib_multipath_hash_policy="$saved_policy"

	block_tcp "$NS1" veth0a
	defer unblock_tcp "$NS1" veth0a
	block_tcp "$NS1" veth1a
	defer unblock_tcp "$NS1" veth1a

	ip netns exec "$NS2" socat \
		"TCP6-LISTEN:$port,bind=[fd00:ff::2],reuseaddr,fork" \
		EXEC:"echo POLICY1_OK" &
	defer kill_process $!

	wait_local_port_listen "$NS2" "$port" tcp

	local rehash_before
	rehash_before=$(get_netstat_counter "$NS1" TcpTimeoutRehash)

	ip netns exec "$NS1" timeout 10 socat -u \
		"TCP6:[fd00:ff::2]:$port,bind=[fd00:ff::1],connect-timeout=8" \
		STDOUT >/dev/null 2>&1 &
	local client_pid=$!
	defer kill_process "$client_pid"

	# With policy 1, the deterministic 5-tuple hash always selects
	# the same path.  Wait for multiple SYN retransmits (proving
	# rehash was attempted), then verify all SYNs landed on the
	# same interface.
	local rehash_after
	slowwait 8 until_counter_is ">= $((rehash_before + 3))" \
		get_netstat_counter "$NS1" TcpTimeoutRehash > /dev/null
	rehash_after=$(get_netstat_counter "$NS1" TcpTimeoutRehash)
	if [ "$rehash_after" -le "$rehash_before" ]; then
		check_err 1 "TcpTimeoutRehash counter did not increment"
	fi

	local c0 c1
	c0=$(tc_filter_pkt_count "$NS1" veth0a)
	c1=$(tc_filter_pkt_count "$NS1" veth1a)
	if [ "${c0:-0}" -ge 1 ] && [ "${c1:-0}" -ge 1 ]; then
		check_err 1 "SYNs appeared on both paths despite policy 1"
	fi
	if [ "${c0:-0}" -eq 0 ] && [ "${c1:-0}" -eq 0 ]; then
		check_err 1 "no SYNs observed on either path"
	fi

	log_test "Local ECMP policy 1: no path change on rehash"
}

# Verify that mp_hash does not leak into the on-wire flowlabel.
# With auto_flowlabels=0, the wire flowlabel must be 0.  Install tc
# filters that pass TCP with flowlabel=0 but drop TCP with nonzero
# flowlabel, then establish a connection and transfer data.  If
# mp_hash leaked into fl6->flowlabel, the SYN or data packets would
# be dropped and the connection would fail.
test_ecmp_no_flowlabel_leak()
{
	RET=0
	local port
	alloc_ports port

	local saved_afl
	saved_afl=$(ip netns exec "$NS1" sysctl -n \
		net.ipv6.auto_flowlabels)
	ip netns exec "$NS1" sysctl -qw net.ipv6.auto_flowlabels=0
	defer ip netns exec "$NS1" sysctl -qw \
		net.ipv6.auto_flowlabels="$saved_afl"

	# On both egress interfaces: pass TCP with flowlabel=0 (prio 1),
	# drop any remaining TCP (nonzero flowlabel, prio 2).  ICMPv6
	# matches neither filter and passes through normally.
	local dev
	for dev in veth0a veth1a; do
		ip netns exec "$NS1" tc qdisc add dev "$dev" \
			root handle 1: prio
		ip netns exec "$NS1" tc filter add dev "$dev" parent 1: \
			protocol ipv6 prio 1 u32 \
			match u32 0x00000000 0x000FFFFF at 0 \
			match u8 0x06 0xff at 6 \
			action ok
		ip netns exec "$NS1" tc filter add dev "$dev" parent 1: \
			protocol ipv6 prio 2 u32 \
			match u8 0x06 0xff at 6 \
			action drop
		defer unblock_tcp "$NS1" "$dev"
	done

	ip netns exec "$NS2" socat \
		"TCP6-LISTEN:$port,bind=[fd00:ff::2],reuseaddr" \
		EXEC:"echo FLOWLABEL_OK" &
	defer kill_process $!

	wait_local_port_listen "$NS2" "$port" tcp

	local tmpfile
	tmpfile=$(mktemp)
	defer rm -f "$tmpfile"

	ip netns exec "$NS1" socat -u \
		"TCP6:[fd00:ff::2]:$port,bind=[fd00:ff::1],connect-timeout=10" \
		STDOUT >"$tmpfile" 2>&1

	local result
	result=$(cat "$tmpfile" 2>/dev/null)
	if [[ "$result" != *"FLOWLABEL_OK"* ]]; then
		check_err 1 "connection failed: mp_hash may have leaked into wire flowlabel"
	fi

	log_test "No flowlabel leak with auto_flowlabels=0"
}

# Helper: stream data, invalidate the cached dst by adding and
# removing a dummy route (bumps fib6_node sernum), then check that
# traffic stays on the same ECMP path.  Used by both the normal
# tcp_v6_connect and syncookie variants.
ecmp_dst_rebuild_check()
{
	local ns_client=$1; shift
	local port=$1; shift
	local rc=0

	# Suppress __dst_negative_advice() during the test so that a
	# real TCP timeout cannot trigger an additional dst
	# invalidation via a different code path.
	local saved_retries1
	saved_retries1=$(ip netns exec "$ns_client" sysctl -n \
		net.ipv4.tcp_retries1)
	ip netns exec "$ns_client" sysctl -qw net.ipv4.tcp_retries1=255

	local base0 base1
	base0=$(link_tx_packets_get "$ns_client" veth0a)
	base1=$(link_tx_packets_get "$ns_client" veth1a)

	ip netns exec "$ns_client" timeout 15 socat -u \
		OPEN:/dev/zero \
		"TCP6:[fd00:ff::2]:$port,bind=[fd00:ff::1]" \
		&>/dev/null &
	local client_pid=$!

	# Wait for enough packets to identify the active path.
	# Return 2 for setup failure (distinct from 1 = path changed).
	if ! busywait "$BUSYWAIT_TIMEOUT" until_counter_is \
			">= $((base0 + base1 + 50))" \
		link_tx_packets_total "$ns_client" > /dev/null; then
		ip netns exec "$ns_client" sysctl -qw \
			net.ipv4.tcp_retries1="$saved_retries1"
		kill "$client_pid" 2>/dev/null
		wait "$client_pid" 2>/dev/null
		return 2
	fi

	local mid0 mid1 active_dev inactive_dev
	mid0=$(link_tx_packets_get "$ns_client" veth0a)
	mid1=$(link_tx_packets_get "$ns_client" veth1a)
	if [ $((mid0 - base0)) -ge $((mid1 - base1)) ]; then
		active_dev=veth0a; inactive_dev=veth1a
	else
		active_dev=veth1a; inactive_dev=veth0a
	fi

	local active_before inactive_before
	active_before=$(link_tx_packets_get "$ns_client" "$active_dev")
	inactive_before=$(link_tx_packets_get "$ns_client" "$inactive_dev")

	# Invalidate the cached dst by bumping the fib6_node sernum.
	# Adding and removing a high-metric dummy route achieves this
	# without touching the ECMP nexthops, avoiding a transient
	# single-nexthop state during multipath route replace.
	ip -n "$ns_client" -6 route add fd00:ff::2/128 dev lo metric 9999
	ip -n "$ns_client" -6 route del fd00:ff::2/128 dev lo metric 9999

	# Wait for enough post-rebuild traffic to detect a path change.
	if ! busywait "$BUSYWAIT_TIMEOUT" until_counter_is \
			">= $((active_before + inactive_before + 50))" \
		link_tx_packets_total "$ns_client" > /dev/null; then
		ip netns exec "$ns_client" sysctl -qw \
			net.ipv4.tcp_retries1="$saved_retries1"
		kill "$client_pid" 2>/dev/null
		wait "$client_pid" 2>/dev/null
		return 2
	fi

	local active_after inactive_after
	active_after=$(link_tx_packets_get "$ns_client" "$active_dev")
	inactive_after=$(link_tx_packets_get "$ns_client" "$inactive_dev")

	local active_delta=$((active_after - active_before))
	local inactive_delta=$((inactive_after - inactive_before))

	if [ "$inactive_delta" -gt "$active_delta" ]; then
		rc=1
	fi

	ip netns exec "$ns_client" sysctl -qw \
		net.ipv4.tcp_retries1="$saved_retries1"
	kill "$client_pid" 2>/dev/null
	wait "$client_pid" 2>/dev/null
	return "$rc"
}

# Run ecmp_dst_rebuild_check for ECMP_REBUILD_ROUNDS rounds, each with
# a fresh server and connection.  With a correct kernel the path is
# deterministic (same txhash always selects the same ECMP nexthop),
# so any path change is a bug.  Multiple rounds catch a buggy kernel
# that picks a random path: each round has 50% chance of accidentally
# matching, so 10 rounds gives < 0.1% false-pass probability.
ecmp_dst_rebuild_loop()
{
	local base_port=$1; shift
	local label=$1; shift
	local path_changes=0
	local r

	for r in $(seq 1 "$ECMP_REBUILD_ROUNDS"); do
		local port=$((base_port + r - 1))

		ip netns exec "$NS2" socat -u \
			"TCP6-LISTEN:$port,bind=[fd00:ff::2],reuseaddr" \
			- >/dev/null &
		local server_pid=$!

		wait_local_port_listen "$NS2" "$port" tcp

		local check_rc=0
		ecmp_dst_rebuild_check "$NS1" "$port" || check_rc=$?

		kill "$server_pid" 2>/dev/null
		wait "$server_pid" 2>/dev/null

		busywait "$BUSYWAIT_TIMEOUT" \
			port_has_no_active_tcp "$NS1" "$port" > /dev/null
		busywait "$BUSYWAIT_TIMEOUT" \
			port_has_no_active_tcp "$NS2" "$port" > /dev/null

		if [ "$check_rc" -eq 2 ]; then
			check_err 1 "no TX activity in round $r"
			break
		elif [ "$check_rc" -eq 1 ]; then
			path_changes=$((path_changes + 1))
		fi
	done

	if [ "$path_changes" -gt 0 ]; then
		check_err 1 "$path_changes/$ECMP_REBUILD_ROUNDS changed path"
	fi

	log_test "$label"
}

# Verify that a dst invalidation does not cause the connection to
# switch ECMP paths.  With the fix, both the initial route lookup
# (tcp_v6_connect) and subsequent rebuilds (inet6_csk_route_socket)
# use sk_txhash >> 1, so the path is stable.
test_ecmp_dst_rebuild_consistency()
{
	RET=0
	local base_port
	alloc_ports base_port "$ECMP_REBUILD_ROUNDS"

	ecmp_dst_rebuild_loop "$base_port" \
		"ECMP path stable after dst invalidation"
}

# Return 0 (true) when no active TCP sockets remain on a port.
# TIME_WAIT is excluded because it does not generate outgoing traffic.
port_has_no_active_tcp()
{
	local ns=$1; shift
	local port=$1; shift

	! ip netns exec "$ns" ss -tnH \
		state established \
		state fin-wait-1 \
		state fin-wait-2 \
		state close-wait \
		state last-ack \
		state closing \
		state syn-sent \
		state syn-recv \
		"sport = :$port or dport = :$port" | grep -q .
}

# Count TCP packets on server egress without blocking them.
# Uses tc filters with "action ok" so packets are counted and passed.
count_tcp()
{
	local ns=$1; shift
	local dev=$1; shift

	ip netns exec "$ns" tc qdisc add dev "$dev" root handle 1: prio
	ip netns exec "$ns" tc filter add dev "$dev" parent 1: \
		protocol ipv6 prio 1 u32 match u8 0x06 0xff at 6 action ok
}

# Verify that the server's SYN-ACK (sent from the request socket) and
# subsequent ACKs (sent from the full socket created in cookie_v6_check)
# use the same ECMP path.  With syncookies the request socket is freed
# after the SYN-ACK and a new one is created during cookie validation;
# this test catches the case where the two request sockets pick
# different ECMP paths due to independent txhash values.
test_ecmp_syncookie_path_consistency()
{
	RET=0

	local saved_syncookies
	saved_syncookies=$(ip netns exec "$NS2" sysctl -n \
		net.ipv4.tcp_syncookies 2>/dev/null)
	if [ -z "$saved_syncookies" ]; then
		log_test_skip "Syncookie server ECMP path consistent"
		return "$ksft_skip"
	fi
	ip netns exec "$NS2" sysctl -qw net.ipv4.tcp_syncookies=2
	defer ip netns exec "$NS2" sysctl -qw \
		net.ipv4.tcp_syncookies="$saved_syncookies"

	count_tcp "$NS2" veth0b
	defer unblock_tcp "$NS2" veth0b
	count_tcp "$NS2" veth1b
	defer unblock_tcp "$NS2" veth1b

	local path_splits=0
	local r base_port
	alloc_ports base_port "$ECMP_REBUILD_ROUNDS"

	for r in $(seq 1 "$ECMP_REBUILD_ROUNDS"); do
		local port=$((base_port + r - 1))

		ip netns exec "$NS2" socat -u \
			"TCP6-LISTEN:$port,bind=[fd00:ff::2],reuseaddr" \
			- >/dev/null &
		local server_pid=$!

		wait_local_port_listen "$NS2" "$port" tcp

		local srv_base0 srv_base1
		srv_base0=$(tc_filter_pkt_count "$NS2" veth0b)
		srv_base1=$(tc_filter_pkt_count "$NS2" veth1b)

		ip netns exec "$NS1" timeout 5 socat -u \
			OPEN:/dev/zero \
			"TCP6:[fd00:ff::2]:$port,bind=[fd00:ff::1]" \
			&>/dev/null &
		local client_pid=$!

		local cli_base
		cli_base=$(link_tx_packets_total "$NS1")
		if ! busywait "$BUSYWAIT_TIMEOUT" until_counter_is \
				">= $((cli_base + 200))" \
			link_tx_packets_total "$NS1" > /dev/null; then
			check_err 1 "no TX activity in round $r"
			kill "$client_pid" 2>/dev/null
			wait "$client_pid" 2>/dev/null
			kill "$server_pid" 2>/dev/null
			wait "$server_pid" 2>/dev/null
			break
		fi

		local srv_tcp0 srv_tcp1
		srv_tcp0=$(tc_filter_pkt_count "$NS2" veth0b)
		srv_tcp1=$(tc_filter_pkt_count "$NS2" veth1b)
		local srv_delta0=$(( ${srv_tcp0:-0} - ${srv_base0:-0} ))
		local srv_delta1=$(( ${srv_tcp1:-0} - ${srv_base1:-0} ))

		if [ "$srv_delta0" -gt 0 ] && [ "$srv_delta1" -gt 0 ]; then
			path_splits=$((path_splits + 1))
		fi

		kill "$client_pid" 2>/dev/null
		wait "$client_pid" 2>/dev/null
		kill "$server_pid" 2>/dev/null
		wait "$server_pid" 2>/dev/null

		# Wait for TCP teardown packets (FIN/RST) to finish so
		# they do not pollute the next round's tc filter counters.
		busywait "$BUSYWAIT_TIMEOUT" \
			port_has_no_active_tcp "$NS1" "$port" > /dev/null
		busywait "$BUSYWAIT_TIMEOUT" \
			port_has_no_active_tcp "$NS2" "$port" > /dev/null
	done

	if [ "$path_splits" -gt 0 ]; then
		check_err 1 "$path_splits/$ECMP_REBUILD_ROUNDS had split server path"
	fi

	log_test "Syncookie server ECMP path consistent"
}

require_command socat

trap 'defer_scopes_cleanup; cleanup_all_ns' EXIT
setup || exit $?
tests_run
exit "$EXIT_STATUS"