| #!/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" |