The issue: bpf_offload.py only exercises per-device ingress qdiscs today, so neither the shared-block accept path nor the late-join replay is tested. The Fix: Add a test for a device-bound SCHED_CLS program attached through a shared block. It loads a dev-bound SCHED_CLS program for a netdevsim device and checks: - the program attaches to a shared block owned by its device, and the filter teardown leaves the device unloaded; - a block that also holds an independently backed device rejects the attach, because the offload path broadcasts the program to all members; - an independently backed device that joins a populated block is not loaded by the bind/replay path, and the driver's extack is asserted; - a second port of the same netdevsim device (same offload device) is accepted together with the bound port. The program is loaded from the existing sample_ret0.bpf.o with an explicit `type classifier` override, so no new sample object is needed. Reviewed-by: Victor Nogueira Signed-off-by: Jamal Hadi Salim --- v1 -> v2 (v2 of an on-list patch; v1 posted 2026-10-04): - New selftest: shared-block + late-join coverage in bpf_offload.py. - Split the single v2 draft into three patches; this companion carries no Fixes: and no Cc: stable (a selftest is not backported; it depends on both code patches). --- tools/testing/selftests/net/bpf_offload.py | 139 +++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/tools/testing/selftests/net/bpf_offload.py b/tools/testing/selftests/net/bpf_offload.py index c856d266c8f3..87e244d8ff05 100755 --- a/tools/testing/selftests/net/bpf_offload.py +++ b/tools/testing/selftests/net/bpf_offload.py @@ -452,6 +452,38 @@ class BpfNetdevSim(NetdevSim): self.tc_add_ingress() self.wait_for_flush(bound=bound, total=total) + def tc_add_block(self, block, fail=True, include_stderr=False): + return tc("qdisc add dev %s ingress_block %d clsact" % + (self['ifname'], block), fail=fail, + include_stderr=include_stderr) + + def tc_del_block(self, block): + tc("qdisc del dev %s clsact" % (self['ifname']), fail=False) + + def tc_add_block_filter(self, block, bpf, op="add", prio=None, handle=None, + da=False, verbose=False, skip_sw=False, + skip_hw=False, fail=True, include_stderr=False): + spec = "" + if prio is not None: + spec += " prio %d" % (prio) + if handle: + spec += " handle %s" % (handle) + + params = "" + if da: + params += " da" + if verbose: + params += " verbose" + if skip_sw: + params += " skip_sw" + if skip_hw: + params += " skip_hw" + + return tc("filter {op} block {block} {spec} bpf {bpf} {params}" + .format(op=op, block=block, spec=spec, bpf=bpf, + params=params), + fail=fail, include_stderr=include_stderr) + def tc_show_ingress(self, expected=None): # No JSON support, oh well... flags = ["skip_sw", "skip_hw", "in_hw"] @@ -629,6 +661,111 @@ def test_spurios_extack(sim, obj, skip_hw, needle): include_stderr=True) check_no_extack(res, needle) +def test_tc_shared_block(): + """ + Device-bound cls_bpf program attached through a shared block. + """ + # A shared block has no qdisc, so the target device is derived from the + # block's bound netdevs and the program must match every member, since + # the offload path broadcasts it to every callback on the block. A + # netdev joining a populated block later is replayed the stored filter + # before it is inserted into block->ports, so the driver callback must + # enforce the device match as well. + simdev = BpfNetdevSimDev() + sim, = simdev.nsims + sim.set_ethtool_tc_offloads(True) + + start_test("Test TC shared-block load dev-bound program...") + bpftool_prog_load("sample_ret0.bpf.o", "/sys/fs/bpf/tc_shared", + prog_type="classifier", dev=sim['ifname']) + + start_test("Test TC shared-block dev-bound attach...") + # The pinned program is dev-bound to this device; attaching it through + # a shared block must succeed even though block->q is NULL. + sim.tc_add_block(22) + # err is kept (include_stderr) for post-mortem value on a failure. + ret, _, err = sim.tc_add_block_filter(22, bpf_pinned("/sys/fs/bpf/tc_shared"), + da=True, skip_sw=True, + fail=False, include_stderr=True) + fail(ret != 0, "dev-bound program rejected on a matching shared block") + fail(sim.dfs_read("bpf_offloaded_id") == "0", + "dev-bound program not offloaded on a matching shared block") + sim.tc_del_block(22) + + start_test("Test TC shared-block broadcast to a foreign member...") + # Add an independently backed device to the SAME block before the + # attach. The program is bound to sim's device only, and the offload + # path fans it out to every member, so the attach must be rejected. + simdev2 = BpfNetdevSimDev() + sim2, = simdev2.nsims + sim2.set_ethtool_tc_offloads(True) + sim.tc_add_block(40) + sim2.tc_add_block(40) + ret, _, err = sim.tc_add_block_filter(40, bpf_pinned("/sys/fs/bpf/tc_shared"), + da=True, skip_sw=True, + fail=False, include_stderr=True) + fail(ret == 0, "dev-bound program broadcast to a foreign shared-block member") + check_extack(err, "Program is not bound to every device in the block.", args) + sim2.tc_del_block(40) + sim.tc_del_block(40) + simdev2.remove() + + start_test("Test TC shared-block late join replay...") + # Build a block owned by sim's device with the matching program + # installed, then let an independently backed device join it. The + # join replays the stored filter to the joining device's callback + # before the device is recorded in block->ports; the driver must + # reject the replay so the foreign device is never loaded. + # sim2 is created here (not inherited) so the TC-offload state it + # depends on is explicit. + simdev2 = BpfNetdevSimDev() + sim2, = simdev2.nsims + sim2.set_ethtool_tc_offloads(True) + sim.tc_add_block(30) + ret, _, err = sim.tc_add_block_filter(30, bpf_pinned("/sys/fs/bpf/tc_shared"), + da=True, skip_sw=True, + fail=False, include_stderr=True) + fail(ret != 0, "dev-bound program rejected on a matching shared block") + ret, _, err = sim2.tc_add_block(30, fail=False, include_stderr=True) + fail(ret == 0, "foreign device joined a populated dev-bound shared block") + check_extack_nsim(err, "program bound to a different device.", args) + fail(sim2.dfs_read("bpf_offloaded_id") != "0", + "replayed program loaded onto a foreign device") + # The add above was expected to fail, so the qdisc may not exist; the + # delete is harmless and only cleans up when it does. + sim2.tc_del_block(30) + sim.tc_del_block(30) + simdev2.remove() + + start_test("Test TC shared-block same-offload-device member...") + # A second port of the same netdevsim device shares the offload device, + # so a program bound to one port may legitimately be offloaded to both. + simdev3 = BpfNetdevSimDev(2) + sim3, sim3b = simdev3.nsims + sim3.set_ethtool_tc_offloads(True) + sim3b.set_ethtool_tc_offloads(True) + bpftool_prog_load("sample_ret0.bpf.o", "/sys/fs/bpf/tc_shared_multi", + prog_type="classifier", dev=sim3['ifname']) + sim3.tc_add_block(50) + sim3b.tc_add_block(50) + # err kept for post-mortem value on a failure. + ret, _, err = sim3.tc_add_block_filter(50, bpf_pinned("/sys/fs/bpf/tc_shared_multi"), + da=True, skip_sw=True, + fail=False, include_stderr=True) + fail(ret != 0, "same-offload-device member rejected on a shared block") + fail(sim3.dfs_read("bpf_offloaded_id") == "0", + "program not offloaded to the bound port") + fail(sim3b.dfs_read("bpf_offloaded_id") == "0", + "program not offloaded to the same-offload-device port") + sim3b.tc_del_block(50) + sim3.tc_del_block(50) + + simdev3.remove() + simdev.remove() + rm("/sys/fs/bpf/tc_shared_multi") + rm("/sys/fs/bpf/tc_shared") + bpftool_prog_list_wait(expected=0) + def test_multi_prog(simdev, sim, obj, modename, modeid): start_test("Test multi-attachment XDP - %s + offload..." % (modename or "default", )) @@ -1349,6 +1486,8 @@ try: ret, out = bpftool("prog show %s" % (progB), fail=False) fail(ret != 0, "couldn't get information about orphaned program") + test_tc_shared_block() + print("%s: OK" % (os.path.basename(__file__))) finally: -- 2.43.0