Commit afeb889255 for frr

commit afeb88925533064944a4b3f1c63e3d2f4fad93e7
Author: Sougata Barik <sougatab@nvidia.com>
Date:   Fri Jul 17 01:54:40 2026 +0530

    tests: topotest for BFD on shared-link-local unnumbered subif churn

    Adds tests/topotests/bgp_bfd_unnumbered_subif_churn, exercising many
    single-hop unnumbered eBGP+BFD peers over VLAN sub-interfaces that all share
    the same IPv6 link-local address (same parent MAC), across repeated BGP
    neighbor delete/re-add churn.

    - test_..._baseline: all peers Established and all BFD sessions Up on first
      bring-up.
    - test_..._churn_recovery: after each delete/re-add cycle every BFD session
      must return to Up. This FAILS without the _bfd_sess_valid() interface-less
      link-local guard (sessions collapse onto one ifindex-less key and stay Down)
      and PASSES with the fix.

    Ticket: #5131052
    Signed-off-by: Sougata Barik <sougatab@nvidia.com>

diff --git a/tests/topotests/bgp_bfd_unnumbered_subif_churn/__init__.py b/tests/topotests/bgp_bfd_unnumbered_subif_churn/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/topotests/bgp_bfd_unnumbered_subif_churn/r1/frr.conf b/tests/topotests/bgp_bfd_unnumbered_subif_churn/r1/frr.conf
new file mode 100644
index 0000000000..f1cbfa4fab
--- /dev/null
+++ b/tests/topotests/bgp_bfd_unnumbered_subif_churn/r1/frr.conf
@@ -0,0 +1,95 @@
+!
+interface lo
+ ip address 192.0.2.1/32
+!
+interface r1-eth0.100
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r1-eth0.101
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r1-eth0.102
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r1-eth0.103
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r1-eth0.104
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r1-eth0.105
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r1-eth0.106
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r1-eth0.107
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r1-eth0.108
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r1-eth0.109
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+ip forwarding
+ipv6 forwarding
+!
+bfd
+ profile fast
+  detect-multiplier 3
+  receive-interval 300
+  transmit-interval 300
+ exit
+exit
+!
+router bgp 65001
+ bgp router-id 192.0.2.1
+ no bgp ebgp-requires-policy
+ timers bgp 3 9
+ neighbor r1-eth0.100 interface remote-as external
+ neighbor r1-eth0.100 bfd
+ neighbor r1-eth0.100 bfd profile fast
+ neighbor r1-eth0.101 interface remote-as external
+ neighbor r1-eth0.101 bfd
+ neighbor r1-eth0.101 bfd profile fast
+ neighbor r1-eth0.102 interface remote-as external
+ neighbor r1-eth0.102 bfd
+ neighbor r1-eth0.102 bfd profile fast
+ neighbor r1-eth0.103 interface remote-as external
+ neighbor r1-eth0.103 bfd
+ neighbor r1-eth0.103 bfd profile fast
+ neighbor r1-eth0.104 interface remote-as external
+ neighbor r1-eth0.104 bfd
+ neighbor r1-eth0.104 bfd profile fast
+ neighbor r1-eth0.105 interface remote-as external
+ neighbor r1-eth0.105 bfd
+ neighbor r1-eth0.105 bfd profile fast
+ neighbor r1-eth0.106 interface remote-as external
+ neighbor r1-eth0.106 bfd
+ neighbor r1-eth0.106 bfd profile fast
+ neighbor r1-eth0.107 interface remote-as external
+ neighbor r1-eth0.107 bfd
+ neighbor r1-eth0.107 bfd profile fast
+ neighbor r1-eth0.108 interface remote-as external
+ neighbor r1-eth0.108 bfd
+ neighbor r1-eth0.108 bfd profile fast
+ neighbor r1-eth0.109 interface remote-as external
+ neighbor r1-eth0.109 bfd
+ neighbor r1-eth0.109 bfd profile fast
+ !
+ address-family ipv4 unicast
+  redistribute connected
+ exit-address-family
+exit
+!
diff --git a/tests/topotests/bgp_bfd_unnumbered_subif_churn/r2/frr.conf b/tests/topotests/bgp_bfd_unnumbered_subif_churn/r2/frr.conf
new file mode 100644
index 0000000000..2ca2bb6956
--- /dev/null
+++ b/tests/topotests/bgp_bfd_unnumbered_subif_churn/r2/frr.conf
@@ -0,0 +1,95 @@
+!
+interface lo
+ ip address 192.0.2.2/32
+!
+interface r2-eth0.100
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r2-eth0.101
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r2-eth0.102
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r2-eth0.103
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r2-eth0.104
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r2-eth0.105
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r2-eth0.106
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r2-eth0.107
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r2-eth0.108
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+interface r2-eth0.109
+ ipv6 nd ra-interval 1
+ no ipv6 nd suppress-ra
+!
+ip forwarding
+ipv6 forwarding
+!
+bfd
+ profile fast
+  detect-multiplier 3
+  receive-interval 300
+  transmit-interval 300
+ exit
+exit
+!
+router bgp 65002
+ bgp router-id 192.0.2.2
+ no bgp ebgp-requires-policy
+ timers bgp 3 9
+ neighbor r2-eth0.100 interface remote-as external
+ neighbor r2-eth0.100 bfd
+ neighbor r2-eth0.100 bfd profile fast
+ neighbor r2-eth0.101 interface remote-as external
+ neighbor r2-eth0.101 bfd
+ neighbor r2-eth0.101 bfd profile fast
+ neighbor r2-eth0.102 interface remote-as external
+ neighbor r2-eth0.102 bfd
+ neighbor r2-eth0.102 bfd profile fast
+ neighbor r2-eth0.103 interface remote-as external
+ neighbor r2-eth0.103 bfd
+ neighbor r2-eth0.103 bfd profile fast
+ neighbor r2-eth0.104 interface remote-as external
+ neighbor r2-eth0.104 bfd
+ neighbor r2-eth0.104 bfd profile fast
+ neighbor r2-eth0.105 interface remote-as external
+ neighbor r2-eth0.105 bfd
+ neighbor r2-eth0.105 bfd profile fast
+ neighbor r2-eth0.106 interface remote-as external
+ neighbor r2-eth0.106 bfd
+ neighbor r2-eth0.106 bfd profile fast
+ neighbor r2-eth0.107 interface remote-as external
+ neighbor r2-eth0.107 bfd
+ neighbor r2-eth0.107 bfd profile fast
+ neighbor r2-eth0.108 interface remote-as external
+ neighbor r2-eth0.108 bfd
+ neighbor r2-eth0.108 bfd profile fast
+ neighbor r2-eth0.109 interface remote-as external
+ neighbor r2-eth0.109 bfd
+ neighbor r2-eth0.109 bfd profile fast
+ !
+ address-family ipv4 unicast
+  redistribute connected
+ exit-address-family
+exit
+!
diff --git a/tests/topotests/bgp_bfd_unnumbered_subif_churn/test_bgp_bfd_unnumbered_subif_churn.py b/tests/topotests/bgp_bfd_unnumbered_subif_churn/test_bgp_bfd_unnumbered_subif_churn.py
new file mode 100644
index 0000000000..8a4790b268
--- /dev/null
+++ b/tests/topotests/bgp_bfd_unnumbered_subif_churn/test_bgp_bfd_unnumbered_subif_churn.py
@@ -0,0 +1,240 @@
+#!/usr/bin/env python
+# SPDX-License-Identifier: ISC
+#
+# Copyright (c) 2026 by
+# Sougata Barik <sougatab@nvidia.com>
+#
+
+"""
+Regression test: BFD sessions must stay Up for many single-hop unnumbered
+eBGP peers that share the same IPv6 link-local address, across repeated
+BGP neighbor delete/re-add churn.
+
+Topology
+
+    R1 (AS 65001) ----[s1]---- R2 (AS 65002)
+
+The physical link r1-eth0 <-> r2-eth0 carries NUM_VLANS tagged VLANs. Each
+VLAN sub-interface hosts one single-hop unnumbered eBGP peer with BFD enabled
+(fast profile). Because all sub-interfaces of a parent share the parent's MAC,
+every sub-interface derives the SAME IPv6 link-local address, so all BFD
+sessions share an identical (src, dst) link-local pair and are distinguishable
+only by their outgoing interface (ifindex).
+
+Bug (FRRouting BFD client library)
+----------------------------------
+During rapid BGP neighbor delete/re-add, bgp_peer_bfd_update_source() fires the
+address setter while peer->nexthop.ifp is still NULL, so the BFD session is
+registered/deregistered with no interface. With every peer sharing the same
+link-local pair, all those interface-less registrations collapse onto a single
+ifindex-less key in bfdd; the sessions tear each other down and most stay Down
+even though BGP reaches Established.
+
+Expected (fixed) behaviour: after each delete/re-add cycle, BGP reaches
+Established AND all NUM_VLANS BFD sessions return to Up.
+
+test_bgp_bfd_unnumbered_subif_baseline
+    Sanity: all peers Established and all BFD sessions Up on first bring-up.
+
+test_bgp_bfd_unnumbered_subif_churn_recovery
+    THE regression. Repeatedly delete and re-add all unnumbered BFD peers on
+    R1, then require every BFD session to come back Up. This FAILS on the
+    unfixed tree (sessions collapse -> stuck Down) and PASSES with the fix.
+"""
+
+import os
+import sys
+import pytest
+import functools
+
+CWD = os.path.dirname(os.path.realpath(__file__))
+sys.path.append(os.path.join(CWD, "../"))
+
+# pylint: disable=C0413
+from lib import topotest
+from lib.topogen import Topogen, get_topogen
+from lib.topolog import logger
+from lib.common_config import step
+
+pytestmark = [pytest.mark.bfdd, pytest.mark.bgpd]
+
+NUM_VLANS = 10
+START_VLAN = 100
+CHURN_ITERATIONS = 5
+
+VLANS = list(range(START_VLAN, START_VLAN + NUM_VLANS))
+
+
+def _subifs(rname):
+    """Return the list of sub-interface names for a router."""
+    return ["{}-eth0.{}".format(rname, vlan) for vlan in VLANS]
+
+
+def build_topo(tgen):
+    """Two routers connected via a single switch."""
+    tgen.add_router("r1")
+    tgen.add_router("r2")
+
+    switch = tgen.add_switch("s1")
+    switch.add_link(tgen.gears["r1"])
+    switch.add_link(tgen.gears["r2"])
+
+
+def setup_module(mod):
+    """Create the VLAN sub-interfaces and load per-router configs."""
+    tgen = Topogen(build_topo, mod.__name__)
+    tgen.start_topology()
+
+    router_list = tgen.routers()
+
+    for rname, router in router_list.items():
+        for vlan in VLANS:
+            router.net.add_vlan(
+                "{}-eth0.{}".format(rname, vlan), "{}-eth0".format(rname), vlan
+            )
+
+    for router in router_list.values():
+        router.load_frr_config()
+
+    tgen.start_router()
+
+
+def teardown_module(mod):
+    """Stop the topology."""
+    tgen = get_topogen()
+    tgen.stop_topology()
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _bgp_established_count(router):
+    """Number of ipv4-unicast peers in Established state."""
+    output = router.vtysh_cmd("show bgp summary json", isjson=True)
+    peers = output.get("ipv4Unicast", {}).get("peers", {})
+    return sum(
+        1 for p in peers.values() if p.get("state") == "Established"
+    )
+
+
+def _bfd_up_count(router):
+    """Number of BFD peer sessions currently in status 'up'."""
+    output = router.vtysh_cmd("show bfd peers json", isjson=True)
+    if not isinstance(output, list):
+        return 0
+    return sum(1 for peer in output if peer.get("status") == "up")
+
+
+def _check_all_up(router):
+    """
+    Return None when both BGP (Established) and BFD (up) reach NUM_VLANS,
+    otherwise a description of what is still missing.
+    """
+    bgp = _bgp_established_count(router)
+    bfd = _bfd_up_count(router)
+    if bgp != NUM_VLANS:
+        return "BGP established {}/{}".format(bgp, NUM_VLANS)
+    if bfd != NUM_VLANS:
+        return "BFD up {}/{} (BGP established {}/{})".format(
+            bfd, NUM_VLANS, bgp, NUM_VLANS
+        )
+    return None
+
+
+def _delete_bfd_peers(router, rname):
+    """Remove all unnumbered BFD peers from the router's BGP config."""
+    lines = ["configure terminal", "router bgp 65001"]
+    for ifname in _subifs(rname):
+        lines.append("no neighbor {} interface remote-as external".format(ifname))
+    lines.append("end")
+    router.vtysh_cmd("\n".join(lines))
+
+
+def _add_bfd_peers(router, rname):
+    """Re-add all unnumbered peers with BFD enabled."""
+    lines = ["configure terminal", "router bgp 65001"]
+    for ifname in _subifs(rname):
+        lines.append("neighbor {} interface remote-as external".format(ifname))
+        lines.append("neighbor {} bfd".format(ifname))
+        lines.append("neighbor {} bfd profile fast".format(ifname))
+    lines.append("end")
+    router.vtysh_cmd("\n".join(lines))
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+def test_bgp_bfd_unnumbered_subif_baseline():
+    """Sanity: on first bring-up all peers are Established and all BFD Up."""
+    tgen = get_topogen()
+    if tgen.routers_have_failure():
+        pytest.skip(tgen.errors)
+
+    r1 = tgen.gears["r1"]
+
+    step("Baseline: all {} BGP peers Established and all BFD sessions Up".format(
+        NUM_VLANS))
+    test_func = functools.partial(_check_all_up, r1)
+    _, result = topotest.run_and_expect(test_func, None, count=90, wait=1)
+    assert result is None, "Baseline did not converge on R1: {}".format(result)
+
+
+def test_bgp_bfd_unnumbered_subif_churn_recovery():
+    """
+    Regression for the shared-link-local interface-less BFD collapse.
+
+    Delete and re-add every unnumbered BFD peer on R1 repeatedly; after each
+    cycle every BFD session must return to Up. Fails on the unfixed tree.
+    """
+    tgen = get_topogen()
+    if tgen.routers_have_failure():
+        pytest.skip(tgen.errors)
+
+    r1 = tgen.gears["r1"]
+
+    # Make sure we start from a fully converged state.
+    test_func = functools.partial(_check_all_up, r1)
+    _, result = topotest.run_and_expect(test_func, None, count=90, wait=1)
+    assert result is None, "Pre-churn state not converged on R1: {}".format(result)
+
+    for i in range(1, CHURN_ITERATIONS + 1):
+        step("Churn iteration {}/{}: delete all unnumbered BFD peers".format(
+            i, CHURN_ITERATIONS))
+        _delete_bfd_peers(r1, "r1")
+
+        # Let the delete settle so the sessions are actually torn down.
+        def _all_down():
+            return None if _bgp_established_count(r1) == 0 else "peers still up"
+
+        topotest.run_and_expect(functools.partial(_all_down), None, count=30, wait=1)
+
+        step("Churn iteration {}/{}: re-add all unnumbered BFD peers".format(
+            i, CHURN_ITERATIONS))
+        _add_bfd_peers(r1, "r1")
+
+        step("Churn iteration {}/{}: require BGP {}/{} and BFD {}/{}".format(
+            i, CHURN_ITERATIONS, NUM_VLANS, NUM_VLANS, NUM_VLANS, NUM_VLANS))
+        test_func = functools.partial(_check_all_up, r1)
+        _, result = topotest.run_and_expect(test_func, None, count=90, wait=1)
+        assert result is None, (
+            "After churn iteration {}: {} - BFD sessions did not all recover "
+            "(shared link-local interface-less collapse)".format(i, result)
+        )
+
+
+def test_memory_leak():
+    """Run the memory leak test and report results."""
+    tgen = get_topogen()
+    if not tgen.is_memleak_enabled():
+        pytest.skip("Memory leak test/report is disabled")
+
+    tgen.report_memory_leaks()
+
+
+if __name__ == "__main__":
+    args = ["-s"] + sys.argv[1:]
+    sys.exit(pytest.main(args))