The read-only Generic Netlink ABI has userspace-visible behavior KUnit
cannot reach: policy validation, multipart dumps, notifications and family
introspection. These paths require a live netlink socket.

Add eleven kselftest programs using the in-tree YNL client against
fabricsim. They cover queries and filters, notifications, malformed policy
input, interrupted dumps, nested port-cursor resumption after endpoint
removal, mixed per-port statistics support, topology churn, opaque switch
peers and provider error propagation.

Shared helpers manage module lifetime and restore fabricsim state between
programs. Add the selftest configuration, README and a regeneration guard
for the generated uAPI header, kernel header and operation/policy source.

  $ make -C tools/testing/selftests TARGETS=drivers/gpu/drm_fabric \
        run_tests
  [...]
  ok 1 selftests: drivers/gpu/drm_fabric: check-spec-regen.sh
  [...]
  ok 11 selftests: drivers/gpu/drm_fabric: harness_reset_abi.py

The run reports 89 results across eleven programs, all passing in a
booted virtme-ng guest.

Signed-off-by: Konstantin Sinyuk <[email protected]>
Assisted-by: GitHub-Copilot:claude-opus-4.8
---
 Documentation/gpu/drm-fabric.rst              |   7 +
 tools/testing/selftests/Makefile              |   1 +
 .../selftests/drivers/gpu/drm_fabric/Makefile |  41 ++
 .../drivers/gpu/drm_fabric/README.rst         | 136 +++++
 .../gpu/drm_fabric/check-spec-regen.sh        | 114 ++++
 .../selftests/drivers/gpu/drm_fabric/config   |   8 +
 .../drivers/gpu/drm_fabric/dump_intr_abi.py   | 361 +++++++++++++
 .../drivers/gpu/drm_fabric/dump_scale_abi.py  | 178 +++++++
 .../drivers/gpu/drm_fabric/fabric_abi.py      | 401 +++++++++++++++
 .../drivers/gpu/drm_fabric/fault_abi.py       | 105 ++++
 .../gpu/drm_fabric/harness_reset_abi.py       | 113 ++++
 .../drivers/gpu/drm_fabric/hotplug_abi.py     | 204 ++++++++
 .../drivers/gpu/drm_fabric/lib_drm_fabric.py  | 481 +++++++++++++++++
 .../drivers/gpu/drm_fabric/nl_policy_probe.py | 485 ++++++++++++++++++
 .../drivers/gpu/drm_fabric/port_cursor_abi.py | 401 +++++++++++++++
 .../gpu/drm_fabric/port_stats_cap_abi.py      | 374 ++++++++++++++
 .../selftests/drivers/gpu/drm_fabric/settings |   1 +
 .../drivers/gpu/drm_fabric/switch_abi.py      | 108 ++++
 18 files changed, 3519 insertions(+)
 create mode 100644 tools/testing/selftests/drivers/gpu/drm_fabric/Makefile
 create mode 100644 tools/testing/selftests/drivers/gpu/drm_fabric/README.rst
 create mode 100755 
tools/testing/selftests/drivers/gpu/drm_fabric/check-spec-regen.sh
 create mode 100644 tools/testing/selftests/drivers/gpu/drm_fabric/config
 create mode 100755 
tools/testing/selftests/drivers/gpu/drm_fabric/dump_intr_abi.py
 create mode 100755 
tools/testing/selftests/drivers/gpu/drm_fabric/dump_scale_abi.py
 create mode 100755 tools/testing/selftests/drivers/gpu/drm_fabric/fabric_abi.py
 create mode 100755 tools/testing/selftests/drivers/gpu/drm_fabric/fault_abi.py
 create mode 100755 
tools/testing/selftests/drivers/gpu/drm_fabric/harness_reset_abi.py
 create mode 100755 
tools/testing/selftests/drivers/gpu/drm_fabric/hotplug_abi.py
 create mode 100644 
tools/testing/selftests/drivers/gpu/drm_fabric/lib_drm_fabric.py
 create mode 100755 
tools/testing/selftests/drivers/gpu/drm_fabric/nl_policy_probe.py
 create mode 100755 
tools/testing/selftests/drivers/gpu/drm_fabric/port_cursor_abi.py
 create mode 100755 
tools/testing/selftests/drivers/gpu/drm_fabric/port_stats_cap_abi.py
 create mode 100644 tools/testing/selftests/drivers/gpu/drm_fabric/settings
 create mode 100755 tools/testing/selftests/drivers/gpu/drm_fabric/switch_abi.py

diff --git a/Documentation/gpu/drm-fabric.rst b/Documentation/gpu/drm-fabric.rst
index cd98a97e1662..bc7b87c766cc 100644
--- a/Documentation/gpu/drm-fabric.rst
+++ b/Documentation/gpu/drm-fabric.rst
@@ -357,3 +357,10 @@ Testing
 
 The object model is covered by KUnit when ``CONFIG_DRM_FABRIC_KUNIT_TEST`` is
 enabled. The test source is folded into the core translation unit.
+
+Generic Netlink ABI tests live under
+``tools/testing/selftests/drivers/gpu/drm_fabric``. They cover the YNL query
+paths, malformed policy input, generated-header synchronization, dump-cursor
+correctness across endpoint removal, ``NLM_F_DUMP_INTR`` handling, the opaque
+switch half-edge, and provider fault handling. See that directory's 
``README.rst``
+for build and execution commands.
diff --git a/tools/testing/selftests/Makefile b/tools/testing/selftests/Makefile
index b622052ec3e9..c90eb5d33ec6 100644
--- a/tools/testing/selftests/Makefile
+++ b/tools/testing/selftests/Makefile
@@ -18,6 +18,7 @@ TARGETS += devices/error_logs
 TARGETS += devices/probe
 TARGETS += dmabuf-heaps
 TARGETS += drivers/dma-buf
+TARGETS += drivers/gpu/drm_fabric
 TARGETS += drivers/ntsync
 TARGETS += drivers/s390x/uvdevice
 TARGETS += drivers/net
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/Makefile 
b/tools/testing/selftests/drivers/gpu/drm_fabric/Makefile
new file mode 100644
index 000000000000..54d756979d97
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/Makefile
@@ -0,0 +1,41 @@
+# SPDX-License-Identifier: GPL-2.0
+#
+# kselftests for the drm-fabric Generic Netlink ABI.
+#
+# Each TEST_PROGS entry is invoked separately by the kselftest harness; suites
+# self-load modules when needed (check-spec-regen.sh is host-only).
+#
+# Run:
+#   make -C tools/testing/selftests TARGETS=drivers/gpu/drm_fabric run_tests
+
+TEST_PROGS := \
+       check-spec-regen.sh \
+       fabric_abi.py \
+       nl_policy_probe.py \
+       dump_intr_abi.py \
+       port_cursor_abi.py \
+       port_stats_cap_abi.py \
+       hotplug_abi.py \
+       dump_scale_abi.py \
+       switch_abi.py \
+       fault_abi.py \
+       harness_reset_abi.py
+
+TEST_FILES := lib_drm_fabric.py
+
+include ../../../lib.mk
+
+# A TEST_PROGS entry that loses its executable bit still runs, because the
+# kselftest runner falls back to the shebang, but only after a warning that is
+# easy to miss in a long run. Catch it at build time instead.
+all: check-test-progs-mode
+
+check-test-progs-mode:
+       @for prog in $(TEST_PROGS); do \
+               test -x "$$prog" || { \
+                       echo "$$prog: in TEST_PROGS but not executable" >&2; \
+                       exit 1; \
+               }; \
+       done
+
+.PHONY: check-test-progs-mode
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/README.rst 
b/tools/testing/selftests/drivers/gpu/drm_fabric/README.rst
new file mode 100644
index 000000000000..6c24581db1f2
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/README.rst
@@ -0,0 +1,136 @@
+.. SPDX-License-Identifier: GPL-2.0
+
+====================
+drm_fabric selftests
+====================
+
+These selftests exercise the ``drm-fabric`` query uAPI against
+``drm_fabric_sim`` using the in-tree YNL library. KUnit covers the core object
+model.
+
+Tree layout
+-----------
+
+``Documentation/netlink/specs/drm_fabric.yaml``
+  Netlink specification and source of truth for generated artifacts.
+
+``include/uapi/drm/drm_fabric.h``
+  Generated uAPI header; checked by ``check-spec-regen.sh``.
+
+``drivers/gpu/drm/fabric/drm_fabric_nl.[ch]``
+  Generated kernel policy and operation tables; checked by
+  ``check-spec-regen.sh``.
+
+Suites
+------
+
+``check-spec-regen.sh``
+  Regenerates each artifact from the YAML spec and asserts an exact match.
+
+``fabric_abi.py``
+  Queries, events and topology.
+
+``nl_policy_probe.py``
+  Raw Generic Netlink policy probes and family introspection.
+
+``dump_intr_abi.py``
+  ``NLM_F_DUMP_INTR`` on a generation bump mid-dump, and on ``NLMSG_DONE``
+  when the bump lands after the last entry.
+
+``port_cursor_abi.py``
+  Nested port-dump cursor (``cb->args``) across endpoints under removal.
+
+``port_stats_cap_abi.py``
+  Heterogeneous per-port stats: mid-list ``-EOPNOTSUPP`` skipped, other errno
+  ends the dump.
+
+``hotplug_abi.py``
+  Endpoint hotplug: CREATE/DELETE notifications.
+
+``dump_scale_abi.py``
+  Dump resume under many endpoints (``bulk_add``).
+
+``switch_abi.py``
+  Opaque switch peers whose identifiers do not resolve to an endpoint
+  (``topology=switch``).
+
+``fault_abi.py``
+  Provider fault injection: errno propagation and no leaked endpoint
+  (``fail_*``).
+
+``harness_reset_abi.py``
+  Recovery after a SIGKILL-terminated predecessor.
+
+``lib_drm_fabric.py``
+  Shared helpers.
+
+Expected skips
+--------------
+
+A SKIP means a required precondition was unavailable.
+
+Environment
+  ``check-spec-regen.sh`` needs PyYAML and writable temporary storage.
+
+Per case
+  A case skips when a required control, parameter or family capability is
+  unavailable.
+
+Whole suite
+  A program skips when it cannot establish its initial topology.
+
+Timing
+  The two ``dump_intr_abi.py`` boundary cases may skip if the concurrent
+  topology change misses the required dump boundary.
+
+KUnit
+-----
+
+Keep the source tree free of ``.config`` and use an object directory:
+
+.. code-block:: sh
+
+   export KBUILD_OUTPUT="$PWD/.kunit/dev-kernel"
+
+.. code-block:: sh
+
+   ./tools/testing/kunit/kunit.py run \
+       --kunitconfig drivers/gpu/drm/fabric/.kunitconfig 'drm_fabric*'
+
+Debug configuration:
+
+.. code-block:: sh
+
+   ./tools/testing/kunit/kunit.py run --arch x86_64 \
+       --kunitconfig drivers/gpu/drm/fabric/.kunitconfig.debug \
+       --timeout 900 --qemu_args '-m 2048' 'drm_fabric*'
+
+KASAN, UBSAN, kmemleak, lockdep or atomic-sleep reports fail the run.
+
+Netlink ABI
+-----------
+
+Needs root and a booted kernel carrying the modules. See ``config`` for the
+Kconfig fragment; the runner applies the 300-second timeout from ``settings``.
+
+.. code-block:: sh
+
+   sudo make -C tools/testing/selftests TARGETS=drivers/gpu/drm_fabric 
run_tests
+
+virtme-ng
+---------
+
+Build out-of-tree, boot with ``vng`` and run the same target in the guest:
+
+.. code-block:: sh
+
+   O=.kunit/vng-drm-fabric
+   vng --kconfig \
+       --config tools/testing/selftests/drivers/gpu/drm_fabric/config "O=$O"
+   make -j"$(nproc)" "O=$O" LOCALVERSION=-virtme
+   vng --run "$O" --user root -- \
+       env FABRIC_DIR="$PWD/$O/drivers/gpu/drm/fabric" \
+       make -C tools/testing/selftests TARGETS=drivers/gpu/drm_fabric run_tests
+
+Dependencies (Debian/Ubuntu): ``python3``, ``python3-yaml``,
+``qemu-system-x86``, ``virtme-ng`` (``pip install --user virtme-ng``).
\ No newline at end of file
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/check-spec-regen.sh 
b/tools/testing/selftests/drivers/gpu/drm_fabric/check-spec-regen.sh
new file mode 100755
index 000000000000..aaf67bd04862
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/check-spec-regen.sh
@@ -0,0 +1,114 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+#
+# Check that every generated drm_fabric netlink artifact matches its spec.
+#
+# Documentation/netlink/specs/drm_fabric.yaml is authoritative for three
+# generated files that must not be hand-edited: the uAPI header and the
+# kernel-side policy and operation tables. Stale kernel tables still build and
+# pass every test here, because those tests exercise whatever family the tables
+# describe. Reconciliation is directory-scoped, so an artifact generated 
outside
+# those three directories is not visible. Host only; skips when the spec, a
+# generated file or the ynl generator (python3 + PyYAML) is missing.
+
+DIR="$(dirname "$(readlink -f "$0")")"
+
+. "${DIR}"/../../../kselftest/ktap_helpers.sh
+
+# Six levels up from this directory is the kernel tree root.
+KDIR="${KDIR:-$(readlink -f "${DIR}/../../../../../..")}"
+
+SPEC="Documentation/netlink/specs/drm_fabric.yaml"
+GEN="tools/net/ynl/pyynl/ynl_gen_c.py"
+[ -f "${KDIR}/${GEN}" ] || GEN="tools/net/ynl/ynl_gen_c.py"
+
+FAB="drivers/gpu/drm/fabric"
+UAPI_H="include/uapi/drm/drm_fabric.h"
+NL_C="${FAB}/drm_fabric_nl.c"
+NL_H="${FAB}/drm_fabric_nl.h"
+
+ktap_print_header
+
+if [ ! -f "${KDIR}/${SPEC}" ] || [ ! -f "${KDIR}/${GEN}" ] || \
+   [ ! -f "${KDIR}/${UAPI_H}" ] || [ ! -f "${KDIR}/${NL_C}" ] || \
+   [ ! -f "${KDIR}/${NL_H}" ]; then
+       ktap_skip_all "drm_fabric spec, a generated file or the ynl generator 
is missing (set KDIR)"
+       exit "${KSFT_SKIP}"
+fi
+
+if ! command -v python3 >/dev/null 2>&1 || ! python3 -c 'import yaml' 
2>/dev/null; then
+       ktap_skip_all "python3 with PyYAML is required"
+       exit "${KSFT_SKIP}"
+fi
+
+# An unwritable tmpdir is an environment limit, not a mismatch: skip.
+if ! tmp=$(mktemp -d 2>/dev/null); then
+       ktap_skip_all "no writable temporary directory"
+       exit "${KSFT_SKIP}"
+fi
+trap 'rm -rf "${tmp}"' EXIT
+
+ktap_set_plan 4
+
+# A generated file carries both a YNL-GEN banner and this spec's path.
+for f in "${UAPI_H}" "${NL_C}" "${NL_H}"; do
+       printf '%s\n' "${f}"
+done | sort >"${tmp}/declared"
+
+sed 's|/[^/]*$||' "${tmp}/declared" | sort -u >"${tmp}/dirs"
+
+: >"${tmp}/found"
+while read -r d; do
+       for f in "${KDIR}/${d}"/*.c "${KDIR}/${d}"/*.h; do
+               [ -f "${f}" ] || continue
+               grep -q '^/\* YNL-GEN ' "${f}" || continue
+               grep -qF -- "${SPEC}" "${f}" || continue
+               printf '%s\n' "${f#"${KDIR}/"}"
+       done
+done <"${tmp}/dirs" | sort >"${tmp}/found"
+
+if diff -u "${tmp}/declared" "${tmp}/found" >"${tmp}/diff"; then
+       ktap_test_pass "generated artifacts in the tree are the ones checked 
here"
+else
+       sed 's/^/# /' "${tmp}/diff"
+       ktap_print_msg "a file generated from ${SPEC} is not on this check's 
list"
+       ktap_test_fail "generated artifacts in the tree are the ones checked 
here"
+fi
+
+# Run from KDIR with a relative spec path so banner and guard match.
+check_generated()
+{
+       committed="$1"
+       mode="$2"
+       kind="$3"
+       name="$4"
+       # Includes derive from the basename; give each its own directory.
+       out_dir="${tmp}/${mode}-${kind}"
+       out="${out_dir}/$(basename "${committed}")"
+       mkdir -p "${out_dir}"
+
+       if ! ( cd "${KDIR}" && python3 "${GEN}" --mode "${mode}" --"${kind}" \
+              --spec "${SPEC}" -o "${out}" ) 2>"${tmp}/err"; then
+               sed 's/^/# /' "${tmp}/err"
+               ktap_test_fail "${name}"
+               return
+       fi
+
+       if diff -u "${KDIR}/${committed}" "${out}" >"${tmp}/diff"; then
+               ktap_test_pass "${name}"
+       else
+               sed 's/^/# /' "${tmp}/diff"
+               ktap_print_msg "regenerate with: tools/net/ynl/ynl-regen.sh -f"
+               ktap_test_fail "${name}"
+       fi
+}
+
+check_generated "${UAPI_H}" uapi header \
+       "drm_fabric uAPI header matches netlink spec"
+check_generated "${NL_C}" kernel source \
+       "drm_fabric netlink ops and policy match netlink spec"
+check_generated "${NL_H}" kernel header \
+       "drm_fabric netlink kernel header matches netlink spec"
+
+ktap_finished
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/config 
b/tools/testing/selftests/drivers/gpu/drm_fabric/config
new file mode 100644
index 000000000000..6eaab8a7d771
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/config
@@ -0,0 +1,8 @@
+# SPDX-License-Identifier: GPL-2.0
+# Kernel config fragment required to run the drm_fabric kselftests.
+# Merge with: scripts/kconfig/merge_config.sh or make kselftest-merge.
+CONFIG_NET=y
+CONFIG_DRM=y
+CONFIG_DEBUG_FS=y
+CONFIG_DRM_FABRIC=m
+CONFIG_DRM_FABRIC_SIM=m
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/dump_intr_abi.py 
b/tools/testing/selftests/drivers/gpu/drm_fabric/dump_intr_abi.py
new file mode 100755
index 000000000000..0f913c38bbc3
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/dump_intr_abi.py
@@ -0,0 +1,361 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+"""
+Tests NLM_F_DUMP_INTR when a topology change lands mid-dump or after the
+last entry. pyynl decodes attributes but never surfaces nlmsg_flags, so
+this talks raw Generic Netlink.
+
+Needs drm_fabric + drm_fabric_sim, fabricsim debugfs (bulk_add), and root.
+"""
+
+import glob
+import os
+import re
+import socket
+import struct
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import lib_drm_fabric as L  # noqa: E402 - shared KTAP/module helpers (no 
pyynl)
+
+# --- Netlink / generic-netlink constants (cf. nl_policy_probe.py) ---------
+
+NETLINK_GENERIC = 16
+NLMSG_ERROR = 0x2
+NLMSG_DONE = 0x3
+NLM_F_REQUEST = 0x01
+NLM_F_MULTI = 0x02
+NLM_F_DUMP = 0x300
+NLM_F_DUMP_INTR = 0x10
+NLMSG_HDRLEN = 16
+GENL_HDRLEN = 4
+CTRL_ID = 0x10
+CTRL_CMD_GETFAMILY = 3
+CTRL_ATTR_FAMILY_NAME = 2
+CTRL_ATTR_FAMILY_ID = 1
+
+# Population large enough that ENDPOINT_GET spans several dump skbs (so there 
is
+# a between-batch window to mutate). Overridable for slow/fast machines.
+SCALE = int(os.environ.get("DUMP_INTR_SCALE", "600"))
+
+
+def _align4(n):
+    return (n + 3) & ~3
+
+
+def _nla(atype, payload):
+    length = 4 + len(payload)
+    pad = b"\x00" * (_align4(length) - length)
+    return struct.pack("=HH", length, atype) + payload + pad
+
+
+def _msg(family_id, cmd, seq, flags, payload=b""):
+    body = struct.pack("=BBH", cmd, 1, 0) + payload
+    total = NLMSG_HDRLEN + len(body)
+    return struct.pack("=IHHII", total, family_id, flags, seq, 0) + body
+
+
+def _open():
+    s = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_GENERIC)
+    s.bind((0, 0))
+    s.settimeout(5)
+    return s
+
+
+def _resolve_family(sock, name):
+    sock.send(_msg(CTRL_ID, CTRL_CMD_GETFAMILY, 1, NLM_F_REQUEST,
+                   _nla(CTRL_ATTR_FAMILY_NAME, name + b"\x00")))
+    data = sock.recv(8192)
+    (_, mtype, _, _, _) = struct.unpack_from("=IHHII", data, 0)
+    if mtype == NLMSG_ERROR:
+        return None
+    off = NLMSG_HDRLEN + GENL_HDRLEN
+    while off + 4 <= len(data):
+        (alen, atype) = struct.unpack_from("=HH", data, off)
+        if alen < 4:
+            break
+        if atype == CTRL_ATTR_FAMILY_ID:
+            # CTRL_ATTR_FAMILY_ID is a u16; tolerate a u32 encoding too.
+            if alen >= 8:
+                return struct.unpack_from("=I", data, off + 4)[0]
+            if alen >= 6:
+                return struct.unpack_from("=H", data, off + 4)[0]
+        off += _align4(alen)
+    return None
+
+
+def _cmd_id(wanted, fallback):
+    """Resolve @wanted from the generated uAPI drm_fabric_cmd enum."""
+    try:
+        text = open(L.UAPI_HEADER).read()
+        m = re.search(r"enum\s+drm_fabric_cmd\s*\{(.*?)\}", text, re.S)
+        if m:
+            n = 0
+            for raw in re.sub(r"/\*.*?\*/", "", m.group(1), 
flags=re.S).split(","):
+                item = raw.strip()
+                if not item:
+                    continue
+                if "=" in item:
+                    name, val = item.split("=", 1)
+                    name, n = name.strip(), int(val.strip(), 0)
+                else:
+                    name = item
+                if name == wanted:
+                    return n
+                n += 1
+    except (OSError, ValueError):
+        pass
+    return fallback
+
+
+def _read_msgs(sock):
+    """Read one dump datagram as (msgs, timed_out): (nlmsg_type, nlmsg_flags)
+    pairs. A timeout is reported explicitly, never conflated with a real
+    NLMSG_DONE.
+    """
+    try:
+        data = sock.recv(65536)
+    except socket.timeout:
+        return [], True
+    msgs, off = [], 0
+    while off + NLMSG_HDRLEN <= len(data):
+        (mlen, mtype, mflags, _, _) = struct.unpack_from("=IHHII", data, off)
+        if mlen < NLMSG_HDRLEN:
+            break
+        msgs.append((mtype, mflags))
+        off += _align4(mlen)
+    return msgs, False
+
+
+def _read_batch(sock):
+    """Read one dump datagram as (list_of_flags, saw_done, saw_error, 
timed_out)."""
+    msgs, timed_out = _read_msgs(sock)
+    if timed_out:
+        return [], False, False, True
+    return ([f for (_, f) in msgs],
+            any(t == NLMSG_DONE for (t, _) in msgs),
+            any(t == NLMSG_ERROR for (t, _) in msgs),
+            False)
+
+
+def _dump(sock, fam, cmd, mutate_after_first=None):
+    """Run an ENDPOINT_GET dump batch-by-batch; returns (batches, intr_seen,
+    err_seen, saw_done, timed_out). @mutate_after_first, if given, runs
+    once between the first and second batch.
+    """
+    sock.send(_msg(fam, cmd, 2, NLM_F_REQUEST | NLM_F_DUMP))
+    batches, intr, err, done, timed_out, mutated = 0, False, False, False, 
False, False
+    while not done:
+        flags, done, e, to = _read_batch(sock)
+        if to:
+            timed_out = True
+            break
+        if not flags:
+            break
+        batches += 1
+        err = err or e
+        if any(f & NLM_F_DUMP_INTR for f in flags):
+            intr = True
+        if mutate_after_first and not mutated:
+            mutate_after_first()
+            mutated = True
+        if batches > 10000:            # runaway guard
+            break
+    return batches, intr, err, done, timed_out
+
+
+class Cfg:
+    def __init__(self, fam, cmd, stats_cmd):
+        self.fam = fam
+        self.cmd = cmd
+        self.stats_cmd = stats_cmd
+
+
+def test_multi_skb_dump_available(ksft, cfg):
+    """Precondition: the population makes ENDPOINT_GET span >1 dump batch."""
+    s = _open()
+    batches, _, err, done, to = _dump(s, cfg.fam, cfg.cmd)
+    s.close()
+    ok = ksft.check(batches >= 2 and not err and done and not to,
+                    "dump-spans-multiple-batches",
+                    "batches=%d err=%s done=%s timeout=%s (raise 
DUMP_INTR_SCALE)"
+                    % (batches, err, done, to))
+    if not ok:
+        cfg.abort = True   # the INTR cases below are meaningless single-batch
+
+
+def test_no_intr_when_quiescent(ksft, cfg):
+    """A quiescent dump must complete with a real NLMSG_DONE (not a socket
+    timeout) and no NLM_F_DUMP_INTR; a stalled dump is a failure, not a
+    silent pass.
+    """
+    s = _open()
+    batches, intr, err, done, to = _dump(s, cfg.fam, cfg.cmd)
+    s.close()
+    ksft.check(done and not to and not intr and not err, 
"quiescent-dump-no-intr",
+               "done=%s timeout=%s intr=%s err=%s batches=%d"
+               % (done, to, intr, err, batches))
+
+
+def _find_oper_state():
+    """A fabricsim per-port oper_state debugfs knob, if any (baseline 
ports)."""
+    m = glob.glob(os.path.join(L.DEBUGFS, "*", "*", "oper_state"))
+    return m[0] if m else None
+
+
+def test_intr_on_mutation_mid_dump(ksft, cfg):
+    """A base_seq bump during a multi-skb dump must raise NLM_F_DUMP_INTR.
+
+    oper_state writes are synchronous, unlike bulk_add, so the generation
+    changes before netlink's one-skb-ahead prefill snapshots it.
+    """
+    oper = _find_oper_state()
+    if not oper:
+        ksft.skip("mutation-mid-dump-sets-intr", "no fabricsim oper_state 
knob")
+        return
+    rel = os.path.relpath(oper, L.DEBUGFS)
+    states = ("active", "degraded")
+
+    s = _open()
+    s.send(_msg(cfg.fam, cfg.cmd, 3, NLM_F_REQUEST | NLM_F_DUMP))
+    intr = err = timed_out = False
+    batches = i = 0
+    done = False
+    while not done:
+        flags, done, e, to = _read_batch(s)
+        if to:
+            timed_out = True
+            break
+        if not flags:
+            break
+        batches += 1
+        err = err or e
+        if any(f & NLM_F_DUMP_INTR for f in flags):
+            intr = True
+        # Synchronous generation bump between batches (state must change to
+        # take effect, so alternate the two values).
+        try:
+            L.dbg_write(rel, states[i % 2])
+            i += 1
+        except OSError:
+            pass
+        if batches > 10000:
+            break
+    s.close()
+
+    # The dump must both observe the interruption and still terminate cleanly
+    # (a real NLMSG_DONE, not a stall).
+    ksft.check(intr and done and not timed_out and not err,
+               "mutation-mid-dump-sets-intr",
+               "intr=%s done=%s timeout=%s err=%s batches=%d"
+               % (intr, done, timed_out, err, batches))
+
+
+def test_intr_on_post_exhaustion_mutation(ksft, cfg):
+    """A topology change after the final entry must still be reported on
+    NLMSG_DONE.
+
+    A dump handler that samples the generation only once it has a record in
+    hand leaves a hole: a batch that finds the cursor already exhausted emits
+    nothing, so it never samples, and NLMSG_DONE goes out carrying the
+    generation from the previous batch.
+    """
+    s = _open()
+    s.send(_msg(cfg.fam, cfg.stats_cmd, 4, NLM_F_REQUEST | NLM_F_DUMP))
+
+    name = "post-exhaustion-mutation-sets-intr"
+    try:
+        msgs, timed_out = _read_msgs(s)
+        entries = sum(1 for (t, _) in msgs if t == cfg.fam)
+        if timed_out or not entries or any(t == NLMSG_DONE for (t, _) in msgs):
+            ksft.skip(name, "PORT_STATS_GET did not park mid-dump "
+                            "(entries=%d timeout=%s)" % (entries, timed_out))
+            return
+
+        # Retire everything ahead of the cursor while the dump is parked. The
+        # dump only advances when we read, so settling here cannot let it run
+        # past the mutation.
+        try:
+            L.dbg_write("bulk_del", 0)
+        except OSError as exc:
+            ksft.skip(name, "bulk_del failed: %s" % exc)
+            return
+        L.settle(0.3)
+
+        # Netlink prefills one skb ahead, so the next read still delivers
+        # entries serialized before the mutation. Those carry the old
+        # generation and leave the pending inconsistency untouched.
+        done_flags, entry_intr, err = None, False, False
+        for _ in range(10000):
+            msgs, timed_out = _read_msgs(s)
+            if timed_out or not msgs:
+                break
+            for (mtype, mflags) in msgs:
+                if mtype == cfg.fam:
+                    entry_intr = entry_intr or bool(mflags & NLM_F_DUMP_INTR)
+                elif mtype == NLMSG_ERROR:
+                    err = True
+                elif mtype == NLMSG_DONE:
+                    done_flags = mflags
+            if done_flags is not None:
+                break
+
+        # An entry that already carried the flag means the interruption was
+        # reported mid-dump and the consistency check reset with it, so
+        # NLMSG_DONE need not repeat it. That is the mid-dump path, covered by
+        # the case above, and it cannot stand in for this one.
+        if entry_intr:
+            ksft.skip(name, "interruption reported on an entry; the "
+                            "exhaustion path was not isolated")
+            return
+
+        ksft.check(done_flags is not None and
+                   bool(done_flags & NLM_F_DUMP_INTR) and not err, name,
+                   "done_flags=%s err=%s"
+                   % ("none" if done_flags is None else hex(done_flags), err))
+    finally:
+        s.close()
+        # Restore the population for whatever runs next.
+        try:
+            L.dbg_write("bulk_add", SCALE)
+        except OSError:
+            pass
+        L.settle(0.3)
+
+
+CASES = (
+    test_multi_skb_dump_available,
+    test_no_intr_when_quiescent,
+    test_intr_on_mutation_mid_dump,
+    test_intr_on_post_exhaustion_mutation,
+)
+
+
+def main():
+    ksft = L.Ksft()
+
+    with L.fabricsim(ksft, need_debugfs=True, need_control="bulk_add",
+                     open_family=False):
+        sock = _open()
+        fam = _resolve_family(sock, L.FAMILY.encode())
+        sock.close()
+        if not fam:
+            ksft.skip_all("cannot resolve drm-fabric genl family")
+
+        # Grow the population so the dump pages across several skbs.
+        try:
+            L.dbg_write("bulk_add", SCALE)
+        except OSError as exc:
+            ksft.skip_all("bulk_add failed: %s" % exc)
+        L.settle(0.3)
+
+        cfg = Cfg(fam,
+                  _cmd_id("DRM_FABRIC_CMD_ENDPOINT_GET", 2),
+                  _cmd_id("DRM_FABRIC_CMD_PORT_STATS_GET", 4))
+        L.run_cases(ksft, cfg, CASES)
+    ksft.finish()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/dump_scale_abi.py 
b/tools/testing/selftests/drivers/gpu/drm_fabric/dump_scale_abi.py
new file mode 100755
index 000000000000..fedf35f51eff
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/dump_scale_abi.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+"""
+Forces netlink dump pagination (cb->args) with fabricsim's bulk_add/bulk_del,
+past what the small object counts in other suites would ever trigger, and
+verifies every dump returns the full set exactly once, including under
+concurrent churn.
+
+Requires drm_fabric + drm_fabric_sim with fabricsim debugfs; run as root.
+"""
+
+import os
+import sys
+import threading
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import lib_drm_fabric as L
+
+# Enough endpoints that the ENDPOINT_GET reply spans several skbs (each entry
+# carries id/fabric-id/fabric-ep-id/name/dev-name/bus-name/admin nests).
+SCALE = int(os.environ.get("DUMP_SCALE", "300"))
+
+
+def ep_ids(fab):
+    return [e["endpoint"]["endpoint-id"] for e in fab.dump("endpoint-get", {})]
+
+
+def port_count(fab):
+    return len(list(fab.dump("port-get", {})))
+
+
+class Cfg:
+    """Carries the pre-scale baseline counts so the teardown case can assert
+    the population is restored exactly."""
+
+    def __init__(self, fab, nl_error, base_ids, base_ports, fid):
+        self.fab = fab
+        self.NlError = nl_error
+        self.base_ids = base_ids
+        self.base_n = len(base_ids)
+        self.base_ports = base_ports
+        self.fid = fid
+
+
+def test_scale_dump(ksft, cfg):
+    fab = cfg.fab
+    try:
+        L.dbg_write("bulk_add", SCALE)
+    except OSError as exc:
+        ksft.skip_all("bulk_add failed: %s" % exc)
+    L.wait_until(lambda: len(ep_ids(fab)) >= cfg.base_n + SCALE, timeout=10)
+
+    ids = ep_ids(fab)
+    ksft.check(len(ids) == cfg.base_n + SCALE, "scale-endpoint-dump-count",
+               "want %d got %d" % (cfg.base_n + SCALE, len(ids)))
+    ksft.check(len(set(ids)) == len(ids), "scale-endpoint-dump-unique",
+               "%d dups" % (len(ids) - len(set(ids))))
+    ksft.check(set(cfg.base_ids).issubset(set(ids)),
+               "scale-endpoint-dump-keeps-baseline")
+
+    pc = port_count(fab)
+    ksft.check(pc == cfg.base_ports + SCALE, "scale-port-dump-count",
+               "want %d got %d" % (cfg.base_ports + SCALE, pc))
+
+    if cfg.fid is not None:
+        flt = [e["endpoint"] for e in fab.dump("endpoint-get",
+                                               {"fabric-id": cfg.fid})]
+        ksft.check(all(e.get("fabric-id") == cfg.fid for e in flt) and
+                   len(flt) >= SCALE, "scale-endpoint-dump-filtered",
+                   "got %d members" % len(flt))
+    else:
+        ksft.skip("scale-endpoint-dump-filtered", "fabricsim fabric absent")
+
+
+def test_dump_consistency_under_churn(ksft, cfg):
+    """Hammer multi-skb dumps while a helper thread churns the population."""
+    fab = cfg.fab
+    pre_churn = len(ep_ids(fab))
+    L.dbg_write("bulk_add", SCALE)
+    L.wait_until(lambda: len(ep_ids(fab)) > pre_churn, timeout=10)
+    stop = threading.Event()
+    churn_err = []
+
+    def churn():
+        while not stop.is_set():
+            try:
+                L.dbg_write("bulk_del", 0)
+                # Re-check before re-populating so the last iteration does not
+                # add a fresh SCALE population that would race the teardown.
+                if stop.is_set():
+                    break
+                L.dbg_write("bulk_add", SCALE)
+            except OSError:
+                # racy debugfs writes may transiently fail; not a dump bug
+                pass
+            except Exception as exc:  # noqa: BLE001
+                churn_err.append(str(exc))
+                return
+
+    worst_dups = 0
+    dump_err = None
+    # The join below is the real synchronization point; the case aborts if it
+    # times out. Mark the worker daemon so a wedged iteration cannot also hang
+    # interpreter shutdown after that failure has already been reported.
+    t = threading.Thread(target=churn, daemon=True)
+    t.start()
+    try:
+        for _ in range(60):
+            d = ep_ids(fab)
+            worst_dups = max(worst_dups, len(d) - len(set(d)))
+    except Exception as exc:  # noqa: BLE001
+        dump_err = str(exc)
+    finally:
+        stop.set()
+        t.join(timeout=30)          # each churn iteration is bounded
+
+    # A worker that will not terminate is a harness failure, not something to
+    # leave running into the next case; abort so the teardown case cannot race
+    # an in-flight bulk_add.
+    if not ksft.check(not t.is_alive(), "dump-churn-worker-terminates",
+                      "churn worker still alive after join"):
+        cfg.abort = True
+        return
+
+    # Assert *no duplicates* (the invariant a broken cb->args resume would
+    # violate), not *no omissions*: under concurrent churn the population
+    # legitimately changes between skbs, so a missing id is expected here and
+    # only a duplicated id signals a dump-resume bug.
+    ksft.check(worst_dups == 0 and dump_err is None and not churn_err,
+               "dump-consistency-no-dup-under-churn",
+               "dups=%d dump_err=%s churn_err=%s"
+               % (worst_dups, dump_err, churn_err[:1]))
+
+    # Worker has exited: drain the churn population back to baseline so the
+    # teardown case (and the next suite) starts from a known, quiescent count.
+    L.dbg_write("bulk_del", 0)
+    L.wait_until(lambda: len(ep_ids(fab)) == cfg.base_n, timeout=10)
+
+
+def test_scale_teardown(ksft, cfg):
+    fab = cfg.fab
+    try:
+        L.dbg_write("bulk_del", 1)
+    except OSError as exc:
+        ksft.not_ok("scale-teardown", "bulk_del failed: %s" % exc)
+        return
+    L.wait_until(lambda: len(ep_ids(fab)) == cfg.base_n, timeout=10)
+    ksft.check(len(ep_ids(fab)) == cfg.base_n, 
"scale-teardown-restores-baseline",
+               "want %d got %d" % (cfg.base_n, len(ep_ids(fab))))
+
+
+CASES = (
+    test_scale_dump,
+    test_dump_consistency_under_churn,
+    test_scale_teardown,
+)
+
+
+def main():
+    ksft = L.Ksft()
+    _, NlError = L.import_ynl()
+
+    with L.fabricsim(ksft, need_debugfs=True, need_control="bulk_add") as fab:
+        base_ids = ep_ids(fab)
+        base_ports = port_count(fab)
+        fid = None
+        for f in fab.dump("fabric-get", {}):
+            if f["fabric"]["name"] == "fabricsim":
+                fid = f["fabric"]["fabric-id"]
+                break
+
+        L.run_cases(ksft, Cfg(fab, NlError, base_ids, base_ports, fid), CASES)
+    ksft.finish()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/fabric_abi.py 
b/tools/testing/selftests/drivers/gpu/drm_fabric/fabric_abi.py
new file mode 100755
index 000000000000..94cc1078a365
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/fabric_abi.py
@@ -0,0 +1,401 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+"""
+Full drm-fabric genetlink ABI coverage via YNL against fabricsim: every
+command (do + dump) and event, asserted on decoded reply dicts so checks
+are immune to CLI text changes.
+
+Usage: fabric_abi.py [--no-load]   (--no-load: modules already loaded)
+"""
+
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import lib_drm_fabric as L
+
+EVT_DURATION = float(os.environ.get("EVT_DURATION", "3"))
+# Multicast subscription is synchronous (setsockopt), so a brief settle before
+# triggering is enough; wait_ntf() then polls with a deadline for the arrival.
+EVT_SETTLE = float(os.environ.get("EVT_SETTLE", "0.2"))
+
+
+def _unload_providers():
+    L.rmmod("drm_fabric_sim")
+    L.rmmod("drm_fabric")
+
+
+def fabric_names(fab):
+    return [e["fabric"]["name"] for e in fab.dump("fabric-get", {})]
+
+
+def ep_list(fab, filt=None):
+    return [e["endpoint"] for e in fab.dump("endpoint-get", filt or {})]
+
+
+def port_list(fab, ep_id):
+    return [p["port"] for p in fab.dump("port-get", {"endpoint-id": ep_id})]
+
+
+def peers_in(ports):
+    return [p["peer"]["peer-id"] for p in ports if "peer" in p]
+
+
+def _reload_sim(shape):
+    L.rmmod("drm_fabric_sim")
+    L.insmod("drm-fabric-sim.ko", "topology=%s" % shape)
+    L.wait_until(lambda: L.module_loaded("drm_fabric_sim"))
+
+
+class Cfg:
+    def __init__(self, fab, fid, dfs, no_load, nl_error):
+        self.fab = fab
+        self.fid = fid
+        self.dfs = dfs
+        self.no_load = no_load
+        self.NlError = nl_error
+
+
+def test_fabric_get(ksft, cfg):
+    fab = cfg.fab
+    ksft.check("fabricsim" in fabric_names(fab), "fabric-get-dump")
+    f = fab.do("fabric-get", {"fabric-id": cfg.fid})
+    ksft.check(f["fabric"]["name"] == "fabricsim", "fabric-get-do")
+
+
+def test_endpoint_get_dump(ksft, cfg):
+    fab = cfg.fab
+    eps = ep_list(fab)
+    ksft.check(len(eps) == 4, "endpoint-get-dump", "got %d" % len(eps))
+    epf = ep_list(fab, {"fabric-id": cfg.fid})
+    ksft.check(len(epf) == 4, "endpoint-get-dump-filtered", "got %d" % 
len(epf))
+
+
+def test_endpoint_get_do(ksft, cfg):
+    fab = cfg.fab
+    e0 = fab.do("endpoint-get", {"endpoint-id": 0})
+    ksft.check(e0["endpoint"]["name"] == "sim-ep0", "endpoint-get-do")
+    e = fab.do("endpoint-get", {"dev-name": "fabricsim.0"})
+    ksft.check(e["endpoint"]["name"] == "sim-ep0", 
"endpoint-get-do-by-dev-name")
+    e = fab.do("endpoint-get", {"dev-name": "fabricsim.1", "bus-name": 
"platform"})
+    ksft.check(e["endpoint"]["name"] == "sim-ep1",
+               "endpoint-get-do-by-dev-name-bus")
+    e = fab.do("endpoint-get", {"endpoint-id": 0, "dev-name": "fabricsim.0"})
+    ksft.check(e["endpoint"]["name"] == "sim-ep0",
+               "endpoint-get-do-id-and-dev-name-agree")
+
+
+def test_endpoint_get_do_errors(ksft, cfg):
+    fab, NlError = cfg.fab, cfg.NlError
+    try:
+        fab.do("endpoint-get", {"endpoint-id": 0, "dev-name": "fabricsim.1"})
+        ksft.not_ok("endpoint-get-do-id-dev-name-conflict-einval", "accepted")
+    except NlError as exc:
+        ksft.check(L.nl_errno(exc) == 22,  # EINVAL
+                   "endpoint-get-do-id-dev-name-conflict-einval",
+                   "errno=%d" % L.nl_errno(exc))
+    try:
+        fab.do("endpoint-get", {"bus-name": "platform"})
+        ksft.not_ok("endpoint-get-do-bus-name-only-einval", "accepted")
+    except NlError as exc:
+        ksft.check(L.nl_errno(exc) == 22, 
"endpoint-get-do-bus-name-only-einval",
+                   "errno=%d" % L.nl_errno(exc))
+    try:
+        fab.do("endpoint-get", {"dev-name": "nope.99"})
+        ksft.not_ok("endpoint-get-do-dev-name-enoent", "accepted")
+    except NlError as exc:
+        ksft.check(L.nl_errno(exc) == 2, "endpoint-get-do-dev-name-enoent",
+                   "errno=%d" % L.nl_errno(exc))
+
+
+def test_port_get(ksft, cfg):
+    fab = cfg.fab
+    p0 = port_list(fab, 0)
+    ksft.check(len(p0) == 4, "port-get-dump", "got %d" % len(p0))
+    port = fab.do("port-get", {"endpoint-id": 0, "port-index": 0})["port"]
+    ksft.check(port["oper-state"] == "active" and "peer" in port, 
"port-get-do")
+    ksft.check(port.get("peer", {}).get("peer-id") == 257,
+               "port-get-peer-data")
+
+
+def test_port_stats(ksft, cfg):
+    fab = cfg.fab
+    st = fab.do("port-stats-get", {"endpoint-id": 0, "port-index": 
0})["port-stats"]
+    ksft.check(st.get("read-bytes", -1) == 0 and st.get("write-bytes", -1) == 
0,
+               "port-stats-get-do")
+    stats = fab.dump("port-stats-get", {"endpoint-id": 0})
+    ksft.check(len(stats) == 4, "port-stats-get-dump", "got %d" % len(stats))
+
+
+def test_activity_stats(ksft, cfg):
+    if not cfg.dfs:
+        ksft.skip("activity-stats-increment", "debugfs not available")
+        return
+    fab = cfg.fab
+    L.dbg_write("ep0/port0/read_rate", 1024)
+    L.dbg_write("ep0/port0/write_rate", 512)
+    L.dbg_write("ep0/port0/activity_enable", 1)
+
+    def _stats():
+        return fab.do("port-stats-get",
+                      {"endpoint-id": 0, "port-index": 0})["port-stats"]
+
+    L.wait_until(lambda: _stats().get("read-bytes", 0) > 0 and
+                 _stats().get("write-bytes", 0) > 0)
+    L.dbg_write("ep0/port0/activity_enable", 0)
+    s = _stats()
+    ksft.check(s.get("read-bytes", 0) > 0 and s.get("write-bytes", 0) > 0,
+               "activity-stats-increment",
+               "read=%s write=%s" % (s.get("read-bytes"), 
s.get("write-bytes")))
+
+
+def test_inject_link_down(ksft, cfg):
+    if not cfg.dfs:
+        ksft.skip("inject-link-down-oper-inactive", "debugfs not available")
+        return
+    fab = cfg.fab
+    L.dbg_write("ep1/port0/inject", "link_down")
+    p = fab.do("port-get", {"endpoint-id": 1, "port-index": 0})["port"]
+    ksft.check(p["oper-state"] == "inactive", "inject-link-down-oper-inactive",
+               "oper=%s" % p["oper-state"])
+    L.dbg_write("ep1/port0/inject", "recover_to_active")
+
+
+def test_oper_state_degraded(ksft, cfg):
+    if not cfg.dfs:
+        ksft.skip("debugfs-oper-state-degraded", "debugfs not available")
+        return
+    fab = cfg.fab
+    L.dbg_write("ep2/port0/oper_state", "degraded")
+    p = fab.do("port-get", {"endpoint-id": 2, "port-index": 0})["port"]
+    ksft.check(p["oper-state"] == "degraded", "debugfs-oper-state-degraded",
+               "oper=%s" % p["oper-state"])
+    L.dbg_write("ep2/port0/oper_state", "active")
+
+
+def test_topology_kn(ksft, cfg):
+    fab = cfg.fab
+    peers = set(peers_in(port_list(fab, 0)))
+    ksft.check(len(peers) == 3, "topology-kn-distinct-peers",
+               "distinct peers=%d" % len(peers))
+    p = fab.do("port-get", {"endpoint-id": 1, "port-index": 0})["port"]
+    ksft.check(p.get("peer", {}).get("peer-id") == 256,
+               "topology-kn-bidirectional")
+    p = fab.do("port-get", {"endpoint-id": 0, "port-index": 3})["port"]
+    ksft.check("peer" not in p, "port-no-peer")
+
+
+def test_counters_stop(ksft, cfg):
+    if not cfg.dfs:
+        ksft.skip("counters-stop-after-disable", "debugfs not available")
+        return
+    fab = cfg.fab
+
+    def _rb():
+        return fab.do("port-stats-get",
+                      {"endpoint-id": 0, "port-index": 
0})["port-stats"]["read-bytes"]
+
+    r1 = _rb()
+    # Proving a *non-event* (counters must NOT advance after disable) needs a
+    # real wait; poll a bounded window and assert the value never moved.
+    moved = L.wait_until(lambda: _rb() != r1, timeout=0.5)
+    ksft.check(not moved, "counters-stop-after-disable",
+               "read-bytes moved from %s to %s" % (r1, _rb()))
+
+
+def test_port_state_cycle(ksft, cfg):
+    if not cfg.dfs:
+        ksft.skip("port-state-inject-cycle", "debugfs not available")
+        return
+    fab = cfg.fab
+    L.dbg_write("ep2/port1/oper_state", "active")
+    L.dbg_write("ep2/port1/inject", "degrade")
+    s1 = fab.do("port-get", {"endpoint-id": 2, "port-index": 
1})["port"]["oper-state"]
+    L.dbg_write("ep2/port1/inject", "link_down")
+    s2 = fab.do("port-get", {"endpoint-id": 2, "port-index": 
1})["port"]["oper-state"]
+    L.dbg_write("ep2/port1/inject", "recover_to_active")
+    s3 = fab.do("port-get", {"endpoint-id": 2, "port-index": 
1})["port"]["oper-state"]
+    ksft.check(s1 == "degraded" and s2 == "inactive" and s3 == "active",
+               "port-state-inject-cycle", "%s,%s,%s" % (s1, s2, s3))
+
+
+def test_port_change_ntf(ksft, cfg):
+    if not cfg.dfs:
+        ksft.skip("port-change-ntf-notification", "debugfs not available")
+        return
+    fab = cfg.fab
+    L.dbg_write("ep1/port1/oper_state", "active")
+    ev = L.DrmFabric()
+    ev.ntf_subscribe(L.MCAST_MONITOR)
+    L.settle(EVT_SETTLE)
+    L.dbg_write("ep1/port1/oper_state", "degraded")
+    got = L.wait_ntf(ev, "port-change-ntf", timeout=EVT_DURATION,
+                     match=lambda n: n["msg"]["port"].get("oper-state") == 
"degraded")
+    ksft.check(got is not None, "port-change-ntf-notification")
+    # The event carries the post-change topology-generation (nonzero); a
+    # subsequent GET reports a generation that is >= the event's.
+    if got is not None:
+        egen = got["msg"].get("topology-generation")
+        ggen = fab.do("port-get",
+                      {"endpoint-id": 1, "port-index": 
1}).get("topology-generation")
+        ksft.check(egen is not None and egen != 0 and
+                   ggen is not None and ggen >= egen,
+                   "port-change-ntf-topology-generation",
+                   "event=%s get=%s" % (egen, ggen))
+    L.dbg_write("ep1/port1/oper_state", "active")
+
+
+def test_linear_topology(ksft, cfg):
+    """Reload the sim into the linear topology and assert the chain shape.
+    Restores the default mesh K_4 on the way out (even on failure), so
+    this cannot cascade into later cases that assume the default topology.
+    """
+    if cfg.no_load:
+        ksft.skip("linear-topology-chain", "skipped with --no-load")
+        ksft.skip("linear-topology-adjacent-peers", "skipped with --no-load")
+        ksft.skip("linear-topology-end-no-extra-peer", "skipped with 
--no-load")
+        ksft.skip("linear-topology-debugfs-works",
+                  "skipped with --no-load or no debugfs")
+        return
+    fab = cfg.fab
+    _reload_sim("linear")
+    try:
+        n0 = len(peers_in(port_list(fab, 0)))
+        n1 = len(peers_in(port_list(fab, 1)))
+        n3 = len(peers_in(port_list(fab, 3)))
+        ksft.check(n0 == 1 and n1 == 2 and n3 == 1, "linear-topology-chain",
+                   "ep0=%d ep1=%d ep3=%d" % (n0, n1, n3))
+        ps = peers_in(port_list(fab, 0))
+        ksft.check(ps and ps[0] == 257, "linear-topology-adjacent-peers",
+                   "ep0 peers=%s" % ps)
+        p = fab.do("port-get", {"endpoint-id": 0, "port-index": 1})["port"]
+        ksft.check("peer" not in p, "linear-topology-end-no-extra-peer")
+        if cfg.dfs:
+            L.dbg_write("ep0/port0/oper_state", "degraded")
+            p = fab.do("port-get", {"endpoint-id": 0, "port-index": 0})["port"]
+            ksft.check(p["oper-state"] == "degraded",
+                       "linear-topology-debugfs-works")
+            L.dbg_write("ep0/port0/oper_state", "active")
+        else:
+            ksft.skip("linear-topology-debugfs-works",
+                      "skipped with --no-load or no debugfs")
+    finally:
+        # Always return to the default mesh K_4 shape for the cases that 
follow.
+        _reload_sim("mesh")
+
+
+def test_reload_mesh(ksft, cfg):
+    """Defensively re-establish the default mesh K_N topology (idempotent)
+    and assert it, guaranteeing the precondition for the RAS/NTF cases
+    that follow even if an earlier reload failed.
+    """
+    if cfg.no_load:
+        ksft.skip("reload-mesh-topology-restored", "skipped with --no-load")
+        return
+    fab = cfg.fab
+    _reload_sim("mesh")
+    n0 = len(peers_in(port_list(fab, 0)))
+    ksft.check(n0 == 3, "reload-mesh-topology-restored", "ep0 peers=%d" % n0)
+
+
+def test_port_change_ntf_full(ksft, cfg):
+    if not cfg.dfs:
+        ksft.skip("port-change-ntf-full-port-nest", "debugfs not available")
+        return
+    L.dbg_write("ep0/port0/oper_state", "active")
+    ev = L.DrmFabric()
+    ev.ntf_subscribe(L.MCAST_MONITOR)
+    L.settle(EVT_SETTLE)
+    L.dbg_write("ep0/port0/oper_state", "degraded")
+    got = L.wait_ntf(
+        ev, "port-change-ntf", timeout=EVT_DURATION,
+        match=lambda n: "endpoint-id" in n["msg"]["port"] and
+        "port-index" in n["msg"]["port"])
+    ksft.check(got is not None, "port-change-ntf-full-port-nest")
+    L.dbg_write("ep0/port0/oper_state", "active")
+
+
+def test_link_down_exact_count(ksft, cfg):
+    if not cfg.dfs:
+        ksft.skip("inject-link-down-exact-count", "debugfs not available")
+        return
+    fab = cfg.fab
+    base = fab.do("port-stats-get",
+                  {"endpoint-id": 3, "port-index": 1})["port-stats"]
+    c0 = base.get("link-down-count", 0)
+    for _ in range(3):
+        L.dbg_write("ep3/port1/inject", "link_down")
+    s = fab.do("port-stats-get",
+               {"endpoint-id": 3, "port-index": 1})["port-stats"]
+    ksft.check(s.get("link-down-count", 0) == c0 + 3,
+               "inject-link-down-exact-count",
+               "expected %d got %s" % (c0 + 3, s.get("link-down-count")))
+    L.dbg_write("ep3/port1/inject", "recover_to_active")
+
+
+# Ordered scenario: each case builds on the topology/state left by the prior
+# one (e.g. the linear reload precedes its assertions, and the mesh reload
+# restores K_N for the stats cases). Keep this list in order.
+CASES = (
+    test_fabric_get,
+    test_endpoint_get_dump,
+    test_endpoint_get_do,
+    test_endpoint_get_do_errors,
+    test_port_get,
+    test_port_stats,
+    test_activity_stats,
+    test_inject_link_down,
+    test_oper_state_degraded,
+    test_topology_kn,
+    test_counters_stop,
+    test_port_state_cycle,
+    test_port_change_ntf,
+    test_linear_topology,
+    test_reload_mesh,
+    test_port_change_ntf_full,
+    test_link_down_exact_count,
+)
+
+
+def main():
+    ksft = L.Ksft()
+    _, NlError = L.import_ynl()
+
+    if not L.is_root():
+        ksft.skip_all("must run as root (genetlink + debugfs + insmod)")
+
+    no_load = "--no-load" in sys.argv[1:]
+
+    if not no_load:
+        L.rmmod("drm_fabric_sim")
+        L.rmmod("drm_fabric")
+        # Arm teardown before loading so a partial load is unwound, and so the
+        # topology reshapes this suite performs are restored even if the 
timeout
+        # killer sends SIGTERM (which a bare atexit would miss).
+        L.on_teardown(_unload_providers)
+        if not L.insmod("drm-fabric.ko") or not L.insmod("drm-fabric-sim.ko"):
+            ksft.skip_all("could not load drm_fabric + drm_fabric_sim modules")
+        L.wait_until(lambda: L.module_loaded("drm_fabric_sim"))
+
+    if not L.module_loaded("drm_fabric"):
+        ksft.skip_all("drm_fabric not loaded")
+    if not L.module_loaded("drm_fabric_sim"):
+        ksft.skip_all("drm_fabric_sim not loaded")
+
+    try:
+        fab = L.DrmFabric()
+    except (OSError, NlError) as exc:
+        ksft.skip_all("cannot open drm-fabric family: %s" % exc)
+
+    # fabric-id 0 is reserved; discover the live provider fabric id.
+    fabrics = fab.dump("fabric-get", {})
+    fid = fabrics[0]["fabric"]["fabric-id"] if fabrics else 1
+
+    cfg = Cfg(fab, fid, L.debugfs_available(), no_load, NlError)
+    L.run_cases(ksft, cfg, CASES)
+    ksft.finish()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/fault_abi.py 
b/tools/testing/selftests/drivers/gpu/drm_fabric/fault_abi.py
new file mode 100755
index 000000000000..8ea2d1de93d7
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/fault_abi.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+"""
+Provider fault injection via fabricsim's fail_register debugfs toggle (cf.
+netdevsim's should_fail): a failed provider-driven endpoint create must
+surface the provider's errno and leak no endpoint, succeeding once the
+fault is cleared.
+
+Requires drm_fabric + drm_fabric_sim with fabricsim debugfs; run as root.
+"""
+
+import errno
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import lib_drm_fabric as L
+
+
+def eps_by_name(fab):
+    return {e["endpoint"]["name"]: e["endpoint"]
+            for e in fab.dump("endpoint-get", {})}
+
+
+def slot_of(name):
+    return int(name.rsplit("ep", 1)[1])
+
+
+def add_via(fab, control, nports=1):
+    """Drive a debugfs hotplug-add; return the new endpoint dict (or None)."""
+    before = set(eps_by_name(fab))
+    L.dbg_write(control, nports)
+    new = L.wait_until(lambda: set(eps_by_name(fab)) - before)
+    return eps_by_name(fab)[next(iter(new))] if len(new) == 1 else None
+
+
+def del_via(fab, name, slot):
+    L.dbg_write("del_endpoint", slot)
+    L.wait_until(lambda: name not in eps_by_name(fab))
+
+
+def fabricsim_fid(fab):
+    for f in fab.dump("fabric-get", {}):
+        if f["fabric"]["name"] == "fabricsim":
+            return f["fabric"]["fabric-id"]
+    return None
+
+
+def set_fault(name, on):
+    L.dbg_write(name, "Y" if on else "N")
+
+
+class Cfg:
+    def __init__(self, fab, fid):
+        self.fab = fab
+        self.fid = fid
+
+
+def test_register_fault(ksft, cfg):
+    """A failed provider create surfaces -ENOMEM and leaks no endpoint."""
+    fab = cfg.fab
+    n_before = len(eps_by_name(fab))
+    set_fault("fail_register", True)
+    try:
+        reg_errno = None
+        try:
+            L.dbg_write("add_endpoint", 1)
+        except OSError as exc:
+            reg_errno = exc.errno
+        # Confirming a non-event needs a bounded wait: poll a short window for 
a
+        # late endpoint after the synchronous -ENOMEM.
+        grew = L.wait_until(lambda: len(eps_by_name(fab)) != n_before, 
timeout=0.3)
+        ksft.check(reg_errno == errno.ENOMEM, "fault-register-returns-enomem",
+                   "errno=%s" % reg_errno)
+        ksft.check(not grew and len(eps_by_name(fab)) == n_before,
+                   "fault-register-no-leak",
+                   "count changed %d -> %d" % (n_before, 
len(eps_by_name(fab))))
+    finally:
+        set_fault("fail_register", False)
+    created = add_via(fab, "add_endpoint", nports=1)
+    ksft.check(created is not None, "fault-cleared-register-ok")
+    if created is not None:
+        del_via(fab, created["name"], slot_of(created["name"]))
+
+
+CASES = (
+    test_register_fault,
+)
+
+
+def main():
+    ksft = L.Ksft()
+
+    with L.fabricsim(ksft, need_debugfs=True, need_control="fail_register") as 
fab:
+        fid = fabricsim_fid(fab)
+        if fid is None:
+            ksft.skip_all("fabricsim fabric not present")
+
+        L.run_cases(ksft, Cfg(fab, fid), CASES)
+    ksft.finish()
+
+
+if __name__ == "__main__":
+    main()
diff --git 
a/tools/testing/selftests/drivers/gpu/drm_fabric/harness_reset_abi.py 
b/tools/testing/selftests/drivers/gpu/drm_fabric/harness_reset_abi.py
new file mode 100755
index 000000000000..211613185a39
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/harness_reset_abi.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+"""
+Verify recovery after a SIGKILL-terminated test. reset_sim_or_fail() must
+restore topology isolation at the next test's entry.
+
+Requires root, YNL, drm_fabric, and drm_fabric_sim.
+"""
+
+import os
+import signal
+import subprocess
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import lib_drm_fabric as L  # noqa: E402 - shared KTAP/module helpers
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+
+# Helper process: load the switch shape, then idle so the parent can kill it
+# mid-life. It deliberately installs no cleanup -- SIGKILL would bypass it.
+_CHILD = (
+    "import sys, time\n"
+    "sys.path.insert(0, %r)\n"
+    "import lib_drm_fabric as L\n"
+    "L.rmmod('drm_fabric_sim')\n"
+    "if not L.module_loaded('drm_fabric'):\n"
+    "    L.insmod('drm-fabric.ko')\n"
+    "L.insmod('drm-fabric-sim.ko', 'topology=switch')\n"
+    "time.sleep(120)\n"
+) % _HERE
+
+
+def _has_switch_peer(fab):
+    """True if any leaf port carries a TYPE=switch half-edge (switch shape)."""
+    for e in fab.dump("endpoint-get", {}):
+        ep_id = e["endpoint"]["endpoint-id"]
+        for p in fab.dump("port-get", {"endpoint-id": ep_id}):
+            peer = p["port"].get("peer")
+            if peer and peer.get("type") == "switch":
+                return True
+    return False
+
+
+class Cfg:
+    def __init__(self, nl_error):
+        self.NlError = nl_error
+
+
+def test_sigkill_topology_recovery(ksft, cfg):
+    """A SIGKILL-leaked switch shape must not survive the next entry reset."""
+    # 1. Bring up the switch shape in a helper and confirm it is observable.
+    child = subprocess.Popen([sys.executable, "-c", _CHILD])
+    try:
+        fab = L.DrmFabric()
+        loaded = L.wait_until(
+            lambda: L.module_loaded("drm_fabric_sim") and 
_has_switch_peer(fab),
+            timeout=10.0)
+        if not loaded:
+            child.send_signal(signal.SIGKILL)
+            ksft.skip("harness-reset-sigkill-recovery",
+                      "helper could not establish switch shape")
+            return
+
+        # 2. Terminate through the SIGKILL path: no cleanup runs, so the switch
+        #    sim stays loaded exactly as a hard-timed-out test would leave it.
+        child.send_signal(signal.SIGKILL)
+        child.wait()
+    finally:
+        if child.poll() is None:
+            child.send_signal(signal.SIGKILL)
+            child.wait()
+
+    stale = L.module_loaded("drm_fabric_sim")
+    ksft.check(stale, "harness-reset-sigkill-leaves-stale-sim",
+               "sim unexpectedly unloaded by the killed helper")
+
+    # 3. The next test's entry reset must recover a known default shape.
+    # Mid-case: a result was already emitted above, so a failed reset here
+    # must become a not_ok(), not a skip_all() 0-plan (invalid once results
+    # are on stdout). reset_sim_or_fail() already reported the failure, so
+    # bail out rather than emitting a second, precondition-less check.
+    if not L.reset_sim_or_fail(ksft, "harness-reset-sigkill-recovery-setup",
+                                topology="mesh"):
+        return
+    fab = L.DrmFabric()
+    ksft.check(not _has_switch_peer(fab),
+               "harness-reset-sigkill-recovery",
+               "switch half-edge survived reset_sim(mesh)")
+
+
+CASES = (
+    test_sigkill_topology_recovery,
+)
+
+
+def main():
+    ksft = L.Ksft()
+    _, NlError = L.import_ynl()
+    if not L.is_root():
+        ksft.skip_all("must run as root (insmod + genetlink)")
+    # This suite drives module load/unload itself rather than via fabricsim().
+    try:
+        L.run_cases(ksft, Cfg(NlError), CASES)
+    finally:
+        # Leave a sane default shape for whatever suite runs next.
+        L.sim_restore_default()
+    ksft.finish()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/hotplug_abi.py 
b/tools/testing/selftests/drivers/gpu/drm_fabric/hotplug_abi.py
new file mode 100755
index 000000000000..19a3405fade9
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/hotplug_abi.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+"""
+Endpoint hotplug via fabricsim's debugfs lifecycle controls (add_endpoint/
+del_endpoint, cf. netdevsim's new_port/del_port): CREATE/DELETE events
+observed over the read-only query ABI and notifications; only the hotplug
+stimulus uses the debugfs controls.
+
+Usage: hotplug_abi.py [--no-load]
+"""
+
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import lib_drm_fabric as L
+
+EVT_DURATION = float(os.environ.get("EVT_DURATION", "3"))
+# Subscription is synchronous (setsockopt); a brief settle suffices before
+# triggering, after which wait_ntf() polls with a deadline.
+EVT_SETTLE = float(os.environ.get("EVT_SETTLE", "0.2"))
+
+
+def eps_by_name(fab):
+    return {e["endpoint"]["name"]: e["endpoint"]
+            for e in fab.dump("endpoint-get", {})}
+
+
+def fabricsim_fid(fab):
+    for f in fab.dump("fabric-get", {}):
+        if f["fabric"]["name"] == "fabricsim":
+            return f["fabric"]["fabric-id"]
+    return None
+
+
+def slot_of(name):
+    return int(name.rsplit("ep", 1)[1])
+
+
+def add_ep(fab, control, nports=None):
+    """Add via debugfs; diff the name set (polling) to return the new 
endpoint."""
+    before = set(eps_by_name(fab))
+    L.dbg_write(control, nports if nports is not None else 1)
+    new = L.wait_until(lambda: set(eps_by_name(fab)) - before)
+    if len(new) != 1:
+        return None
+    return eps_by_name(fab)[next(iter(new))]
+
+
+def del_ep(fab, slot, name):
+    L.dbg_write("del_endpoint", slot)
+    return L.wait_until(lambda: name not in eps_by_name(fab))
+
+
+class Cfg:
+    def __init__(self, fab, fid, nl_error):
+        self.fab = fab
+        self.fid = fid
+        self.NlError = nl_error
+
+def _gen(fab):
+    """Current global topology-generation, read via a stable initial port."""
+    return fab.do("port-get",
+                  {"endpoint-id": 0, "port-index": 
0}).get("topology-generation")
+
+
+def test_provider_topology_lifecycle(ksft, cfg):
+    """Provider grows and shrinks the topology within its fabric.
+
+    Non-destructive: only the endpoints it adds are removed.
+    """
+    fab = cfg.fab
+
+    # (a) provider-established initial adjacency (mesh) is observable 
read-only.
+    p0 = fab.do("port-get", {"endpoint-id": 0, "port-index": 0})["port"]
+    peer = p0.get("peer")
+    ksft.check(peer is not None and peer.get("type") == "accel",
+               "topology-initial-adjacency-visible", "peer=%s" % peer)
+
+    ev = L.DrmFabric()
+    ev.ntf_subscribe(L.MCAST_MONITOR)
+    L.settle(EVT_SETTLE)
+
+    n0 = len(eps_by_name(fab))
+    g0 = _gen(fab)
+
+    # (b) grow: add two endpoints; the first is asserted to announce a CREATE.
+    ep_c = add_ep(fab, "add_endpoint", nports=2)
+    c_evt = L.wait_ntf(ev, "endpoint-create-ntf", timeout=EVT_DURATION)
+    ep_d = add_ep(fab, "add_endpoint", nports=2)
+
+    if ep_c is None or ep_d is None:
+        for e in (ep_c, ep_d):
+            if e:
+                del_ep(fab, slot_of(e["name"]), e["name"])
+        for name in ("topology-grow-two-members", "topology-grow-create-ntf",
+                     "topology-grow-advances-generation",
+                     "topology-hotplug-is-member",
+                     "topology-hotplug-endpoint-unlinked",
+                     "topology-reads-do-not-advance-generation",
+                     "topology-shrink-delete-ntf",
+                     "topology-shrink-restores-baseline"):
+            ksft.not_ok(name, "grow failed (c=%s d=%s)" % (ep_c, ep_d))
+        return
+
+    ksft.check(len(eps_by_name(fab)) == n0 + 2, "topology-grow-two-members",
+               "n0=%d now=%d" % (n0, len(eps_by_name(fab))))
+    ksft.check(c_evt is not None, "topology-grow-create-ntf")
+
+    g_grown = _gen(fab)
+    ksft.check(g0 is not None and g_grown is not None and g_grown > g0,
+               "topology-grow-advances-generation",
+               "g0=%s grown=%s" % (g0, g_grown))
+    ksft.check(ep_c.get("fabric-id") == cfg.fid, "topology-hotplug-is-member",
+               "fabric-id=%s" % ep_c.get("fabric-id"))
+
+    # (c) a late arrival is not auto-wired: the provider links explicitly.
+    pc = fab.do("port-get",
+                {"endpoint-id": ep_c["endpoint-id"], "port-index": 0})["port"]
+    ksft.check(pc.get("peer") is None, "topology-hotplug-endpoint-unlinked",
+               "unexpected peer=%s" % pc.get("peer"))
+
+    # (d) pure reads (dump + stats GET) never advance generation.
+    g_pre_reads = _gen(fab)
+    eps_by_name(fab)
+    fab.do("port-stats-get", {"endpoint-id": ep_c["endpoint-id"],
+                              "port-index": 0})
+    g_post_reads = _gen(fab)
+    ksft.check(g_post_reads == g_pre_reads,
+               "topology-reads-do-not-advance-generation",
+               "pre=%s post=%s" % (g_pre_reads, g_post_reads))
+
+    # shrink back to baseline; assert one DELETE event and the restored count.
+    ev2 = L.DrmFabric()
+    ev2.ntf_subscribe(L.MCAST_MONITOR)
+    L.settle(EVT_SETTLE)
+    del_ep(fab, slot_of(ep_d["name"]), ep_d["name"])
+    d_evt = L.wait_ntf(ev2, "endpoint-delete-ntf", timeout=EVT_DURATION)
+    del_ep(fab, slot_of(ep_c["name"]), ep_c["name"])
+    ksft.check(d_evt is not None, "topology-shrink-delete-ntf")
+    ksft.check(len(eps_by_name(fab)) == n0, 
"topology-shrink-restores-baseline",
+               "n0=%d now=%d" % (n0, len(eps_by_name(fab))))
+
+
+
+def test_hotplug_lifecycle(ksft, cfg):
+    """Hotplug one endpoint and unplug it: CREATE, DELETE, membership."""
+    fab = cfg.fab
+    ev = L.DrmFabric()
+    ev.ntf_subscribe(L.MCAST_MONITOR)
+    L.settle(EVT_SETTLE)
+    n_before = len(eps_by_name(fab))
+    new_ep = add_ep(fab, "add_endpoint", nports=2)
+    add_evt = L.wait_ntf(ev, "endpoint-create-ntf", timeout=EVT_DURATION)
+
+    ksft.check(new_ep is not None and len(eps_by_name(fab)) == n_before + 1,
+               "hotplug-add-appears",
+               "n_before=%d new=%s" % (n_before, new_ep))
+    ksft.check(add_evt is not None, "hotplug-add-endpoint-create-ntf")
+    if new_ep is None:
+        ksft.not_ok("hotplug-add-is-member", "add_endpoint produced no 
endpoint")
+        ksft.not_ok("hotplug-del-disappears", "add failed")
+        ksft.not_ok("hotplug-del-endpoint-delete-ntf", "add failed")
+        return
+
+    name = new_ep["name"]
+    ksft.check(new_ep.get("fabric-id") == cfg.fid, "hotplug-add-is-member",
+               "fabric-id=%s" % new_ep.get("fabric-id"))
+
+    try:
+        ev = L.DrmFabric()
+        ev.ntf_subscribe(L.MCAST_MONITOR)
+        L.settle(EVT_SETTLE)
+        gone = del_ep(fab, slot_of(name), name)
+        del_evt = L.wait_ntf(ev, "endpoint-delete-ntf", timeout=EVT_DURATION)
+        ksft.check(gone, "hotplug-del-disappears")
+        ksft.check(del_evt is not None, "hotplug-del-endpoint-delete-ntf")
+    finally:
+        if name in eps_by_name(fab):
+            del_ep(fab, slot_of(name), name)
+
+
+CASES = (
+    test_provider_topology_lifecycle,
+    test_hotplug_lifecycle,
+)
+
+
+def main():
+    ksft = L.Ksft()
+    _, NlError = L.import_ynl()
+
+    with L.fabricsim(ksft, need_debugfs=True, need_control="add_endpoint") as 
fab:
+        fid = fabricsim_fid(fab)
+        if fid is None:
+            ksft.skip_all("fabricsim fabric not present")
+
+        L.run_cases(ksft, Cfg(fab, fid, NlError), CASES)
+    ksft.finish()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/lib_drm_fabric.py 
b/tools/testing/selftests/drivers/gpu/drm_fabric/lib_drm_fabric.py
new file mode 100644
index 000000000000..30fb0edb02b9
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/lib_drm_fabric.py
@@ -0,0 +1,481 @@
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+"""
+Shared helpers for the drm-fabric selftests (cf.
+tools/testing/selftests/net/lib/py): YNL family binding to drm_fabric.yaml,
+path/module discovery, the DrmFabric wrapper, and a KTAP emitter (Ksft)
+over kselftest/ksft.py.
+"""
+
+import atexit
+import contextlib
+import os
+import signal
+import subprocess
+import sys
+import time
+
+# Path discovery.
+# This file lives at tools/testing/selftests/drivers/gpu/drm_fabric/, six
+# directory levels below the kernel tree root, which ROOT resolves to.
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.abspath(os.path.join(HERE, "..", "..", "..", "..", "..", ".."))
+
+SPEC = os.environ.get("SPEC") or os.path.join(
+    ROOT, "Documentation", "netlink", "specs", "drm_fabric.yaml")
+UAPI_HEADER = os.environ.get("UAPI_HEADER") or os.path.join(
+    ROOT, "include", "uapi", "drm", "drm_fabric.h")
+
+# Modules: the QEMU harness sets FABRIC_DIR=/modules; otherwise build output
+# lives next to the source.
+FABRIC_DIR = os.environ.get("FABRIC_DIR") or os.path.join(
+    ROOT, "drivers", "gpu", "drm", "fabric")
+
+DEBUGFS = "/sys/kernel/debug/drm_fabric_sim"
+
+FAMILY = "drm-fabric"
+MCAST_MONITOR = "monitor"
+
+
+def _ynl_dir():
+    env = os.environ.get("YNL_DIR")
+    if env:
+        return env
+    return os.path.join(ROOT, "tools", "net", "ynl")
+
+
+def import_ynl():
+    """Import YnlFamily/NlError from the in-tree YNL library, or SKIP."""
+    ynl_dir = _ynl_dir()
+    if ynl_dir not in sys.path:
+        sys.path.insert(0, ynl_dir)
+    try:
+        from pyynl.lib import YnlFamily, NlError
+    except ModuleNotFoundError as exc:
+        print("1..0 # SKIP cannot import YNL library from %s (%s)"
+              % (ynl_dir, exc))
+        sys.exit(4)
+    return YnlFamily, NlError
+
+
+def _ksft_dir_candidates():
+    """Where the kernel's generic kselftest/ksft.py may live."""
+    yield os.path.normpath(os.path.join(HERE, "..", "..", "..", "kselftest"))
+    yield os.path.join(ROOT, "tools", "testing", "selftests", "kselftest")
+    ynl_root = os.path.normpath(os.path.join(_ynl_dir(), "..", "..", ".."))
+    yield os.path.join(ynl_root, "tools", "testing", "selftests", "kselftest")
+
+
+_ksft_mod = None
+
+
+def import_ksft():
+    """Import the kernel's kselftest/ksft.py, or None if unavailable (inline
+    TAP fallback below)."""
+    global _ksft_mod
+    if _ksft_mod is not None:
+        return _ksft_mod or None
+    for cand in _ksft_dir_candidates():
+        if os.path.isfile(os.path.join(cand, "ksft.py")):
+            if cand not in sys.path:
+                sys.path.insert(0, cand)
+            import ksft as _k
+            _ksft_mod = _k
+            return _k
+    _ksft_mod = False   # cache "looked, not found"
+    return None
+
+
+# KTAP emitter
+
+class Ksft:
+    """KTAP emitter delegating to the kernel's kselftest/ksft.py.
+
+    Thin ergonomic adapter (check/ok/skip/finish) over the in-tree primitives
+    (``test_result_*``/``set_plan``/``finished``) so the suites do not reinvent
+    TAP. Falls back to inline printing only when ksft.py is not importable.
+    """
+
+    def __init__(self):
+        self._k = import_ksft()
+        self.cnt = 0
+        self.fail = 0
+        self._started = False
+        # Timing. Suite wall starts at construction so it also covers the
+        # module load / family setup main() does before the first result.
+        # Per-case and per-step deltas accrue as results are emitted; the
+        # digest is printed by finish() as KTAP "# time:" diagnostics.
+        self._t0 = time.monotonic()
+        self._last = None                # time of the previous result
+        self._cur = None                 # [name, t_start, cnt_at_start]
+        self._cases = []                 # [(name, dur_s, steps), ...]
+        self._slow_step = ("", 0.0)      # (name, longest inter-result gap)
+
+    def _tick(self, name):
+        """Record the wall gap since the previous result (a "step")."""
+        now = time.monotonic()
+        if self._last is None:
+            self._last = now             # first result: no prior to measure
+            return
+        delta = now - self._last
+        self._last = now
+        if delta > self._slow_step[1]:
+            self._slow_step = (name, delta)
+
+    def case_begin(self, name):
+        self._cur = [name, time.monotonic(), self.cnt]
+
+    def case_end(self):
+        if self._cur is None:
+            return
+        name, t_start, cnt0 = self._cur
+        self._cases.append((name, time.monotonic() - t_start, self.cnt - cnt0))
+        self._cur = None
+
+    def start(self):
+        if self._started:
+            return
+        self._started = True
+        if self._k:
+            self._k.print_header()
+        else:
+            print("TAP version 13")
+
+    def skip_all(self, reason):
+        # A 0-plan skip is the whole result (standard KTAP, both backends),
+        # which is only valid before any other result has been printed. A
+        # mid-case call is a bug in the caller, not a runtime condition to
+        # report as KTAP -- surface it loudly instead of emitting a plan
+        # that contradicts results already on stdout.
+        if self.cnt != 0:
+            raise RuntimeError(
+                "skip_all() called after %d result(s) already emitted "
+                "(reason=%r); mid-case failures must use not_ok() (e.g. "
+                "via reset_sim_or_fail()), not skip_all()" % (self.cnt, 
reason))
+        print("1..0 # SKIP %s" % reason)
+        sys.exit(4)
+
+    def ok(self, name):
+        self.start()
+        self._tick(name)
+        self.cnt += 1
+        if self._k:
+            self._k.test_result_pass(name)
+        else:
+            print("ok %d %s" % (self.cnt, name))
+
+    def not_ok(self, name, detail=""):
+        self.start()
+        self._tick(name)
+        self.cnt += 1
+        self.fail += 1
+        if self._k:
+            if detail:
+                self._k.print_msg(detail)
+            self._k.test_result_fail(name)
+        else:
+            print("not ok %d %s" % (self.cnt, name))
+            if detail:
+                print("  # %s" % detail)
+
+    def skip(self, name, reason=""):
+        self.start()
+        self._tick(name)
+        self.cnt += 1
+        if self._k:
+            if reason:
+                self._k.print_msg("%s: %s" % (name, reason))
+            self._k.test_result_skip(name)
+        else:
+            print("ok %d %s # SKIP %s" % (self.cnt, name, reason))
+
+    def check(self, cond, name, detail=""):
+        if cond:
+            self.ok(name)
+        else:
+            self.not_ok(name, detail)
+        return bool(cond)
+
+    def _emit_timing(self):
+        """Print per-case and per-suite wall-clock as "# time:" KTAP 
diagnostics
+        (ignored by TAP parsers). Per-case lines are gated behind
+        FABRIC_TIMING; the one-line suite summary is always emitted.
+        """
+        wall = time.monotonic() - self._t0
+        suite = os.path.basename(sys.argv[0]) or "suite"
+        if os.environ.get("FABRIC_TIMING"):
+            for name, dur, steps in self._cases:
+                print("# time: case=%s wall=%.3fs steps=%d" % (name, dur, 
steps))
+        slow = max(self._cases, default=("-", 0.0, 0), key=lambda c: c[1])
+        print("# time: suite=%s wall=%.3fs cases=%d steps=%d "
+              "slowest-case=%s(%.3fs) slowest-step=%s(%.3fs)"
+              % (suite, wall, len(self._cases), self.cnt,
+                 slow[0], slow[1], self._slow_step[0], self._slow_step[1]))
+
+    def finish(self):
+        self.start()
+        self._emit_timing()
+        if self._k:
+            self._k.set_plan(self.cnt)
+            self._k.finished()       # prints totals + exits 0/1 by pass+skip
+        else:
+            print("1..%d" % self.cnt)
+            print("")
+            print("# %d/%d passed, %d failed"
+                  % (self.cnt - self.fail, self.cnt, self.fail))
+            sys.exit(1 if self.fail else 0)
+
+
+# Case dispatch
+
+def run_cases(ksft, cfg, cases):
+    """Dispatch each case, isolating an exception to its own result."""
+    for fn in cases:
+        ksft.case_begin(fn.__name__)
+        try:
+            fn(ksft, cfg)
+        except Exception as exc:  # noqa: BLE001 - isolate one case's failure
+            ksft.not_ok(fn.__name__, "unhandled exception: %r" % exc)
+        finally:
+            ksft.case_end()
+        if getattr(cfg, "abort", False):
+            break
+
+
+# YNL wrapper
+
+def DrmFabric(**kwargs):
+    """Construct a YnlFamily bound to the drm_fabric spec (schema off)."""
+    YnlFamily, _ = import_ynl()
+    if not os.path.isfile(SPEC):
+        Ksft().skip_all("drm_fabric.yaml not found at %s" % SPEC)
+    # schema='' skips slow jsonschema validation, matching the net selftests.
+    return YnlFamily(SPEC, schema="", **kwargs)
+
+
+def nl_errno(exc):
+    """Positive errno carried by a netlink exception."""
+    return getattr(exc, "error", 0)
+
+
+def family_has_op(fab, name):
+    """True if the loaded family advertises operation @name: a query-only
+    build has none of the topology-mutation ops (fabric-new, fabric-del,
+    endpoint-set, port-set, port-peer-new/del), so cases exercising them are
+    filtered rather than raising KeyError.
+    """
+    return name in getattr(fab, "ops", {})
+
+
+# System helpers (kselftest runs as root)
+
+def is_root():
+    return os.geteuid() == 0
+
+
+def module_loaded(name):
+    return os.path.isdir("/sys/module/%s" % name)
+
+
+def insmod(ko, *args):
+    path = ko if os.path.isabs(ko) else os.path.join(FABRIC_DIR, ko)
+    return subprocess.call(["insmod", path, *args],
+                           stderr=subprocess.DEVNULL) == 0
+
+
+def rmmod(name):
+    subprocess.call(["rmmod", name], stderr=subprocess.DEVNULL)
+
+
+def debugfs_available():
+    return os.path.isdir(DEBUGFS)
+
+
+def dbg_write(rel, val):
+    with open(os.path.join(DEBUGFS, rel), "w") as fh:
+        fh.write(str(val))
+
+
+def settle(seconds=0.2):
+    time.sleep(seconds)
+
+
+def wait_until(predicate, timeout=3.0, interval=0.02):
+    """Poll @predicate until truthy or @timeout elapses; return the last 
value."""
+    deadline = time.monotonic() + timeout
+    val = predicate()
+    while not val and time.monotonic() < deadline:
+        time.sleep(interval)
+        val = predicate()
+    return val
+
+
+def wait_ntf(ev, want_name, timeout=3.0, match=None):
+    """First notification named @want_name within @timeout, else None."""
+    deadline = time.monotonic() + timeout
+    while True:
+        remaining = deadline - time.monotonic()
+        if remaining <= 0:
+            return None
+        for ntf in ev.poll_ntf(duration=min(remaining, 0.25)):
+            if ntf["name"] != want_name:
+                continue
+            if match is None or match(ntf):
+                return ntf
+
+
+# Teardown that survives the timeout killer
+
+_teardowns = []
+_teardown_armed = False
+
+
+def _run_teardowns():
+    """Run registered teardowns once, most-recent first, swallowing errors."""
+    while _teardowns:
+        fn = _teardowns.pop()
+        try:
+            fn()
+        except Exception:  # noqa: BLE001 - teardown must not mask the exit
+            pass
+
+
+def _sig_teardown(signum, _frame):
+    _run_teardowns()
+    # Restore the default disposition and re-raise so the exit status still
+    # reflects the signal (the kselftest runner treats it as a 
failure/timeout).
+    signal.signal(signum, signal.SIG_DFL)
+    os.kill(os.getpid(), signum)
+
+
+def on_teardown(fn):
+    """Register @fn for normal exit and SIGTERM/SIGINT.
+
+    atexit alone misses the timeout runner's SIGTERM. SIGKILL cannot be
+    caught, so a killed predecessor is recovered at the next test's entry.
+    """
+    global _teardown_armed
+    if not _teardown_armed:
+        atexit.register(_run_teardowns)
+        for sig in (signal.SIGTERM, signal.SIGINT):
+            try:
+                signal.signal(sig, _sig_teardown)
+            except (ValueError, OSError):
+                pass   # not on the main thread; atexit still covers clean exit
+        _teardown_armed = True
+    _teardowns.append(fn)
+
+
+# Suite fixture
+
+def _providers_unload():
+    rmmod("drm_fabric_sim")
+    rmmod("drm_fabric")
+
+
+def sim_restore_default():
+    # Drop the shape the suite loaded and put the default topology back.
+    rmmod("drm_fabric_sim")
+    if module_loaded("drm_fabric"):
+        insmod("drm-fabric-sim.ko")
+        wait_until(lambda: module_loaded("drm_fabric_sim"))
+
+
+def _reset_sim_steps(topology):
+    """Drop whatever a previous test left loaded, then load @topology.
+
+    A test killed with SIGKILL runs no Python cleanup, so isolation is
+    re-established here, at the next test's entry. Returns (ok, reason) so
+    each caller can pick its own KTAP path.
+    """
+    if not is_root():
+        return False, "must run as root (insmod)"
+    rmmod("drm_fabric_sim")
+    if not module_loaded("drm_fabric") and not insmod("drm-fabric.ko"):
+        return False, "could not load drm_fabric"
+    if not insmod("drm-fabric-sim.ko", "topology=%s" % topology):
+        return False, "could not load drm_fabric_sim topology=%s" % topology
+    if not wait_until(lambda: module_loaded("drm_fabric_sim")):
+        return False, "drm_fabric_sim did not appear after reset"
+    return True, ""
+
+
+def reset_sim(ksft, topology="mesh"):
+    """Load @topology, or skip the suite.
+
+    Setup-time only: skip_all() is invalid once a result has been emitted.
+    """
+    ok, reason = _reset_sim_steps(topology)
+    if not ok:
+        ksft.skip_all(reason)
+
+
+def reset_sim_or_fail(ksft, name, topology="mesh"):
+    """Load @topology, or fail the current case as @name.
+
+    On False the caller must bail out; the not_ok() already stands.
+    """
+    ok, reason = _reset_sim_steps(topology)
+    if not ok:
+        ksft.not_ok(name, reason)
+        return False
+    return True
+
+
[email protected]
+def fabricsim(ksft, topology=None, need_debugfs=False, need_control=None,
+              open_family=True):
+    """Bring the providers up, yield a bound family, arrange teardown.
+
+    A missing precondition skips the suite. @topology reloads the sim even
+    under --no-load and restores the default on exit. @open_family=False
+    yields None for suites opening their own socket.
+    """
+    _, NlError = import_ynl()
+
+    if not is_root():
+        ksft.skip_all("must run as root (genetlink + debugfs + insmod)")
+
+    if topology is not None:
+        # Entry reset: dropping any sim left by a previous (possibly
+        # SIGKILL-terminated) test before loading this shape is what makes a
+        # topology-changing suite start from a known state. See reset_sim().
+        rmmod("drm_fabric_sim")
+        loaded_core = False
+        if not module_loaded("drm_fabric"):
+            if not insmod("drm-fabric.ko"):
+                ksft.skip_all("could not load drm_fabric")
+            loaded_core = True
+        # Arm teardown before loading the sim so a failed sim load (or any
+        # later skip) still restores the default shape and unwinds a core we
+        # loaded here, rather than leaking it into the next suite.
+        on_teardown(_providers_unload if loaded_core else sim_restore_default)
+        if not insmod("drm-fabric-sim.ko", "topology=%s" % topology):
+            ksft.skip_all("could not load drm_fabric_sim topology=%s" % 
topology)
+        wait_until(lambda: module_loaded("drm_fabric_sim"))
+    elif "--no-load" not in sys.argv[1:] and not module_loaded("drm_fabric"):
+        rmmod("drm_fabric_sim")
+        rmmod("drm_fabric")
+        # Arm teardown before loading so a partial load (core up, sim load
+        # failed) is unwound instead of leaking a module into the next suite.
+        on_teardown(_providers_unload)
+        if not insmod("drm-fabric.ko") or not insmod("drm-fabric-sim.ko"):
+            ksft.skip_all("could not load drm_fabric + drm_fabric_sim modules")
+        wait_until(lambda: module_loaded("drm_fabric_sim"))
+
+    if not module_loaded("drm_fabric_sim"):
+        ksft.skip_all("drm_fabric_sim not loaded")
+    if need_debugfs and not debugfs_available():
+        ksft.skip_all("fabricsim debugfs not available (runtime controls)")
+    if need_control and not os.path.exists(os.path.join(DEBUGFS, 
need_control)):
+        ksft.skip_all("fabricsim lacks '%s' control (old module)" % 
need_control)
+
+    if not open_family:
+        yield None
+        return
+
+    try:
+        fab = DrmFabric()
+    except (OSError, NlError) as exc:
+        ksft.skip_all("cannot open drm-fabric family: %s" % exc)
+    yield fab
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/nl_policy_probe.py 
b/tools/testing/selftests/drivers/gpu/drm_fabric/nl_policy_probe.py
new file mode 100755
index 000000000000..0d4d60d45e5a
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/nl_policy_probe.py
@@ -0,0 +1,485 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+"""
+Adversarial raw-netlink probes the YNL suites cannot reach: malformed
+attrs (wrong type, unknown id, truncated nest, out-of-range enum, missing
+required) must return a clean NLMSG_ERROR, never an oops; a liveness dump
+confirms nothing wedged the family. Also introspects the family and emits
+TAP.
+
+Topology-mutation policy probes arrive with the provisioning ABI; this
+query-only build defines no mutation commands or attributes to probe.
+"""
+
+import errno
+import os
+import re
+import socket
+import struct
+import subprocess
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import lib_drm_fabric as L  # noqa: E402 - shared KTAP emitter only (no pyynl)
+
+# Netlink / generic-netlink constants
+NETLINK_GENERIC = 16
+
+NLMSG_ERROR = 0x2
+NLMSG_DONE = 0x3
+
+NLM_F_REQUEST = 0x01
+NLM_F_ACK = 0x04
+NLM_F_DUMP = 0x300
+
+GENL_ID_CTRL = 0x10
+CTRL_CMD_GETFAMILY = 3
+# uapi/linux/genetlink.h CTRL_ATTR_* enum:
+#   1 FAMILY_ID, 2 FAMILY_NAME, 3 VERSION, 4 HDRSIZE, 5 MAXATTR,
+#   6 OPS, 7 MCAST_GROUPS
+CTRL_ATTR_FAMILY_ID = 1
+CTRL_ATTR_FAMILY_NAME = 2
+CTRL_ATTR_VERSION = 3
+CTRL_ATTR_OPS = 6
+CTRL_ATTR_MCAST_GROUPS = 7
+# Within a CTRL_ATTR_OPS entry:
+CTRL_ATTR_OP_ID = 1
+CTRL_ATTR_OP_FLAGS = 2
+# Within a CTRL_ATTR_MCAST_GROUPS entry:
+CTRL_ATTR_MCAST_GRP_NAME = 1
+
+# genetlink op flags (uapi/linux/genetlink.h)
+GENL_ADMIN_PERM = 0x01
+
+NLA_F_NESTED = 0x8000
+NLA_TYPE_MASK = ~(NLA_F_NESTED | 0x4000)
+
+NLMSG_HDRLEN = 16
+GENL_HDRLEN = 4
+
+FAMILY_NAME = b"drm-fabric"
+
+EXPECTED_VERSION = 1
+MCAST_MONITOR = b"monitor"
+
+
+# Command / attribute ids: derive from the uAPI header.
+#
+# Hand-written ids drift the moment someone reorders an enum, leaving the probe
+# silently fuzzing the wrong command. Parse them from the canonical uapi header
+# (or its sibling/initramfs copy) so a reorder is reflected automatically;
+# deliberately no fallback table -- skip the whole suite if the header cannot
+# be found, rather than risk probing under a stale guess.
+
+def _find_uapi_header():
+    cand = os.environ.get("UAPI_HEADER")
+    if cand and os.path.isfile(cand):
+        return cand
+    here = os.path.dirname(os.path.abspath(__file__))
+    root = os.path.abspath(os.path.join(here, *([".."] * 6)))
+    for p in (os.path.join(root, "include", "uapi", "drm", "drm_fabric.h"),
+              "/opt/spec/drm_fabric.h"):
+        if os.path.isfile(p):
+            return p
+    return None
+
+
+def _parse_all_enums(text):
+    """Merge values from every enum block into one symbol table.
+
+    Commands use an anonymous enum and attributes a named one, so parsing by
+    enum name is brittle. Symbols are assumed unique; collisions are
+    last-wins.
+    """
+    out = {}
+    for body in re.findall(r"enum\s*(?:\w+\s*)?\{(.*?)\}", text, re.S):
+        body = re.sub(r"/\*.*?\*/", "", body, flags=re.S)
+        body = re.sub(r"//[^\n]*", "", body)
+        nxt = 0
+        for raw in body.split(","):
+            item = raw.strip()
+            if not item:
+                continue
+            if "=" in item:
+                name, val = item.split("=", 1)
+                name = name.strip()
+                try:
+                    nxt = int(val.strip(), 0)
+                except ValueError:
+                    continue
+            else:
+                name = item
+            if re.match(r"^[A-Za-z_]\w*$", name):
+                out[name] = nxt
+            nxt += 1
+    return out
+
+
+def _load_ids():
+    # Committed fallbacks (kept in sync with drm_fabric.h, query-only build).
+    syms = {"DRM_FABRIC_CMD_FABRIC_GET": 1, "DRM_FABRIC_CMD_PORT_GET": 3,
+            "DRM_FABRIC_A_FABRIC_ID": 5, "DRM_FABRIC_A_ENDPOINT_ID": 6,
+            "DRM_FABRIC_A_PORT_INDEX": 7, "DRM_FABRIC_A_PEER": 10}
+    src = "fallback literals"
+    hdr = _find_uapi_header()
+    if hdr:
+        parsed = _parse_all_enums(open(hdr).read())
+        if "DRM_FABRIC_CMD_PORT_GET" in parsed and "DRM_FABRIC_A_FABRIC_ID" in 
parsed:
+            syms, src = parsed, hdr
+    return syms, src
+
+
+_SYMS, _ID_SRC = _load_ids()
+
+CMD_FABRIC_GET = _SYMS["DRM_FABRIC_CMD_FABRIC_GET"]
+CMD_PORT_GET = _SYMS["DRM_FABRIC_CMD_PORT_GET"]
+
+A_FABRIC_ID = _SYMS["DRM_FABRIC_A_FABRIC_ID"]
+A_ENDPOINT_ID = _SYMS["DRM_FABRIC_A_ENDPOINT_ID"]
+A_PORT_INDEX = _SYMS["DRM_FABRIC_A_PORT_INDEX"]
+
+# An attribute id guaranteed to be past the family's top-level maxattr, so the
+# kernel strict-rejects it. Derived from the parsed ids (one past the largest
+# symbol) rather than a magic literal, which would silently stop testing strict
+# rejection once the attribute set grows past it.
+A_UNKNOWN = max(_SYMS.values()) + 1
+
+
+# NLA builders
+
+def _align4(n):
+    return (n + 3) & ~3
+
+
+def nla(attr_type, payload):
+    length = 4 + len(payload)
+    pad = b"\x00" * (_align4(length) - length)
+    return struct.pack("=HH", length, attr_type) + payload + pad
+
+
+def nla_u32(attr_type, val):
+    return nla(attr_type, struct.pack("=I", val & 0xFFFFFFFF))
+
+
+def nla_u64(attr_type, val):
+    return nla(attr_type, struct.pack("=Q", val & 0xFFFFFFFFFFFFFFFF))
+
+
+def build_msg(family_id, cmd, seq, payload, flags=NLM_F_REQUEST | NLM_F_ACK):
+    body = struct.pack("=BBH", cmd, 1, 0) + payload
+    total = NLMSG_HDRLEN + len(body)
+    nlh = struct.pack("=IHHII", total, family_id, flags, seq, 0)
+    return nlh + body
+
+
+# Socket helpers
+
+def open_sock():
+    s = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_GENERIC)
+    s.bind((0, 0))
+    s.settimeout(3)
+    return s
+
+
+def iter_attrs(blob):
+    off = 0
+    while off + 4 <= len(blob):
+        (alen, atype) = struct.unpack_from("=HH", blob, off)
+        if alen < 4:
+            break
+        payload = blob[off + 4:off + alen]
+        yield atype, payload
+        off += _align4(alen)
+
+
+def parse_dgram(data):
+    """Split one recv() buffer into (nlmsg_type, errno) tuples.
+
+    errno is None when mlen is too short to hold the 4 payload bytes it
+    claims -- distinct from a genuine zero status.
+    """
+    out = []
+    off = 0
+    while off + NLMSG_HDRLEN <= len(data):
+        (mlen, mtype, _, _, _) = struct.unpack_from("=IHHII", data, off)
+        if mlen < NLMSG_HDRLEN:
+            break
+        if mtype in (NLMSG_ERROR, NLMSG_DONE):
+            if mlen >= NLMSG_HDRLEN + 4:
+                (err,) = struct.unpack_from("=i", data, off + NLMSG_HDRLEN)
+            else:
+                err = None
+            out.append((mtype, err))
+        else:
+            out.append((mtype, 0))
+        off += _align4(mlen)
+    return out
+
+
+def drain(sock, first_timeout=0.5, more_timeout=0.3):
+    """Read every datagram the kernel queued in response to one request."""
+    msgs = []
+    sock.settimeout(first_timeout)
+    try:
+        msgs += parse_dgram(sock.recv(16384))
+    except socket.timeout:
+        return msgs
+    sock.settimeout(more_timeout)
+    while True:
+        try:
+            msgs += parse_dgram(sock.recv(16384))
+        except socket.timeout:
+            break
+    return msgs
+
+
+def resolve_family(sock, name):
+    seq = 1
+    msg = build_msg(GENL_ID_CTRL, CTRL_CMD_GETFAMILY, seq,
+                    nla(CTRL_ATTR_FAMILY_NAME, name + b"\x00"))
+    sock.send(msg)
+    try:
+        data = sock.recv(8192)
+    except socket.timeout:
+        return None
+    (_, mtype, _, _, _) = struct.unpack_from("=IHHII", data, 0)
+    if mtype == NLMSG_ERROR:
+        return None
+    attrs = data[NLMSG_HDRLEN + GENL_HDRLEN:]
+    for atype, payload in iter_attrs(attrs):
+        if atype == CTRL_ATTR_FAMILY_ID:
+            if len(payload) >= 4:
+                return struct.unpack_from("=I", payload, 0)[0]
+            if len(payload) >= 2:
+                return struct.unpack_from("=H", payload, 0)[0]
+    return None
+
+
+def get_family_info(sock, name):
+    """Introspect the family via CTRL_CMD_GETFAMILY.
+
+    Returns {version, ops: {op_id: flags}, mcast: set(names)} or None,
+    letting callers confirm version, admin-perm on mutators, and the
+    monitor group.
+    """
+    seq = 2
+    msg = build_msg(GENL_ID_CTRL, CTRL_CMD_GETFAMILY, seq,
+                    nla(CTRL_ATTR_FAMILY_NAME, name + b"\x00"),
+                    flags=NLM_F_REQUEST)
+    sock.send(msg)
+    try:
+        data = sock.recv(65536)
+    except socket.timeout:
+        return None
+    (_, mtype, _, _, _) = struct.unpack_from("=IHHII", data, 0)
+    if mtype == NLMSG_ERROR:
+        return None
+
+    info = {"version": None, "ops": {}, "mcast": set()}
+    attrs = data[NLMSG_HDRLEN + GENL_HDRLEN:]
+    for atype, payload in iter_attrs(attrs):
+        atype &= NLA_TYPE_MASK
+        if atype == CTRL_ATTR_VERSION and len(payload) >= 4:
+            info["version"] = struct.unpack_from("=I", payload, 0)[0]
+        elif atype == CTRL_ATTR_OPS:
+            for _, op_blob in iter_attrs(payload):     # one entry per op
+                op_id = op_flags = None
+                for sub, val in iter_attrs(op_blob):
+                    sub &= NLA_TYPE_MASK
+                    if sub == CTRL_ATTR_OP_ID and len(val) >= 4:
+                        op_id = struct.unpack_from("=I", val, 0)[0]
+                    elif sub == CTRL_ATTR_OP_FLAGS and len(val) >= 4:
+                        op_flags = struct.unpack_from("=I", val, 0)[0]
+                if op_id is not None:
+                    info["ops"][op_id] = op_flags or 0
+        elif atype == CTRL_ATTR_MCAST_GROUPS:
+            for _, grp_blob in iter_attrs(payload):
+                for sub, val in iter_attrs(grp_blob):
+                    sub &= NLA_TYPE_MASK
+                    if sub == CTRL_ATTR_MCAST_GRP_NAME:
+                        info["mcast"].add(val.rstrip(b"\x00"))
+    return info
+
+
+# The KTAP emitter (L.Ksft) is shared with the YNL suites: one emitter, and a
+# dynamic plan printed at finish() instead of a hard-coded count that drifts
+# every time a case is added or removed.
+
+_SEQ = [100]
+
+
+def case_rejected(tap, name, sock, fid, cmd, payload, expect):
+    """Pass iff the kernel rejected with one of @expect (positive errno
+    values; the netlink error is negative, so we compare -e). The specific
+    code matters: e.g. -EINVAL for a malformed attribute, not a generic
+    failure.
+    """
+    _SEQ[0] += 1
+    sock.send(build_msg(fid, cmd, _SEQ[0], payload))
+    msgs = drain(sock)
+    rejected = [-e for (t, e) in msgs
+               if t == NLMSG_ERROR and e is not None and e != 0]
+    if not msgs:
+        tap.not_ok(name, "no response (possible hang)")
+    elif not rejected:
+        tap.not_ok(name, "accepted (no error returned)")
+    elif rejected[0] in expect:
+        tap.ok("%s (errno=%d)" % (name, rejected[0]))
+    else:
+        want = "/".join(errno.errorcode.get(e, str(e)) for e in sorted(expect))
+        tap.not_ok(name, "errno=%d (%s), expected %s"
+                   % (rejected[0], errno.errorcode.get(rejected[0], "?"), 
want))
+
+
+def _maybe_load_modules():
+    """Standalone runs self-load; a pre-loading harness passes --no-load.
+    Returns True iff this run loaded the providers, so the caller can
+    register teardown.
+    """
+    if "--no-load" in sys.argv[1:]:
+        return False
+    if os.path.isdir("/sys/module/drm_fabric"):
+        return False
+    here = os.path.dirname(os.path.abspath(__file__))
+    root = os.path.abspath(os.path.join(here, *([".."] * 6)))
+    fdir = os.environ.get("FABRIC_DIR") or os.path.join(
+        root, "drivers", "gpu", "drm", "fabric")
+    loaded = False
+    for ko in ("drm-fabric.ko", "drm-fabric-sim.ko"):
+        path = os.path.join(fdir, ko)
+        if os.path.isfile(path):
+            if subprocess.call(["insmod", path],
+                               stderr=subprocess.DEVNULL) == 0:
+                loaded = True
+    return loaded
+
+
+def _unload_providers():
+    subprocess.call(["rmmod", "drm_fabric_sim"], stderr=subprocess.DEVNULL)
+    subprocess.call(["rmmod", "drm_fabric"], stderr=subprocess.DEVNULL)
+
+
+class Cfg:
+    def __init__(self, sock, fid):
+        self.sock = sock
+        self.fid = fid
+
+
+def test_malformed_requests(ksft, cfg):
+    sock, fid = cfg.sock, cfg.fid
+    # Malformed framing/attributes must fail validation with -EINVAL.
+    EINVAL = {errno.EINVAL}
+
+    case_rejected(ksft, "wrong-type-short-u32", sock, fid, CMD_FABRIC_GET,
+                  nla(A_FABRIC_ID, struct.pack("=H", 1)), EINVAL)
+
+    case_rejected(ksft, "unknown-attribute-id", sock, fid, CMD_FABRIC_GET,
+                  nla_u32(A_FABRIC_ID, 1) + nla_u32(A_UNKNOWN, 0), EINVAL)
+
+    case_rejected(ksft, "missing-required-port-index", sock, fid, CMD_PORT_GET,
+                  nla_u32(A_ENDPOINT_ID, 0), EINVAL)
+
+
+def test_liveness(ksft, cfg):
+    """A dump that doesn't hang or error is not enough: it must also carry
+    a well-formed, zero-status terminal NLMSG_DONE, or a wedge/regression in
+    the dump's termination path would go unnoticed. An empty-but-valid dump
+    (no data records, just a clean DONE) is still a pass.
+    """
+    sock, fid = cfg.sock, cfg.fid
+    _SEQ[0] += 1
+    sock.send(build_msg(fid, CMD_FABRIC_GET, _SEQ[0], b"",
+                        flags=NLM_F_REQUEST | NLM_F_DUMP))
+    msgs = drain(sock)
+    errs = [e for (t, e) in msgs if t == NLMSG_ERROR and e != 0]
+    dones = [e for (t, e) in msgs if t == NLMSG_DONE]
+    if not msgs:
+        ksft.not_ok("liveness-dump-after-fuzz", "no response (possible hang)")
+    elif errs:
+        ksft.not_ok("liveness-dump-after-fuzz", "dump errno=%s" % errs[0])
+    elif not dones:
+        ksft.not_ok("liveness-dump-after-fuzz",
+                    "no terminal DONE (dump possibly truncated)")
+    elif dones[0] is None:
+        ksft.not_ok("liveness-dump-after-fuzz", "malformed terminal DONE")
+    elif dones[0] != 0:
+        ksft.not_ok("liveness-dump-after-fuzz",
+                    "terminal DONE error=%d" % dones[0])
+    else:
+        ksft.ok("liveness-dump-after-fuzz")
+
+
+def test_family_introspection(ksft, cfg):
+    """Via CTRL_CMD_GETFAMILY: version, admin-perm gating, mcast surface."""
+    getter_ids = [_SYMS[n] for n in (
+        "DRM_FABRIC_CMD_FABRIC_GET", "DRM_FABRIC_CMD_ENDPOINT_GET",
+        "DRM_FABRIC_CMD_PORT_GET", "DRM_FABRIC_CMD_PORT_STATS_GET")
+        if n in _SYMS]
+
+    info = get_family_info(cfg.sock, FAMILY_NAME)
+    if not info:
+        for nm in ("genl-family-version", "genl-mcast-monitor-present",
+                   "genl-getters-not-admin-perm"):
+            ksft.not_ok(nm, "CTRL_CMD_GETFAMILY introspection failed")
+        return
+
+    if info["version"] == EXPECTED_VERSION:
+        ksft.ok("genl-family-version (v%d)" % info["version"])
+    else:
+        ksft.not_ok("genl-family-version",
+                    "got %s want %d" % (info["version"], EXPECTED_VERSION))
+
+    if MCAST_MONITOR in info["mcast"]:
+        ksft.ok("genl-mcast-monitor-present")
+    else:
+        ksft.not_ok("genl-mcast-monitor-present",
+                    "groups=%s" % info["mcast"])
+
+    ops = info["ops"]
+    # A query-only build exposes getters only: each must be ungated (no
+    # GENL_ADMIN_PERM), so a normal namespace can enumerate topology.
+    seen_get = [c for c in getter_ids if c in ops]
+    bad_get = [c for c in seen_get if ops[c] & GENL_ADMIN_PERM]
+    if seen_get and not bad_get:
+        ksft.ok("genl-getters-not-admin-perm (%d cmds)" % len(seen_get))
+    else:
+        ksft.not_ok("genl-getters-not-admin-perm",
+                    "seen=%s wrongly-gated=%s" % (seen_get, bad_get))
+
+
+CASES = (
+    test_malformed_requests,
+    test_liveness,
+    test_family_introspection,
+)
+
+
+def main():
+    tap = L.Ksft()
+
+    if os.geteuid() != 0:
+        tap.skip_all("root is required to load drm_fabric modules")
+
+    if _maybe_load_modules():
+        L.on_teardown(_unload_providers)
+
+    try:
+        sock = open_sock()
+    except OSError as exc:
+        tap.skip_all("cannot open genetlink socket: %s" % exc)
+
+    fid = resolve_family(sock, FAMILY_NAME)
+    if not fid:
+        tap.skip_all("drm-fabric genl family not registered "
+                     "(load drm_fabric.ko)")
+
+    sys.stderr.write("# attribute/command ids from: %s\n" % _ID_SRC)
+
+    cfg = Cfg(sock, fid)
+    L.run_cases(tap, cfg, CASES)
+    tap.finish()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/port_cursor_abi.py 
b/tools/testing/selftests/drivers/gpu/drm_fabric/port_cursor_abi.py
new file mode 100755
index 000000000000..eef6378d5ec2
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/port_cursor_abi.py
@@ -0,0 +1,401 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+"""Exercise nested port-dump resume across endpoint removal.
+
+A resumed dump must not apply the removed endpoint's saved port cursor
+to its successor. Verify that each endpoint observed after removal starts
+at port index 0 for both PORT_GET and PORT_STATS_GET.
+"""
+
+import os
+import re
+import socket
+import struct
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import lib_drm_fabric as L  # noqa: E402 - shared KTAP/module helpers
+
+# --- Netlink / generic-netlink constants (cf. dump_intr_abi.py) -----------
+
+NETLINK_GENERIC = 16
+NLMSG_ERROR = 0x2
+NLMSG_DONE = 0x3
+NLM_F_REQUEST = 0x01
+NLM_F_DUMP = 0x300
+NLMSG_HDRLEN = 16
+GENL_HDRLEN = 4
+NLA_HDRLEN = 4
+NLA_TYPE_MASK = 0x3FFF
+CTRL_ID = 0x10
+CTRL_CMD_GETFAMILY = 3
+CTRL_ATTR_FAMILY_NAME = 2
+CTRL_ATTR_FAMILY_ID = 1
+
+SEQ = 2                 # request sequence; replies in the dump must echo it
+
+# Multi-port endpoints so a batch boundary can land *inside* an endpoint (the
+# only case that exercises a non-zero saved port cursor). 15 is near the sim's
+# 16-port cap and rarely divides the per-batch port capacity evenly.
+PORTS = int(os.environ.get("PORT_CURSOR_PORTS", "15"))
+SCALE = int(os.environ.get("PORT_CURSOR_SCALE", "160"))
+
+
+def _align4(n):
+    return (n + 3) & ~3
+
+
+def _nla(atype, payload):
+    length = NLA_HDRLEN + len(payload)
+    pad = b"\x00" * (_align4(length) - length)
+    return struct.pack("=HH", length, atype) + payload + pad
+
+
+def _msg(family_id, cmd, seq, flags, payload=b""):
+    body = struct.pack("=BBH", cmd, 1, 0) + payload
+    total = NLMSG_HDRLEN + len(body)
+    return struct.pack("=IHHII", total, family_id, flags, seq, 0) + body
+
+
+def _open():
+    s = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_GENERIC)
+    s.bind((0, 0))
+    s.settimeout(5)
+    return s
+
+
+def _resolve_family(sock, name):
+    sock.send(_msg(CTRL_ID, CTRL_CMD_GETFAMILY, 1, NLM_F_REQUEST,
+                   _nla(CTRL_ATTR_FAMILY_NAME, name + b"\x00")))
+    data = sock.recv(8192)
+    (_, mtype, _, _, _) = struct.unpack_from("=IHHII", data, 0)
+    if mtype == NLMSG_ERROR:
+        return None
+    off = NLMSG_HDRLEN + GENL_HDRLEN
+    while off + NLA_HDRLEN <= len(data):
+        (alen, atype) = struct.unpack_from("=HH", data, off)
+        if alen < NLA_HDRLEN:
+            break
+        if atype == CTRL_ATTR_FAMILY_ID and alen >= 6:
+            return struct.unpack_from("=H", data, off + 4)[0]
+        off += _align4(alen)
+    return None
+
+
+def _enum(name):
+    """Parse `enum <name> { ... }` from the uAPI header into {member: value}.
+    A value referencing another enumerator (the generated `MAX = (__MAX - 1)`
+    sentinel) fails int() and is skipped rather than silently mis-numbering.
+    """
+    out = {}
+    try:
+        text = open(L.UAPI_HEADER).read()
+    except OSError:
+        return out
+    m = re.search(r"enum\s+%s\s*\{(.*?)\}" % re.escape(name), text, re.S)
+    if not m:
+        return out
+    n = 0
+    for raw in re.sub(r"/\*.*?\*/", "", m.group(1), flags=re.S).split(","):
+        item = raw.strip()
+        if not item:
+            continue
+        if "=" in item:
+            key, val = item.split("=", 1)
+            key = key.strip()
+            try:
+                n = int(val.strip(), 0)
+            except ValueError:
+                continue        # references another enumerator; skip sentinel
+        else:
+            key = item
+        out[key] = n
+        n += 1
+    return out
+
+
+_HDR_PRESENT = os.path.isfile(L.UAPI_HEADER)
+_CMD = _enum("drm_fabric_cmd")
+_A = _enum("drm_fabric_a")
+_PA = _enum("drm_fabric_a_port_attrs")
+_PSA = _enum("drm_fabric_a_port_stats_attrs")
+
+# (wanted name, parsed dict, fallback id) -- fallbacks match the current ABI 
and
+# are only trusted when the header is absent (see main()).
+_WANTED = (
+    ("DRM_FABRIC_CMD_PORT_GET", _CMD, 3),
+    ("DRM_FABRIC_CMD_PORT_STATS_GET", _CMD, 4),
+    ("DRM_FABRIC_A_PORT", _A, 3),
+    ("DRM_FABRIC_A_PORT_STATS", _A, 4),
+    ("DRM_FABRIC_A_PORT_ATTRS_PORT_INDEX", _PA, 1),
+    ("DRM_FABRIC_A_PORT_ATTRS_ENDPOINT_ID", _PA, 2),
+    ("DRM_FABRIC_A_PORT_STATS_ATTRS_ENDPOINT_ID", _PSA, 2),
+    ("DRM_FABRIC_A_PORT_STATS_ATTRS_PORT_INDEX", _PSA, 3),
+)
+CMD_PORT_GET = _CMD.get("DRM_FABRIC_CMD_PORT_GET", 3)
+CMD_PORT_STATS_GET = _CMD.get("DRM_FABRIC_CMD_PORT_STATS_GET", 4)
+A_PORT = _A.get("DRM_FABRIC_A_PORT", 3)
+A_PORT_STATS = _A.get("DRM_FABRIC_A_PORT_STATS", 4)
+PA_PORT_INDEX = _PA.get("DRM_FABRIC_A_PORT_ATTRS_PORT_INDEX", 1)
+PA_ENDPOINT_ID = _PA.get("DRM_FABRIC_A_PORT_ATTRS_ENDPOINT_ID", 2)
+PSA_ENDPOINT_ID = _PSA.get("DRM_FABRIC_A_PORT_STATS_ATTRS_ENDPOINT_ID", 2)
+PSA_PORT_INDEX = _PSA.get("DRM_FABRIC_A_PORT_STATS_ATTRS_PORT_INDEX", 3)
+
+
+def _walk(buf, base, end):
+    """Yield (masked_type, payload) for well-formed nlattrs in 
buf[base:end]."""
+    off = base
+    while off + NLA_HDRLEN <= end:
+        (alen, atype) = struct.unpack_from("=HH", buf, off)
+        if alen < NLA_HDRLEN or off + alen > end:
+            break                       # malformed: stop rather than over-read
+        yield atype & NLA_TYPE_MASK, buf[off + NLA_HDRLEN:off + alen]
+        off += _align4(alen)
+
+
+def _parse_entry(payload, outer, ep_attr, idx_attr):
+    """Extract (endpoint_id, port_index) from one dump reply payload. @outer
+    is the top-level nest (A_PORT/A_PORT_STATS); @ep_attr/@idx_attr are the
+    member ids inside it.
+    """
+    ep_id = port_idx = None
+    for atype, data in _walk(payload, 0, len(payload)):
+        if atype != outer:
+            continue
+        for btype, bdata in _walk(data, 0, len(data)):
+            if btype == ep_attr and len(bdata) >= 4:
+                ep_id = struct.unpack_from("=I", bdata, 0)[0]
+            elif btype == idx_attr and len(bdata) >= 4:
+                port_idx = struct.unpack_from("=I", bdata, 0)[0]
+    return ep_id, port_idx
+
+
+def _read_batch(sock, fam, seq, outer, ep_attr, idx_attr):
+    """Read one dump datagram with strict structural validation.
+
+    Returns (pairs, last_ep, done, err). Any malformed length, unexpected
+    family/sequence, or unparseable reply sets err (reported, not silently
+    dropped).
+    """
+    pairs, last_ep, done, err = [], None, False, False
+    try:
+        data = sock.recv(65536)
+    except socket.timeout:
+        return pairs, last_ep, True, True   # a stall mid-dump is a failure 
here
+    off, end = 0, len(data)
+    while off + NLMSG_HDRLEN <= end:
+        (mlen, mtype, _, mseq, _) = struct.unpack_from("=IHHII", data, off)
+        if mlen < NLMSG_HDRLEN or off + mlen > end:
+            err = True
+            break
+        if mtype == NLMSG_DONE:
+            done = True
+        elif mtype == NLMSG_ERROR:
+            err = True
+        elif mtype == fam and mseq == seq:
+            body = off + NLMSG_HDRLEN + GENL_HDRLEN
+            ep_id, port_idx = _parse_entry(memoryview(data)[body:off + mlen],
+                                           outer, ep_attr, idx_attr)
+            if ep_id is not None and port_idx is not None:
+                pairs.append((ep_id, port_idx))
+                last_ep = ep_id
+            else:
+                err = True
+        else:
+            err = True
+        off += _align4(mlen)
+    return pairs, last_ep, done, err
+
+
+class Cfg:
+    def __init__(self, fam, fab):
+        self.fam = fam
+        self.fab = fab                  # YnlFamily handle (drm-fabric)
+        self.ep_slot = {}               # endpoint-id -> fabricsim slot
+        self.ep_ports = {}              # endpoint-id -> its own port count
+        self.abort = False
+
+
+def _ensure_population(cfg):
+    """Top up to SCALE endpoints and rebuild the endpoint-id -> slot map.
+
+    add_endpoint reuses freed slots, so the map is rebuilt each time.
+    """
+    have = len(cfg.fab.dump("endpoint-get", {}))
+    for _ in range(SCALE - have):
+        try:
+            L.dbg_write("add_endpoint", PORTS)
+        except OSError:
+            break
+    ep_slot = {}
+    for e in cfg.fab.dump("endpoint-get", {}):
+        ep = e["endpoint"]
+        m = re.match(r"sim-ep(\d+)$", ep.get("name", ""))
+        if m:
+            ep_slot[ep["endpoint-id"]] = int(m.group(1))
+    cfg.ep_slot = ep_slot
+
+    # Per-endpoint port count, taken from each endpoint's own topology rather
+    # than the global PORTS: the baseline population and runtime-added 
endpoints
+    # can differ in width, and the "provably mid-dump" oracle below must 
compare
+    # against the specific endpoint being suspended, not a module-wide setting.
+    ep_ports = {}
+    for p in cfg.fab.dump("port-get", {}):
+        port = p["port"]
+        ep_ports[port["endpoint-id"]] = ep_ports.get(port["endpoint-id"], 0) + 
1
+    cfg.ep_ports = ep_ports
+
+
+def _reload():
+    L.rmmod("drm_fabric_sim")
+    if not L.module_loaded("drm_fabric"):
+        if not L.insmod("drm-fabric.ko"):
+            return False
+    # A tiny baseline; the multi-port population is added below.
+    if not L.insmod("drm-fabric-sim.ko", "num_endpoints=2", "ports_per_ep=2",
+                    "topology=linear"):
+        return False
+    L.wait_until(lambda: L.module_loaded("drm_fabric_sim"))
+    return True
+
+
+def _restore_default():
+    """Restore fabricsim's default shape so the next suite (sharing the loaded
+    module) does not inherit this suite's small/churned population."""
+    L.rmmod("drm_fabric_sim")
+    if not L.module_loaded("drm_fabric"):
+        L.insmod("drm-fabric.ko")
+    L.insmod("drm-fabric-sim.ko")
+    L.wait_until(lambda: L.module_loaded("drm_fabric_sim"))
+
+
+def _dump_removal_keeps_leading_ports(ksft, cfg, cmd, outer, ep_attr, idx_attr,
+                                      tag):
+    """Assert every endpoint still starts at port 0 after a mid-endpoint
+    removal. The stats variant also runs the provider callback off the
+    topology lock during resume.
+    """
+    _ensure_population(cfg)
+    s = _open()
+    s.send(_msg(cfg.fam, cmd, SEQ, NLM_F_REQUEST | NLM_F_DUMP))
+
+    seen = {}           # ep_id -> set(port_index) seen so far
+    deleted = set()
+    batches = exercised = 0
+    done = err = False
+    while not done:
+        pairs, last_ep, done, e = _read_batch(s, cfg.fam, SEQ,
+                                              outer, ep_attr, idx_attr)
+        err = err or e
+        if pairs:
+            batches += 1
+            for ep_id, port_idx in pairs:
+                seen.setdefault(ep_id, set()).add(port_idx)
+        # Delete only when provably mid-dump: the cumulative port set is a
+        # strict, non-empty subset of that endpoint's own width, i.e. the
+        # saved cursor is (last_ep, port_idx>0).
+        ep_width = cfg.ep_ports.get(last_ep, PORTS)
+        if (last_ep is not None and last_ep not in deleted
+                and last_ep in cfg.ep_slot
+                and 0 < len(seen.get(last_ep, ())) < ep_width):
+            try:
+                L.dbg_write("del_endpoint", cfg.ep_slot[last_ep])
+                deleted.add(last_ep)
+                exercised += 1
+            except OSError:
+                pass
+        if batches > 10000:
+            break
+    s.close()
+
+    if not ksft.check(batches >= 2 and not err,
+                      "%s-dump-spans-multiple-batches" % tag,
+                      "batches=%d err=%s (raise PORT_CURSOR_SCALE)"
+                      % (batches, err)):
+        cfg.abort = True
+        return
+
+    if not ksft.check(exercised >= 1,
+                      "%s-dump-exercised-mid-endpoint-removal" % tag,
+                      "no batch suspended mid-endpoint "
+                      "(raise PORT_CURSOR_PORTS/PORT_CURSOR_SCALE)"):
+        cfg.abort = True
+        return
+
+    # The invariant: no endpoint may appear missing its leading ports.
+    bad = {ep: sorted(ports)[:3] for ep, ports in seen.items()
+           if 0 not in ports}
+    ksft.check(not bad, "%s-no-leading-ports-dropped" % tag,
+               "endpoints missing port 0: %s"
+               % ", ".join("ep%d=%s" % (e, p) for e, p in bad.items()))
+
+
+def test_port_get_dump_removal_keeps_leading_ports(ksft, cfg):
+    """Nested PORT_GET cursor survives mid-dump endpoint removal."""
+    _dump_removal_keeps_leading_ports(ksft, cfg, CMD_PORT_GET, A_PORT,
+                                      PA_ENDPOINT_ID, PA_PORT_INDEX, 
"port-get")
+
+
+def test_port_stats_dump_removal_keeps_leading_ports(ksft, cfg):
+    """Nested PORT_STATS_GET cursor + unlocked stats callback survive
+    mid-dump endpoint removal."""
+    _dump_removal_keeps_leading_ports(ksft, cfg, CMD_PORT_STATS_GET,
+                                      A_PORT_STATS, PSA_ENDPOINT_ID,
+                                      PSA_PORT_INDEX, "port-stats-get")
+
+
+CASES = (
+    test_port_get_dump_removal_keeps_leading_ports,
+    test_port_stats_dump_removal_keeps_leading_ports,
+)
+
+
+def main():
+    ksft = L.Ksft()
+    _, NlError = L.import_ynl()  # early SKIP if the YNL lib is missing
+
+    if not L.is_root():
+        ksft.skip_all("must run as root (genetlink + debugfs)")
+
+    # Trust parsed ids only when they are complete; require every wanted enum
+    # name when the header is present so a partial parse cannot mis-number.
+    missing = [nm for nm, d, _ in _WANTED if nm not in d]
+    if _HDR_PRESENT and missing:
+        ksft.skip_all("uAPI header present but missing enum(s): %s"
+                      % ", ".join(missing))
+    if not _HDR_PRESENT:
+        print("# port_cursor_abi: uAPI header absent, using id fallbacks")
+
+    if not _reload():
+        ksft.skip_all("could not load drm_fabric_sim")
+    # Primary cleanup is try/finally below; on_teardown() is the backup for 
hard
+    # exits, including the timeout killer's SIGTERM (which atexit would miss),
+    # so this suite's churned population never leaks into the next one.
+    L.on_teardown(_restore_default)
+
+    if not L.debugfs_available():
+        ksft.skip_all("fabricsim debugfs not present")
+
+    try:
+        fab = L.DrmFabric()
+    except (OSError, NlError) as exc:
+        ksft.skip_all("cannot open drm-fabric family: %s" % exc)
+
+    s = _open()
+    fam = _resolve_family(s, L.FAMILY.encode())
+    s.close()
+    if not fam:
+        ksft.skip_all("could not resolve %s family id" % L.FAMILY)
+
+    # Each case tops the multi-port population up to SCALE (so a dump spans 
many
+    # batches) and rebuilds the endpoint-id -> slot map before it runs.
+    try:
+        L.run_cases(ksft, Cfg(fam, fab), CASES)
+    finally:
+        _restore_default()
+    ksft.finish()
+
+
+if __name__ == "__main__":
+    main()
diff --git 
a/tools/testing/selftests/drivers/gpu/drm_fabric/port_stats_cap_abi.py 
b/tools/testing/selftests/drivers/gpu/drm_fabric/port_stats_cap_abi.py
new file mode 100755
index 000000000000..738ee76884f8
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/port_stats_cap_abi.py
@@ -0,0 +1,374 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+"""
+Heterogeneous per-port stats: a dump skips a port whose provider returns
+-EOPNOTSUPP and resumes; any other errno ends the dump. pyynl reassembles
+multipart dumps transparently, so this talks raw Generic Netlink.
+
+Needs drm_fabric + drm_fabric_sim (>= 3 ports on ep0), fabricsim debugfs
+(per-port stats_errno, bulk_add/bulk_del), and root.
+"""
+
+import glob
+import os
+import re
+import socket
+import struct
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import lib_drm_fabric as L  # noqa: E402 - shared KTAP/module helpers
+
+EOPNOTSUPP = 95
+EIO = 5
+MID = 1  # not the first/last port, so skipping it proves resumption
+
+# Runtime endpoints added for the skip case. ep0 is enumerated first, so its
+# skipped port is necessarily behind a batch boundary from everything added
+# here. Overridable where record size or NLMSG_GOODSIZE change the batch
+# arithmetic enough to leave the dump single-batch.
+RESUME_SCALE = int(os.environ.get("STATS_RESUME_SCALE", "300"))
+
+# --- raw Generic Netlink (cf. nl_policy_probe.py, dump_intr_abi.py) -------
+
+FAMILY_NAME = b"drm-fabric"
+
+NETLINK_GENERIC = 16
+NLMSG_ERROR = 0x2
+NLMSG_DONE = 0x3
+NLM_F_REQUEST = 0x01
+NLM_F_DUMP = 0x300
+NLMSG_HDRLEN = 16
+GENL_HDRLEN = 4
+CTRL_ID = 0x10
+CTRL_CMD_GETFAMILY = 3
+CTRL_ATTR_FAMILY_ID = 1
+CTRL_ATTR_FAMILY_NAME = 2
+NLA_TYPE_MASK = 0x3FFF  # strips NLA_F_NESTED / NLA_F_NET_BYTEORDER
+
+# Small reads keep dump batches small, making resume easier to exercise.
+BATCH_READ = 8192
+
+
+def _align4(n):
+    return (n + 3) & ~3
+
+
+def _nla(attr_type, payload):
+    length = 4 + len(payload)
+    pad = b"\x00" * (_align4(length) - length)
+    return struct.pack("=HH", length, attr_type) + payload + pad
+
+
+def _msg(family_id, cmd, seq, flags, payload=b""):
+    body = struct.pack("=BBH", cmd, 1, 0) + payload
+    total = NLMSG_HDRLEN + len(body)
+    return struct.pack("=IHHII", total, family_id, flags, seq, 0) + body
+
+
+def _open():
+    s = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_GENERIC)
+    s.bind((0, 0))
+    s.settimeout(5)
+    return s
+
+
+def _resolve_family(sock, name):
+    sock.send(_msg(CTRL_ID, CTRL_CMD_GETFAMILY, 1, NLM_F_REQUEST,
+                   _nla(CTRL_ATTR_FAMILY_NAME, name + b"\x00")))
+    data = sock.recv(8192)
+    (_, mtype, _, _, _) = struct.unpack_from("=IHHII", data, 0)
+    if mtype == NLMSG_ERROR:
+        return None
+    for atype, payload in _iter_attrs(data[NLMSG_HDRLEN + GENL_HDRLEN:]):
+        if atype != CTRL_ATTR_FAMILY_ID:
+            continue
+        # CTRL_ATTR_FAMILY_ID is a u16; tolerate a u32 encoding too.
+        if len(payload) >= 4:
+            return struct.unpack_from("=I", payload)[0]
+        if len(payload) >= 2:
+            return struct.unpack_from("=H", payload)[0]
+    return None
+
+
+def _iter_attrs(blob):
+    off = 0
+    while off + 4 <= len(blob):
+        (alen, atype) = struct.unpack_from("=HH", blob, off)
+        if alen < 4:
+            break
+        yield atype & NLA_TYPE_MASK, blob[off + 4:off + alen]
+        off += _align4(alen)
+
+
+def _uapi_ids():
+    """Stats-dump ids from the generated uAPI header.
+
+    No fallback table: an unreadable header skips the suite rather than
+    decoding under a stale guess.
+    """
+    want = ("DRM_FABRIC_CMD_PORT_STATS_GET",
+            "DRM_FABRIC_A_PORT_STATS",
+            "DRM_FABRIC_A_PORT_STATS_ATTRS_ENDPOINT_ID",
+            "DRM_FABRIC_A_PORT_STATS_ATTRS_PORT_INDEX")
+    try:
+        text = open(L.UAPI_HEADER).read()
+    except OSError:
+        return None
+    # Commands are an anonymous enum and attributes are named ones, so merge
+    # every block flatly rather than parsing by enum name.
+    syms = {}
+    for body in re.findall(r"enum\s*(?:\w+\s*)?\{(.*?)\}", text, re.S):
+        nxt = 0
+        for raw in body.split(","):
+            item = raw.split("/*")[0].strip()
+            if not item:
+                continue
+            if "=" in item:
+                name, val = item.split("=", 1)
+                name = name.strip()
+                try:
+                    nxt = int(val.strip(), 0)
+                except ValueError:
+                    continue
+            else:
+                name = item
+            if name.isidentifier():
+                syms[name] = nxt
+            nxt += 1
+    if not all(w in syms for w in want):
+        return None
+    return {"cmd": syms[want[0]], "nest": syms[want[1]],
+            "ep": syms[want[2]], "port": syms[want[3]]}
+
+
+def _record(body, ids):
+    """(endpoint-id, port-index) carried by one stats entry, or None."""
+    for atype, payload in _iter_attrs(body):
+        if atype != ids["nest"]:
+            continue
+        ep = idx = None
+        for natype, npayload in _iter_attrs(payload):
+            if natype == ids["ep"] and len(npayload) >= 4:
+                ep = struct.unpack_from("=I", npayload)[0]
+            elif natype == ids["port"] and len(npayload) >= 4:
+                idx = struct.unpack_from("=I", npayload)[0]
+        if ep is not None and idx is not None:
+            return (ep, idx)
+    return None
+
+
+class Dump:
+    """Records in wire order, plus how the kernel delivered them."""
+
+    def __init__(self, batches, records, done, error, timed_out):
+        self.batches = batches
+        self.records = records
+        self.done = done
+        self.error = error
+        self.timed_out = timed_out
+
+    @property
+    def ok(self):
+        return self.done and not self.error and not self.timed_out
+
+    def __str__(self):
+        return ("batches=%d records=%d done=%s error=%s timeout=%s"
+                % (self.batches, len(self.records), self.done, self.error,
+                   self.timed_out))
+
+
+def _dump_stats(ids):
+    """Run an unfiltered PORT_STATS_GET dump batch by batch."""
+    sock = _open()
+    try:
+        sock.send(_msg(ids["fam"], ids["cmd"], 2, NLM_F_REQUEST | NLM_F_DUMP))
+        batches, recs, done, err = 0, [], False, False
+        while not done:
+            try:
+                data = sock.recv(BATCH_READ)
+            except socket.timeout:
+                return Dump(batches, recs, done, err, True)
+            if not data:
+                break
+            batches += 1
+            off = 0
+            while off + NLMSG_HDRLEN <= len(data):
+                (mlen, mtype, _, _, _) = struct.unpack_from("=IHHII", data, 
off)
+                if mlen < NLMSG_HDRLEN:
+                    break
+                if mtype == NLMSG_DONE:
+                    done = True
+                elif mtype == NLMSG_ERROR:
+                    err = True
+                else:
+                    rec = _record(data[off + NLMSG_HDRLEN + GENL_HDRLEN:
+                                       off + mlen], ids)
+                    if rec:
+                        recs.append(rec)
+                off += _align4(mlen)
+            if batches > 10000:  # runaway guard
+                break
+        return Dump(batches, recs, done, err, False)
+    finally:
+        sock.close()
+
+
+def _grow(ids):
+    """Grow until the dump spans several batches.
+
+    bulk_add is asynchronous, so read back until two dumps agree.
+    """
+    L.dbg_write("bulk_add", RESUME_SCALE)
+    last, deadline = -1, time.monotonic() + 10.0
+    while time.monotonic() < deadline:
+        now = len(_dump_stats(ids).records)
+        if now == last:
+            return now
+        last = now
+        time.sleep(0.05)
+    return last
+
+
+def _ep0(fab):
+    """endpoint-id of the first init endpoint (debugfs dir ep0, name 
sim-ep0)."""
+    for e in fab.dump("endpoint-get", {}):
+        ep = e["endpoint"]
+        if ep.get("name") == "sim-ep0":
+            return ep["endpoint-id"]
+    return None
+
+
+def _port_count(fab, ep_id):
+    return len(fab.dump("port-get", {"endpoint-id": ep_id}))
+
+
+def _knob(port, val):
+    # fabricsim's per-port debugfs control: forces @port's next stats
+    # callback to return -@val instead of real data.
+    L.dbg_write("ep0/port%d/stats_errno" % port, val)
+
+
+class Cfg:
+    def __init__(self, fab, ep_id, nports, nl_error, ids):
+        self.fab = fab
+        self.ep_id = ep_id
+        self.nports = nports
+        self.NlError = nl_error
+        self.ids = ids
+
+
+def test_dump_skips_unsupported_port(ksft, cfg):
+    """A mid-list -EOPNOTSUPP port is skipped across a real batch boundary.
+
+    Expectation is the unknobbed dump minus exactly that port, so a lost
+    neighbour, a re-emit after resume, or a duplicate all fail.
+    """
+    name = "stats-dump-skips-unsupported"
+    ids = cfg.ids
+    skipped = (cfg.ep_id, MID)
+    try:
+        _grow(ids)
+        base = _dump_stats(ids)
+        if not base.ok or not base.records:
+            ksft.not_ok(name, "unknobbed dump unusable: %s" % base)
+            return
+        _knob(MID, EOPNOTSUPP)
+        got = _dump_stats(ids)
+        if not got.ok:
+            ksft.not_ok(name, "dump did not complete: %s" % got)
+            return
+        lost = sorted(set(base.records) - set(got.records))
+        extra = sorted(set(got.records) - set(base.records))
+        dupes = len(got.records) != len(set(got.records))
+        ksft.check(got.batches >= 2 and lost == [skipped] and not extra
+                   and not dupes,
+                   name,
+                   "%s lost=%s extra=%s dupes=%s (want lost=[%s], >=2 "
+                   "batches -- raise STATS_RESUME_SCALE if single-batch)"
+                   % (got, lost, extra, dupes, skipped))
+    finally:
+        _knob(MID, 0)
+        L.dbg_write("bulk_del", 0)
+
+
+def test_targeted_unsupported_port_eopnotsupp(ksft, cfg):
+    """A targeted request for the unsupported port still returns 
-EOPNOTSUPP."""
+    fab, NlError = cfg.fab, cfg.NlError
+    _knob(MID, EOPNOTSUPP)
+    try:
+        fab.do("port-stats-get", {"endpoint-id": cfg.ep_id, "port-index": MID})
+        ksft.not_ok("stats-targeted-unsupported-eopnotsupp", "request 
accepted")
+    except NlError as exc:
+        e = L.nl_errno(exc)
+        ksft.check(e == EOPNOTSUPP, "stats-targeted-unsupported-eopnotsupp",
+                   "errno=%d (want EOPNOTSUPP=%d)" % (e, EOPNOTSUPP))
+    finally:
+        _knob(MID, 0)
+
+
+def test_dump_aborts_on_real_error(ksft, cfg):
+    """A non-capability provider error (EIO) ends the dump."""
+    fab, NlError = cfg.fab, cfg.NlError
+    _knob(MID, EIO)
+    try:
+        try:
+            fab.dump("port-stats-get", {"endpoint-id": cfg.ep_id})
+            ksft.not_ok("stats-dump-aborts-on-real-error",
+                        "dump completed instead of aborting")
+        except NlError as exc:
+            e = L.nl_errno(exc)
+            ksft.check(e == EIO, "stats-dump-aborts-on-real-error",
+                       "errno=%d (want EIO=%d)" % (e, EIO))
+    finally:
+        _knob(MID, 0)
+
+
+CASES = (
+    test_dump_skips_unsupported_port,
+    test_targeted_unsupported_port_eopnotsupp,
+    test_dump_aborts_on_real_error,
+)
+
+
+def main():
+    ksft = L.Ksft()
+    _, NlError = L.import_ynl()
+
+    with L.fabricsim(ksft, need_debugfs=True) as fab:
+        if not L.family_has_op(fab, "port-stats-get"):
+            ksft.skip_all("port-stats-get op absent")
+        ep_id = _ep0(fab)
+        if ep_id is None:
+            ksft.skip_all("sim-ep0 endpoint not present")
+        # The per-port stats_errno knob only exists on a recent simulator.
+        if not glob.glob(os.path.join(L.DEBUGFS, "ep0", "port*", 
"stats_errno")):
+            ksft.skip_all("fabricsim lacks per-port stats_errno knob (old 
module)")
+        # The skip case needs a population big enough to span dump batches.
+        if not os.path.exists(os.path.join(L.DEBUGFS, "bulk_add")):
+            ksft.skip_all("fabricsim lacks bulk_add (cannot reach a resume "
+                          "boundary)")
+        nports = _port_count(fab, ep_id)
+        if nports < 3:
+            ksft.skip_all("need >= 3 ports on ep0 to place a mid-list skip "
+                          "(have %d)" % nports)
+        ids = _uapi_ids()
+        if ids is None:
+            ksft.skip_all("could not resolve stats uAPI ids from %s"
+                          % L.UAPI_HEADER)
+        sock = _open()
+        try:
+            fam = _resolve_family(sock, FAMILY_NAME)
+        finally:
+            sock.close()
+        if fam is None:
+            ksft.skip_all("drm-fabric generic netlink family not resolvable")
+        ids["fam"] = fam
+        L.run_cases(ksft, Cfg(fab, ep_id, nports, NlError, ids), CASES)
+    ksft.finish()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/settings 
b/tools/testing/selftests/drivers/gpu/drm_fabric/settings
new file mode 100644
index 000000000000..694d70710ff0
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/settings
@@ -0,0 +1 @@
+timeout=300
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/switch_abi.py 
b/tools/testing/selftests/drivers/gpu/drm_fabric/switch_abi.py
new file mode 100755
index 000000000000..775be4ac2160
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/switch_abi.py
@@ -0,0 +1,108 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+"""
+fabricsim's "switch" shape links each leaf's first port to an opaque
+switch that is not a registered endpoint: asserts half-edge serialization
+and peer-id non-resolution, not leaf-switch-leaf reachability.
+
+--no-load is ignored (needs a fresh insmod). Run as root.
+"""
+
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import lib_drm_fabric as L
+
+
+def ports_of(fab, ep_id):
+    return [p["port"] for p in fab.dump("port-get", {"endpoint-id": ep_id})]
+
+
+def switch_peers(fab, sim_eps):
+    """All TYPE=switch peers across the sim leaves: [(ep, peer), ...]."""
+    out = []
+    for e in sim_eps:
+        for p in ports_of(fab, e["endpoint-id"]):
+            peer = p.get("peer")
+            if peer and peer.get("type") == "switch":
+                out.append((e, peer))
+    return out
+
+
+class Cfg:
+    def __init__(self, fab, sim_eps, sw_peers):
+        self.fab = fab
+        self.sim_eps = sim_eps
+        self.sw_peers = sw_peers
+        self.abort = False
+
+
+def test_every_leaf_has_switch_peer(ksft, cfg):
+    """Every leaf endpoint has a port with a TYPE=switch half-edge."""
+    ksft.check(len(cfg.sw_peers) == len(cfg.sim_eps),
+               "switch-every-leaf-has-switch-peer",
+               "switch-peers=%d leaves=%d"
+               % (len(cfg.sw_peers), len(cfg.sim_eps)))
+    if not cfg.sw_peers:
+        cfg.abort = True
+
+
+def test_half_edge_fully_serialized(ksft, cfg):
+    """Every switch half-edge carries all three peer fields."""
+    complete = all(
+        {"peer-id", "type", "port-index"} <= set(peer)
+        for _, peer in cfg.sw_peers)
+    ksft.check(complete, "switch-half-edge-fully-serialized",
+               "a switch peer is missing peer-id/type/port-index")
+
+
+def test_single_opaque_switch_id(ksft, cfg):
+    """All leaves name one opaque switch id, each via a distinct switch 
port."""
+    ids = {peer["peer-id"] for _, peer in cfg.sw_peers}
+    ports = [peer["port-index"] for _, peer in cfg.sw_peers]
+    ksft.check(len(ids) == 1, "switch-single-opaque-id",
+               "switch peer-ids=%s" % sorted(ids))
+    ksft.check(len(set(ports)) == len(ports), "switch-distinct-switch-ports",
+               "switch-side port-indexes=%s" % sorted(ports))
+
+
+def test_switch_id_does_not_resolve(ksft, cfg):
+    """The opaque switch id is not a registered endpoint (local adjacency)."""
+    ep_fepids = {e["fabric-ep-id"] for e in cfg.sim_eps}
+    sw_ids = {peer["peer-id"] for _, peer in cfg.sw_peers}
+    leaked = sw_ids & ep_fepids
+    ksft.check(not leaked, "switch-id-does-not-resolve-to-endpoint",
+               "switch id resolves to an endpoint fabric-ep-id: %s"
+               % sorted(leaked))
+
+
+CASES = (
+    test_every_leaf_has_switch_peer,
+    test_half_edge_fully_serialized,
+    test_single_opaque_switch_id,
+    test_switch_id_does_not_resolve,
+)
+
+
+def main():
+    ksft = L.Ksft()
+
+    # The switch shape is an insmod parameter, so fabricsim is always reloaded
+    # with topology=switch (ignoring --no-load) and restored to default on 
exit.
+    with L.fabricsim(ksft, topology="switch") as fab:
+        eps = [e["endpoint"] for e in fab.dump("endpoint-get", {})]
+        # Restrict to fabricsim's members (ignore anything a prior suite left).
+        sim_eps = [e for e in eps if e["name"].startswith("sim-ep")]
+        if len(sim_eps) < 2:
+            ksft.skip_all("switch topology needs >= 2 endpoints, got %d"
+                          % len(sim_eps))
+
+        sw_peers = switch_peers(fab, sim_eps)
+        L.run_cases(ksft, Cfg(fab, sim_eps, sw_peers), CASES)
+    ksft.finish()
+
+
+if __name__ == "__main__":
+    main()
-- 
2.43.0

Reply via email to