linux-doc.vger.kernel.org archive mirror
 help / color / mirror / Atom feed
* [RFC PATCH 0/12] drm/fabric: vendor-neutral topology infrastructure for scale-up accelerator interconnects
@ 2026-08-24  8:09 Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 01/12] drm/fabric: add core object model and provider API Konstantin Sinyuk
                   ` (11 more replies)
  0 siblings, 12 replies; 13+ messages in thread
From: Konstantin Sinyuk @ 2026-08-24  8:09 UTC (permalink / raw)
  To: dri-devel
  Cc: Maarten Lankhorst, Francois Dugast, David Airlie, Simona Vetter,
	Maxime Ripard, Thomas Zimmermann, Jonathan Corbet, Shuah Khan,
	Donald Hunter, Jakub Kicinski, David S. Miller, Eric Dumazet,
	Paolo Abeni, Simon Horman, Ilia Levi, Rodrigo Vivi, linux-doc,
	linux-kselftest, netdev, linux-kernel

Modern GPUs and AI accelerators are increasingly connected through scale-up
fabrics such as AMD xGMI today and emerging UALink systems [4]. Linux lacks
common, vendor-neutral infrastructure for reporting which accelerators are
directly connected, through which ports, and in what state.

Vendors expose fragments through driver-specific interfaces such as amdgpu's
xGMI sysfs [3] and AMD's UALink pod-configuration sysfs [6], while prior
per-driver proposals such as XeLink never became shared infrastructure, so
topology semantics remain per-driver.

Building on the LPC 2025 "Toward Mainline Linux Support for UALink" BoF
[1], this RFC proposes DRM Fabric: vendor-neutral, protocol-agnostic DRM
topology infrastructure for scale-up interconnects, using the model

    fabric -> endpoint -> port -> peer

A fabric groups the endpoints of one provider-defined interconnect
instance. An endpoint is one accelerator attachment holding its physical
ports. A port reports lane capability, operational state and optional
counters. A peer is a typed value naming the directly adjacent accelerator
or switch port, not a reference to a live kernel object; it may identify an
accelerator managed by another OS or an opaque switch in another trust
domain.

The core records direct adjacency only, not end-to-end reachability or
switch forwarding, which remain with the fabric controller. Vendor drivers
retain hardware discovery, firmware interaction, memory semantics and the
hardware-carried data path. DRM Fabric represents topology and control
state of DRM-managed accelerators; it does not create a network device or
own route computation, switch forwarding, transport, or congestion control.

Generic Netlink fits this multi-object, event-driven model better than
sysfs, which maps poorly onto dump enumeration and asynchronous
notifications, and provides the YAML-described uAPI discipline introduced
to DRM by DRM RAS [2]. Devlink was considered, but models devices and
subordinate objects rather than a fabric spanning DRM devices.

Patches 1-6 form a complete, separately tested read-only milestone that can
be merged independently while provisioning remains under review. Providers
publish objects, adjacency, operational state and optional port statistics
through a small in-kernel API; userspace queries the live graph but cannot
modify it.

Patches 7-12 add privileged provisioning for software-defined fabrics.
Userspace can create and delete empty fabrics, attach orphan endpoints
registered without a fabric, request administrative state and manage peer
adjacency, while the provider performs the hardware programming.
Administrative state records control-plane intent, operational state
remains provider-reported, and each port is managed either by its provider
or by userspace. The mutation operations are fabric-new, fabric-del,
endpoint-set, port-set, port-peer-new and port-peer-del.

Provider callbacks may sleep or report state back into the core, so
drm_fabric_lock is not held across them. Provisioning instead uses an outer
mutation lock across validation, callback and commit. The complete locking,
lifetime and failure contracts are documented in
Documentation/gpu/drm-fabric.rst.

Every operation is confined to the initial network namespace, and mutation
additionally requires CAP_NET_ADMIN.

drm_fabric_sim is a software-only provider modeled on netdevsim [5]. It
registers linear, mesh and switch-facing topologies without accelerator
hardware and gains provisioning callbacks and fault injection. Its debugfs
interface is not ABI; the reviewed interface is the Generic Netlink family.

The series defines no accelerator memory sharing, MMU programming, data
transport, route computation, switch policy, key management, live
migration, required userspace daemon or protocol-specific commands.

No production provider is included. Shipping only the synthetic provider is
deliberate, allowing the object model and uAPI to be reviewed before being
tied to hardware. We used the public amdgpu xGMI implementation to shape
the provider API, but that mapping has not been validated by AMD.

Example queries using the in-tree YNL tool are:

    $ ./tools/net/ynl/pyynl/cli.py \
          --spec Documentation/netlink/specs/drm_fabric.yaml \
          --dump fabric-get
    $ ./tools/net/ynl/pyynl/cli.py \
          --spec Documentation/netlink/specs/drm_fabric.yaml \
          --dump endpoint-get --json '{"fabric-id": <id>}'
    $ ./tools/net/ynl/pyynl/cli.py \
          --spec Documentation/netlink/specs/drm_fabric.yaml \
          --do port-get --json '{"endpoint-id": <id>, "port-index": 0}'

Testing includes 12/12 per-commit W=1 builds, 43 passing KUnit tests and
172 passing kselftest results across 14 programs. KASAN, UBSAN, lockdep,
atomic-sleep and kmemleak validation completed without reports.

The series is also available from the public review tree:

  https://git.kernel.org/pub/scm/linux/kernel/git/ksinyuk/linux.git/tag/?h=drm-fabric-v1

We are specifically requesting feedback on:

  1. Is fabric -> endpoint -> port -> peer the right minimum common DRM
     representation for accelerator interconnects?

  2. Are the Generic Netlink semantics, including dump consistency,
     notifications and optional port statistics, aligned with YNL
     expectations?

  3. Do the object model and provider API cover xGMI topology-management
     requirements, or would any xGMI requirement force protocol-specific
     uAPI or provider semantics? AMD's assessment would be especially
     valuable.

[1] LPC 2025 BoF, "Toward Mainline Linux Support for UALink":
    https://lpc.events/event/19/contributions/2308/

[2] Riana Tauro, "[PATCH v10 0/5] Introduce DRM_RAS using generic netlink
    for RAS", 2026-03-04:
    https://lore.kernel.org/dri-devel/20260304074412.464435-7-riana.tauro@intel.com

[3] amdgpu xGMI topology:
    drivers/gpu/drm/amd/amdgpu/amdgpu_xgmi.c

[4] UALink specification:
    https://ualinkconsortium.org/specification/

[5] netdevsim:
    Documentation/networking/devlink/netdevsim.rst

[6] Alex Deucher, "[PATCH 00/95] Add UALink instrastructure series 1",
    2026-08-21:
    https://lore.kernel.org/amd-gfx/20260821193458.808626-1-alexander.deucher@amd.com/

Ilia Levi (4):
  drm/fabric: add core object model and provider API
  drm/fabric: add query uAPI and generated headers
  drm/fabric: add topology-provisioning core
  drm/fabric: add provisioning netlink uAPI

Konstantin Sinyuk (8):
  drm/fabric: implement query netlink operations
  drm/fabric: add read-only synthetic provider
  drm/fabric: add object-model KUnit tests
  drm/fabric: add YNL query and policy selftests
  drm/fabric: implement mutation netlink operations
  drm/fabric: make the synthetic provider writable
  drm/fabric: add mutation KUnit tests
  drm/fabric: add mutation netlink selftests

 Documentation/gpu/drm-fabric.rst              |  479 +++
 Documentation/gpu/index.rst                   |    1 +
 Documentation/netlink/specs/drm_fabric.yaml   |  580 ++++
 MAINTAINERS                                   |   14 +
 drivers/gpu/drm/Kconfig                       |    1 +
 drivers/gpu/drm/Makefile                      |    1 +
 drivers/gpu/drm/fabric/.kunitconfig           |    5 +
 drivers/gpu/drm/fabric/.kunitconfig.debug     |   16 +
 drivers/gpu/drm/fabric/Kconfig                |   41 +
 drivers/gpu/drm/fabric/Makefile               |    9 +
 drivers/gpu/drm/fabric/drm_fabric.c           | 1045 +++++++
 drivers/gpu/drm/fabric/drm_fabric_internal.h  |   66 +
 drivers/gpu/drm/fabric/drm_fabric_netlink.c   | 1310 ++++++++
 drivers/gpu/drm/fabric/drm_fabric_nl.c        |  231 ++
 drivers/gpu/drm/fabric/drm_fabric_nl.h        |   66 +
 drivers/gpu/drm/fabric/drm_fabric_sim.c       | 1044 +++++++
 drivers/gpu/drm/fabric/drm_fabric_test.c      | 2635 +++++++++++++++++
 include/drm/drm_fabric.h                      |  333 +++
 include/uapi/drm/drm_fabric.h                 |  162 +
 tools/testing/selftests/Makefile              |    1 +
 .../selftests/drivers/gpu/drm_fabric/Makefile |   44 +
 .../drivers/gpu/drm_fabric/README.rst         |  146 +
 .../drivers/gpu/drm_fabric/cap_netadmin.py    |  314 ++
 .../gpu/drm_fabric/check-spec-regen.sh        |  114 +
 .../selftests/drivers/gpu/drm_fabric/config   |   13 +
 .../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      |  553 ++++
 .../drivers/gpu/drm_fabric/fault_abi.py       |  302 ++
 .../gpu/drm_fabric/harness_reset_abi.py       |  113 +
 .../drivers/gpu/drm_fabric/hotplug_abi.py     |  331 +++
 .../drivers/gpu/drm_fabric/lib_drm_fabric.py  |  498 ++++
 .../drivers/gpu/drm_fabric/netns_abi.py       |  294 ++
 .../drivers/gpu/drm_fabric/nl_policy_probe.py |  651 ++++
 .../drivers/gpu/drm_fabric/port_cursor_abi.py |  401 +++
 .../gpu/drm_fabric/port_stats_cap_abi.py      |  374 +++
 .../drm_fabric/provisioning_scenarios_abi.py  |  324 ++
 .../selftests/drivers/gpu/drm_fabric/settings |    1 +
 .../drivers/gpu/drm_fabric/switch_abi.py      |  137 +
 39 files changed, 13189 insertions(+)
 create mode 100644 Documentation/gpu/drm-fabric.rst
 create mode 100644 Documentation/netlink/specs/drm_fabric.yaml
 create mode 100644 drivers/gpu/drm/fabric/.kunitconfig
 create mode 100644 drivers/gpu/drm/fabric/.kunitconfig.debug
 create mode 100644 drivers/gpu/drm/fabric/Kconfig
 create mode 100644 drivers/gpu/drm/fabric/Makefile
 create mode 100644 drivers/gpu/drm/fabric/drm_fabric.c
 create mode 100644 drivers/gpu/drm/fabric/drm_fabric_internal.h
 create mode 100644 drivers/gpu/drm/fabric/drm_fabric_netlink.c
 create mode 100644 drivers/gpu/drm/fabric/drm_fabric_nl.c
 create mode 100644 drivers/gpu/drm/fabric/drm_fabric_nl.h
 create mode 100644 drivers/gpu/drm/fabric/drm_fabric_sim.c
 create mode 100644 drivers/gpu/drm/fabric/drm_fabric_test.c
 create mode 100644 include/drm/drm_fabric.h
 create mode 100644 include/uapi/drm/drm_fabric.h
 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/cap_netadmin.py
 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/netns_abi.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 100755 tools/testing/selftests/drivers/gpu/drm_fabric/provisioning_scenarios_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


base-commit: e3e05bddbe9fea67639d2a3551e7cf2d473b804c
-- 
2.43.0


^ permalink raw reply	[flat|nested] 13+ messages in thread

* [RFC PATCH 01/12] drm/fabric: add core object model and provider API
  2026-08-24  8:09 [RFC PATCH 0/12] drm/fabric: vendor-neutral topology infrastructure for scale-up accelerator interconnects Konstantin Sinyuk
@ 2026-08-24  8:09 ` Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 02/12] drm/fabric: add query uAPI and generated headers Konstantin Sinyuk
                   ` (10 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Konstantin Sinyuk @ 2026-08-24  8:09 UTC (permalink / raw)
  To: dri-devel
  Cc: Maarten Lankhorst, Francois Dugast, David Airlie, Simona Vetter,
	Maxime Ripard, Thomas Zimmermann, Jonathan Corbet, Shuah Khan,
	Donald Hunter, Jakub Kicinski, David S. Miller, Eric Dumazet,
	Paolo Abeni, Simon Horman, Ilia Levi, Rodrigo Vivi, linux-doc,
	linux-kselftest, netdev, linux-kernel

From: Ilia Levi <ilia.levi@intel.com>

Accelerator drivers currently expose interconnect topology through
driver-specific interfaces, leaving topology semantics fragmented across
drivers. Add a common DRM object model for fabrics, endpoints, ports and
directly adjacent peers, together with the provider API drivers use to
populate it:

    fabric -> endpoint -> port -> peer

A fabric groups the endpoints of one provider-defined interconnect
instance. An endpoint owns a fixed set of ports. A port may carry a
type-qualified peer value describing the accelerator or switch directly
adjacent at the far end of its link; the peer need not resolve to another
live kernel object. The core assigns live-object IDs, manages object
lifetime and enforces the model's invariants. The complete object, peer,
locking and lifecycle contracts are documented in
Documentation/gpu/drm-fabric.rst, added here.

CONFIG_DRM_FABRIC builds the core as drm-fabric.ko and depends on NET for
the Generic Netlink uAPI added next. No provider uses the API yet, so
loading the module registers no fabric objects.

Add a MAINTAINERS entry for the subsystem.

Split the core and generated uAPI header across this patch and the next to
keep the object model and wire contract separately reviewable while both
commits remain buildable. Temporarily define the uAPI value enums locally;
the generated header replaces them in the next patch.

Signed-off-by: Ilia Levi <ilia.levi@intel.com>
Co-developed-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Signed-off-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Assisted-by: GitHub-Copilot:claude-opus-4.8
---
 Documentation/gpu/drm-fabric.rst             | 142 ++++
 Documentation/gpu/index.rst                  |   1 +
 MAINTAINERS                                  |  14 +
 drivers/gpu/drm/Kconfig                      |   1 +
 drivers/gpu/drm/Makefile                     |   1 +
 drivers/gpu/drm/fabric/Kconfig               |  14 +
 drivers/gpu/drm/fabric/Makefile              |   4 +
 drivers/gpu/drm/fabric/drm_fabric.c          | 681 +++++++++++++++++++
 drivers/gpu/drm/fabric/drm_fabric_internal.h |  34 +
 include/drm/drm_fabric.h                     | 259 +++++++
 10 files changed, 1151 insertions(+)
 create mode 100644 Documentation/gpu/drm-fabric.rst
 create mode 100644 drivers/gpu/drm/fabric/Kconfig
 create mode 100644 drivers/gpu/drm/fabric/Makefile
 create mode 100644 drivers/gpu/drm/fabric/drm_fabric.c
 create mode 100644 drivers/gpu/drm/fabric/drm_fabric_internal.h
 create mode 100644 include/drm/drm_fabric.h

diff --git a/Documentation/gpu/drm-fabric.rst b/Documentation/gpu/drm-fabric.rst
new file mode 100644
index 000000000000..99b189dc614e
--- /dev/null
+++ b/Documentation/gpu/drm-fabric.rst
@@ -0,0 +1,142 @@
+.. SPDX-License-Identifier: (GPL-2.0+ OR MIT)
+
+===============================
+DRM Fabric over Generic Netlink
+===============================
+
+Modern GPUs and dedicated AI accelerators are increasingly connected through
+scale-up interconnect fabrics such as AMD xGMI and
+`UALink <https://ualinkconsortium.org/specification/>`__.
+
+DRM Fabric is a registry and dispatcher. It does not discover routes, compute
+reachability, program switch forwarding, manage device memory, or provide a
+data path; those remain with the vendor driver and the fabric controller.
+
+Key Goals:
+
+* Provide a standardized topology model for GPU and accelerator interconnects
+  (xGMI, UALink and similar), enabling data-center discovery and monitoring.
+* Support read-only enumeration, monitoring and state queries for
+  provider-owned topology.
+* Offer a flexible, future-proof interface that can be extended with new fabric
+  types and attributes without breaking the uAPI.
+* Allow multiple endpoints and ports per provider, so drivers can model
+  accelerator attachments, links and their peers.
+
+.. contents::
+
+Object model
+============
+
+DRM Fabric models interconnect topology with four object types::
+
+    fabric
+      `-- endpoint
+            `-- port
+                  `-- peer (optional value descriptor)
+
+A *fabric* is a membership group of *endpoints*; each endpoint represents one
+accelerator attachment and owns a fixed set of *ports*; and a *port* may carry a
+*peer* describing the device directly adjacent at the far end of its link, which
+is either another accelerator or a fabric switch.
+
+Identity is layered. The core assigns kernel-local ``fabric-id`` and
+``endpoint-id`` values that address live registry objects for the duration of
+their registration. Providers supply an endpoint ``fabric-ep-id`` -- an
+accelerator's identity within its fabric's identity domain. A peer instead
+carries a type-qualified ``peer-id``: for ``peer-type = accel`` it is the
+far-end accelerator's ``fabric-ep-id``, and for ``peer-type = switch`` it is an
+opaque provider-defined switch identity that names no local object. Accelerator
+and switch identities occupy separate namespaces selected by ``peer-type``, so
+the same numeric value may name different objects under each type.
+
+Fabric membership does not imply end-to-end reachability, and the topology is
+not necessarily a tree. The registered shape reflects the direct adjacency the
+provider reports: for example, it may be a full mesh with no root, a linear
+chain, or a switch-based topology in which ports terminate at opaque switch
+peers rather than locally registered endpoints.
+
+A *peer* is a value descriptor, not a reference to a live kernel object: its
+``peer-id`` may name a remote accelerator managed by another OS or an opaque
+switch in another trust domain, and need not resolve in the local registry. The
+core stores one directed half-edge and does not require the reverse half-edge to
+exist, so removing an endpoint does not retract peer descriptors held by other
+endpoints. ``peer-type = switch`` only describes the kind of far end; it does
+not create a first-class switch object.
+
+.. kernel-doc:: drivers/gpu/drm/fabric/drm_fabric.c
+   :doc: DRM Fabric core
+
+Peer semantics
+--------------
+
+A peer is an identity rather than a reference to a live object, and the
+difference decides what the core reports. A ``peer-id`` that resolves in the
+local registry today may stop resolving later because the endpoint it named
+unregistered, and no event is emitted on the port still carrying it: a peer
+is recorded on one local half-edge, and peer disappearance does not retract
+that half-edge. A peer is therefore topology as last set, not proof of live
+connectivity; liveness belongs to the fabric controller.
+
+The core never retracts a half-edge on its own. Failing to resolve a peer
+locally is not the same as the link going away -- the far end may be a switch,
+an accelerator on another node, or a local endpoint that merely unregistered
+-- so only the provider knows when a port's physical adjacency actually
+changed, and only the provider retracts or replaces the descriptor.
+
+Endpoint teardown removes the endpoint's owned half-edges without generating
+a separate event for each port: the delete already describes the transition,
+so removing an endpoint advances the topology generation once rather than
+once per child port.
+
+Driver API
+----------
+
+.. kernel-doc:: include/drm/drm_fabric.h
+   :internal:
+
+.. kernel-doc:: drivers/gpu/drm/fabric/drm_fabric.c
+   :export:
+
+Design scope and boundaries
+===========================
+
+Vendor drivers retain hardware discovery, firmware interaction and the
+load/store data path; DRM Fabric represents only the topology and
+provider-reported state of DRM-managed accelerators, which is why it
+belongs in DRM. The interface does not define MMU programming, switch
+policy, key management, live migration, or any required user space daemon.
+
+DRM Fabric does not define in-network collective operations or how an
+endpoint or switch executes them. Such capabilities belong to the
+interconnect implementation and its provider. Adding capability later is not
+foreclosed: for an endpoint it is a new attribute, while
+``peer-type = switch`` is a value descriptor rather than a registered object,
+so there is nowhere to attach one today. ``peer-id`` is already an opaque
+switch identity that need not resolve locally, so making one resolvable later
+strengthens the contract rather than breaking it.
+
+Object lifetime and locking
+===========================
+
+All registry and object state is protected by ``drm_fabric_lock``. A fabric and
+its endpoints are created and torn down through the provider API; endpoint
+unregister removes the endpoint from the registry and frees its fixed set of
+ports. Fabric membership is tracked, so a provider must remove all member
+endpoints before unregistering a provider-owned fabric: drm_fabric_unregister()
+returns ``-EBUSY`` and leaves the fabric registered if any remain, so the
+provider must retry after removing them rather than treat the fabric as gone.
+
+Objects are reference counted and a port is pinned through its owning endpoint.
+Endpoint unregister drops the registration reference and waits for outstanding
+pins before freeing the ports; fabric membership holds a fabric reference.
+
+Providers own object lifetime, so a provider must serialise endpoint
+registration against unregistration of the containing fabric. The unregister
+entry points compare the supplied pointer against the registry before
+dereferencing it, so a stale or repeated teardown is rejected: fabric
+unregistration returns ``-ENODEV``, and endpoint unregistration, having no
+error return, warns and performs no teardown. Endpoint registration rejects a
+departed parent the same way. These checks prove current address membership
+only: they cannot tell an earlier incarnation from another object registered
+later at the same address.
diff --git a/Documentation/gpu/index.rst b/Documentation/gpu/index.rst
index 65bf3b26e4f4..7d99c47ddbe7 100644
--- a/Documentation/gpu/index.rst
+++ b/Documentation/gpu/index.rst
@@ -16,6 +16,7 @@ GPU Driver Developer's Guide
    driver-uapi
    drm-client
    drm-compute
+   drm-fabric
    drivers
    backlight
    vga-switcheroo
diff --git a/MAINTAINERS b/MAINTAINERS
index 3c508bda61d5..2ab63bd763b3 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -8966,6 +8966,20 @@ F:	Documentation/devicetree/bindings/display/xlnx/
 F:	Documentation/gpu/zynqmp.rst
 F:	drivers/gpu/drm/xlnx/
 
+DRM FABRIC
+M:	Konstantin Sinyuk <ksinyuk@kernel.org>
+M:	Maarten Lankhorst <maarten.lankhorst@linux.intel.com>
+R:	Francois Dugast <francois.dugast@intel.com>
+L:	dri-devel@lists.freedesktop.org
+S:	Maintained
+T:	git https://gitlab.freedesktop.org/drm/misc/kernel.git
+F:	Documentation/gpu/drm-fabric.rst
+F:	Documentation/netlink/specs/drm_fabric.yaml
+F:	drivers/gpu/drm/fabric/
+F:	include/drm/drm_fabric.h
+F:	include/uapi/drm/drm_fabric.h
+F:	tools/testing/selftests/drivers/gpu/drm_fabric/
+
 DRM GPU SCHEDULER
 M:	Matthew Brost <matthew.brost@intel.com>
 M:	Danilo Krummrich <dakr@kernel.org>
diff --git a/drivers/gpu/drm/Kconfig b/drivers/gpu/drm/Kconfig
index 323422861e8f..fc66146a603b 100644
--- a/drivers/gpu/drm/Kconfig
+++ b/drivers/gpu/drm/Kconfig
@@ -309,6 +309,7 @@ source "drivers/gpu/drm/atmel-hlcdc/Kconfig"
 source "drivers/gpu/drm/bridge/Kconfig"
 source "drivers/gpu/drm/etnaviv/Kconfig"
 source "drivers/gpu/drm/exynos/Kconfig"
+source "drivers/gpu/drm/fabric/Kconfig"
 source "drivers/gpu/drm/fsl-dcu/Kconfig"
 source "drivers/gpu/drm/gma500/Kconfig"
 source "drivers/gpu/drm/gud/Kconfig"
diff --git a/drivers/gpu/drm/Makefile b/drivers/gpu/drm/Makefile
index e635fcffd379..bdf67dd3aab9 100644
--- a/drivers/gpu/drm/Makefile
+++ b/drivers/gpu/drm/Makefile
@@ -213,6 +213,7 @@ obj-y			+= panel/
 obj-y			+= bridge/
 obj-$(CONFIG_DRM_FSL_DCU) += fsl-dcu/
 obj-$(CONFIG_DRM_ETNAVIV) += etnaviv/
+obj-$(CONFIG_DRM_FABRIC) += fabric/
 obj-y			+= hisilicon/
 obj-y			+= mxsfb/
 obj-y			+= sysfb/
diff --git a/drivers/gpu/drm/fabric/Kconfig b/drivers/gpu/drm/fabric/Kconfig
new file mode 100644
index 000000000000..21fbfae9863d
--- /dev/null
+++ b/drivers/gpu/drm/fabric/Kconfig
@@ -0,0 +1,14 @@
+# SPDX-License-Identifier: GPL-2.0
+
+config DRM_FABRIC
+	tristate "DRM fabric support"
+	depends on DRM && NET
+	help
+	  Enable DRM fabric support. This infrastructure provides the
+	  core object model and provider API for registered accelerator
+	  interconnect topologies.
+
+	  To compile this as a module, choose M here: the module will be
+	  called drm-fabric.
+
+	  If in doubt, say N.
diff --git a/drivers/gpu/drm/fabric/Makefile b/drivers/gpu/drm/fabric/Makefile
new file mode 100644
index 000000000000..3a76f31f1e83
--- /dev/null
+++ b/drivers/gpu/drm/fabric/Makefile
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: GPL-2.0
+
+obj-$(CONFIG_DRM_FABRIC) += drm-fabric.o
+drm-fabric-y := drm_fabric.o
diff --git a/drivers/gpu/drm/fabric/drm_fabric.c b/drivers/gpu/drm/fabric/drm_fabric.c
new file mode 100644
index 000000000000..8769d7bdcde1
--- /dev/null
+++ b/drivers/gpu/drm/fabric/drm_fabric.c
@@ -0,0 +1,681 @@
+// SPDX-License-Identifier: MIT
+/*
+ * Copyright © 2026 Intel Corporation
+ */
+
+#include <linux/cleanup.h>
+#include <linux/completion.h>
+#include <linux/device.h>
+#include <linux/module.h>
+#include <linux/refcount.h>
+#include <linux/slab.h>
+#include <linux/xarray.h>
+
+#include <drm/drm_fabric.h>
+
+#include "drm_fabric_internal.h"
+
+/**
+ * DOC: DRM Fabric core
+ *
+ * The core keeps a registry of fabric, endpoint, port and peer objects behind
+ * a small provider-facing API. A provider registers a fabric, attaches
+ * endpoints with their fixed set of ports, then reports link changes and peer
+ * adjacency through drm_fabric_port_set_oper(), drm_fabric_port_set_peer() and
+ * drm_fabric_port_unset_peer().
+ *
+ * The core owns object identity and lifetime: it assigns kernel-local IDs,
+ * refcounts objects, serialises access under its internal lock, and advances a
+ * topology generation on every change so a concurrent netlink dump can detect
+ * a torn snapshot.
+ */
+
+/* Global lock for all fabric, endpoint and port state. */
+DEFINE_MUTEX(drm_fabric_lock);
+
+DEFINE_XARRAY_ALLOC1(drm_fabric_xa);
+DEFINE_XARRAY_ALLOC(drm_fabric_ep_xa);
+
+/*
+ * Family-global topology change token, exposed to user space as
+ * topology-generation and used as the netlink dump-consistency sequence. It is
+ * nonzero and never emits zero across the u32 wrap. Increment it on a
+ * committed topology mutation so a subsequent GET/DUMP reports the
+ * post-change generation.
+ */
+u32 drm_fabric_base_seq = 1;
+
+static u32 drm_fabric_base_seq_inc(void)
+{
+	lockdep_assert_held(&drm_fabric_lock);
+	/*
+	 * Generation feeds cb->seq; netlink treats 0 as "no dump in progress",
+	 * so skip it on wrap.
+	 */
+	if (++drm_fabric_base_seq == 0)
+		drm_fabric_base_seq = 1;
+	return drm_fabric_base_seq;
+}
+
+struct drm_fabric *drm_fabric_find_by_id(u32 id)
+{
+	lockdep_assert_held(&drm_fabric_lock);
+	return xa_load(&drm_fabric_xa, id);
+}
+
+struct drm_fabric_endpoint *drm_fabric_endpoint_find_by_id(u32 id)
+{
+	lockdep_assert_held(&drm_fabric_lock);
+	return xa_load(&drm_fabric_ep_xa, id);
+}
+
+/*
+ * Returns NULL if no endpoint matches, or ERR_PTR(-EINVAL) if @devname is
+ * ambiguous across buses and @busname does not disambiguate it.
+ */
+struct drm_fabric_endpoint *
+drm_fabric_endpoint_find_by_dev_name(const char *devname, const char *busname)
+{
+	struct drm_fabric_endpoint *match = NULL;
+	struct drm_fabric_endpoint *ep;
+	unsigned long idx;
+
+	lockdep_assert_held(&drm_fabric_lock);
+	xa_for_each(&drm_fabric_ep_xa, idx, ep) {
+		if (strcmp(dev_name(ep->parent), devname))
+			continue;
+
+		if (busname) {
+			if (strcmp(dev_bus_name(ep->parent), busname))
+				continue;
+			return ep;
+		}
+
+		if (match)
+			return ERR_PTR(-EINVAL);
+
+		match = ep;
+	}
+
+	return match;
+}
+
+/*
+ * Returns true if a member of @fabric already uses @fabric_ep_id. fabric_ep_id
+ * is the accelerator's identity within a fabric and is what a peer descriptor
+ * names (peer_id for DRM_FABRIC_PEER_TYPE_ACCEL), so it must be unique per
+ * fabric or peer resolution is ambiguous.
+ */
+static bool drm_fabric_ep_id_in_use(const struct drm_fabric *fabric, u64 fabric_ep_id)
+{
+	struct drm_fabric_endpoint *ep;
+	unsigned long idx;
+
+	lockdep_assert_held(&drm_fabric_lock);
+
+	xa_for_each(&drm_fabric_ep_xa, idx, ep)
+		if (ep->fabric == fabric &&
+		    ep->fabric_ep_id == fabric_ep_id)
+			return true;
+
+	return false;
+}
+
+static bool drm_fabric_has_instance(const struct drm_fabric *fabric)
+{
+	struct drm_fabric *other;
+	unsigned long idx;
+
+	lockdep_assert_held(&drm_fabric_lock);
+
+	xa_for_each(&drm_fabric_xa, idx, other)
+		if (other->type == fabric->type &&
+		    other->instance_id == fabric->instance_id)
+			return true;
+
+	return false;
+}
+
+static bool drm_fabric_type_valid(enum drm_fabric_type type)
+{
+	switch (type) {
+	case DRM_FABRIC_TYPE_SYNTHETIC:
+		return true;
+	}
+
+	return false;
+}
+
+/**
+ * drm_fabric_register() - Register a new fabric
+ * @desc: fabric description (type, instance id, name)
+ *
+ * Allocates the fabric and inserts it into the registry as provider-owned.
+ * Rejects zero and out-of-range types before allocating.
+ *
+ * Context: May sleep. Acquires drm_fabric_lock.
+ * Return: the registered fabric, or an ERR_PTR() on failure, -EINVAL for a
+ *         type this kernel does not define.
+ */
+struct drm_fabric *drm_fabric_register(const struct drm_fabric_desc *desc)
+{
+	struct drm_fabric *fabric __free(kfree) = NULL;
+
+	if (WARN_ON_ONCE(!drm_fabric_type_valid(desc->type)))
+		return ERR_PTR(-EINVAL);
+
+	fabric = kzalloc_obj(*fabric);
+	if (!fabric)
+		return ERR_PTR(-ENOMEM);
+
+	fabric->type = desc->type;
+	fabric->instance_id = desc->instance_id;
+	refcount_set(&fabric->refs, 1);
+
+	if (desc->name &&
+	    strscpy(fabric->name, desc->name, sizeof(fabric->name)) < 0)
+		return ERR_PTR(-ENAMETOOLONG);
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		int ret;
+
+		if (drm_fabric_has_instance(fabric))
+			return ERR_PTR(-EEXIST);
+
+		ret = xa_alloc(&drm_fabric_xa, &fabric->id, fabric,
+			       xa_limit_32b, GFP_KERNEL);
+		if (ret)
+			return ERR_PTR(ret);
+		drm_fabric_base_seq_inc();
+	}
+
+	return_ptr(fabric);
+}
+EXPORT_SYMBOL(drm_fabric_register);
+
+struct drm_fabric *drm_fabric_get(struct drm_fabric *fabric)
+{
+	lockdep_assert_held(&drm_fabric_lock);
+	refcount_inc(&fabric->refs);
+	return fabric;
+}
+
+void drm_fabric_put(struct drm_fabric *fabric)
+{
+	if (refcount_dec_and_test(&fabric->refs))
+		kfree(fabric);
+}
+
+static bool drm_fabric_has_members(const struct drm_fabric *fabric)
+{
+	struct drm_fabric_endpoint *ep;
+	unsigned long idx;
+
+	lockdep_assert_held(&drm_fabric_lock);
+
+	xa_for_each(&drm_fabric_ep_xa, idx, ep)
+		if (ep->fabric == fabric)
+			return true;
+
+	return false;
+}
+
+/*
+ * Compare the possibly stale pointer by address without dereferencing it.
+ * A reused address passes as the later object; providers own incarnation
+ * tracking.
+ */
+static bool drm_fabric_is_registered(const struct drm_fabric *fabric)
+{
+	struct drm_fabric *entry;
+	unsigned long idx;
+
+	lockdep_assert_held(&drm_fabric_lock);
+
+	xa_for_each(&drm_fabric_xa, idx, entry)
+		if (entry == fabric)
+			return true;
+
+	return false;
+}
+
+/**
+ * drm_fabric_unregister() - Unregister a fabric
+ * @fabric: fabric to remove
+ *
+ * Removes an empty @fabric from the registry and frees it. A provider must
+ * unregister all member endpoints first.
+ *
+ * @fabric must still be registered. The pointer is compared against the
+ * registry before anything dereferences it, so a stale or repeated
+ * unregister is rejected rather than acted on. That comparison proves
+ * current address membership only: it cannot tell an earlier incarnation
+ * from another fabric registered later at the same address, which remains
+ * the provider's obligation.
+ *
+ * Context: May sleep. Acquires drm_fabric_lock.
+ * Return: 0 on removal. -ENODEV if @fabric is not registered, checked first.
+ *         -EBUSY if members remain; the fabric stays registered and the
+ *         provider must remove them before retrying. -EBUSY also warns
+ *         because this is a teardown-ordering bug.
+ */
+int drm_fabric_unregister(struct drm_fabric *fabric)
+{
+	scoped_guard(mutex, &drm_fabric_lock) {
+		if (!drm_fabric_is_registered(fabric))
+			return -ENODEV;
+		/*
+		 * Members still reference ep->fabric; freeing it here would
+		 * leave stale pointers.
+		 */
+		if (WARN_ON_ONCE(drm_fabric_has_members(fabric)))
+			return -EBUSY;
+		drm_fabric_base_seq_inc();
+		xa_erase(&drm_fabric_xa, fabric->id);
+	}
+
+	drm_fabric_put(fabric);
+	return 0;
+}
+EXPORT_SYMBOL(drm_fabric_unregister);
+
+static void drm_fabric_ports_destroy(struct drm_fabric_endpoint *ep)
+{
+	struct drm_fabric_port *port;
+	unsigned long index;
+
+	xa_for_each(&ep->ports, index, port) {
+		kfree(port);
+		ep->num_ports--;
+	}
+	xa_destroy(&ep->ports);
+}
+
+static int drm_fabric_ports_create(struct drm_fabric_endpoint *ep,
+				   const struct drm_fabric_port_desc *descs,
+				   unsigned int num_ports)
+{
+	unsigned int i;
+
+	for (i = 0; i < num_ports; i++) {
+		struct drm_fabric_port *port;
+		int ret;
+
+		port = kzalloc_obj(*port);
+		if (!port) {
+			drm_fabric_ports_destroy(ep);
+			return -ENOMEM;
+		}
+
+		port->index = descs[i].index;
+		port->max_lane_count = descs[i].max_lane_count;
+		port->max_lane_signaling_rate_mbps =
+			descs[i].max_lane_signaling_rate_mbps;
+		port->oper_state = DRM_FABRIC_PORT_STATE_UNKNOWN;
+		port->has_peer = false;
+		port->endpoint = ep;
+
+		ret = xa_insert(&ep->ports, port->index, port, GFP_KERNEL);
+		if (ret) {
+			/* A duplicate port index is a provider bug. */
+			WARN_ON_ONCE(ret == -EBUSY);
+			kfree(port);
+			drm_fabric_ports_destroy(ep);
+			return ret;
+		}
+
+		ep->num_ports++;
+	}
+
+	return 0;
+}
+
+/**
+ * drm_fabric_endpoint_register() - Register a provider-owned endpoint
+ * @fabric: non-NULL fabric the endpoint belongs to
+ * @desc: endpoint description, including its fixed set of ports
+ *
+ * Registers @desc as a member of @fabric and advances the topology generation.
+ * @desc->fabric_ep_id must be unique among the fabric's registered endpoints.
+ *
+ * The provider must serialise this call against drm_fabric_unregister() of
+ * @fabric. A fabric that has already left the registry is rejected, but that
+ * check matches on address and cannot distinguish incarnations; only the
+ * provider knows its own object lifecycle.
+ *
+ * Context: May sleep. Acquires drm_fabric_lock.
+ * Return: the registered endpoint, or an ERR_PTR() on failure: -EINVAL if
+ * @fabric or @desc->parent is NULL, or @desc claims ports without supplying a
+ * port array, -ENODEV if @fabric is no longer registered, -EEXIST if
+ * @desc->fabric_ep_id is already in use within @fabric.
+ */
+struct drm_fabric_endpoint *
+drm_fabric_endpoint_register(struct drm_fabric *fabric,
+			     const struct drm_fabric_endpoint_desc *desc)
+{
+	struct drm_fabric_endpoint *ep __free(kfree) = NULL;
+	int ret;
+
+	if (!fabric)
+		return ERR_PTR(-EINVAL);
+
+	/* Supplies dev_name()/bus for the query paths; pinned below. */
+	if (!desc->parent)
+		return ERR_PTR(-EINVAL);
+
+	if (desc->num_ports && !desc->ports)
+		return ERR_PTR(-EINVAL);
+
+	ep = kzalloc_obj(*ep);
+	if (!ep)
+		return ERR_PTR(-ENOMEM);
+
+	ep->fabric_ep_id = desc->fabric_ep_id;
+	ep->fabric = fabric;
+	ep->parent = desc->parent;
+	ep->ops = desc->ops;
+	ep->priv = desc->priv;
+	refcount_set(&ep->refs, 1);
+	init_completion(&ep->unregistered);
+	xa_init(&ep->ports);
+
+	if (desc->name &&
+	    strscpy(ep->name, desc->name, sizeof(ep->name)) < 0)
+		return ERR_PTR(-ENAMETOOLONG);
+
+	ret = drm_fabric_ports_create(ep, desc->ports, desc->num_ports);
+	if (ret)
+		return ERR_PTR(ret);
+
+	get_device(ep->parent);
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		/*
+		 * A concurrent drm_fabric_unregister() may have freed @fabric
+		 * since the caller passed it, so validate membership by address
+		 * without dereferencing it.
+		 */
+		if (!drm_fabric_is_registered(fabric)) {
+			ret = -ENODEV;
+			break;
+		}
+
+		if (drm_fabric_ep_id_in_use(fabric, ep->fabric_ep_id)) {
+			ret = -EEXIST;
+			break;
+		}
+
+		ret = xa_alloc(&drm_fabric_ep_xa, &ep->id, ep, xa_limit_32b, GFP_KERNEL);
+		if (ret)
+			break;
+		drm_fabric_get(fabric);
+
+		drm_fabric_base_seq_inc();
+	}
+
+	if (ret) {
+		put_device(ep->parent);
+		drm_fabric_ports_destroy(ep);
+		return ERR_PTR(ret);
+	}
+
+	return_ptr(ep);
+}
+EXPORT_SYMBOL(drm_fabric_endpoint_register);
+
+/**
+ * drm_fabric_endpoint_port() - Look up a port of a registered endpoint
+ * @ep: provider-owned endpoint
+ * @port_index: per-endpoint port index
+ *
+ * An endpoint's set of ports is fixed for its registration lifetime, so a
+ * provider that serializes the endpoint lifecycle may call this without
+ * holding drm_fabric_lock. The returned pointer is borrowed and remains valid
+ * only while @ep is registered; it must not be retained across
+ * drm_fabric_endpoint_unregister().
+ *
+ * Return: the port at @port_index, or NULL if no such port exists.
+ */
+struct drm_fabric_port *
+drm_fabric_endpoint_port(struct drm_fabric_endpoint *ep, u32 port_index)
+{
+	return xa_load(&ep->ports, port_index);
+}
+EXPORT_SYMBOL(drm_fabric_endpoint_port);
+
+struct drm_fabric_endpoint *drm_fabric_endpoint_get(struct drm_fabric_endpoint *ep)
+{
+	lockdep_assert_held(&drm_fabric_lock);
+	refcount_inc(&ep->refs);
+	return ep;
+}
+
+void drm_fabric_endpoint_put(struct drm_fabric_endpoint *ep)
+{
+	if (refcount_dec_and_test(&ep->refs))
+		complete(&ep->unregistered);
+}
+
+/*
+ * Same contract as drm_fabric_is_registered(): a stale, possibly freed @ep is
+ * compared but never dereferenced, and an address reused by a later
+ * registration passes as that later endpoint.
+ */
+static bool drm_fabric_ep_is_registered(const struct drm_fabric_endpoint *ep)
+{
+	struct drm_fabric_endpoint *entry;
+	unsigned long idx;
+
+	lockdep_assert_held(&drm_fabric_lock);
+
+	xa_for_each(&drm_fabric_ep_xa, idx, entry)
+		if (entry == ep)
+			return true;
+
+	return false;
+}
+
+/**
+ * drm_fabric_endpoint_unregister() - Unregister and free an endpoint
+ * @ep: endpoint to remove
+ *
+ * Removes @ep from the registry and frees it along with its ports, advancing
+ * the topology generation. Peers on other endpoints that name @ep are left
+ * untouched: the core never scans or retracts a half-edge on unregister.
+ *
+ * The provider must first quiesce its own use of @ep and any borrowed
+ * drm_fabric_endpoint_port() pointer. The core waits only for references it
+ * issued itself; concurrent netlink readers need no provider action.
+ *
+ * @ep must still be registered. The pointer is compared against the registry
+ * before anything dereferences it, so a stale or repeated unregister warns
+ * and performs no teardown; there is no error return to report it. As for a
+ * fabric, the comparison proves current address membership only.
+ *
+ * Context: May sleep. Acquires drm_fabric_lock.
+ */
+void drm_fabric_endpoint_unregister(struct drm_fabric_endpoint *ep)
+{
+	scoped_guard(mutex, &drm_fabric_lock) {
+		if (WARN_ON_ONCE(!drm_fabric_ep_is_registered(ep)))
+			return;
+		drm_fabric_base_seq_inc();
+		xa_erase(&drm_fabric_ep_xa, ep->id);
+	}
+
+	/*
+	 * Drop the registration reference and wait for any in-flight netlink
+	 * operation that pinned the endpoint to complete.
+	 */
+	drm_fabric_endpoint_put(ep);
+	wait_for_completion(&ep->unregistered);
+
+	drm_fabric_put(ep->fabric);
+
+	drm_fabric_ports_destroy(ep);
+	put_device(ep->parent);
+	kfree(ep);
+}
+EXPORT_SYMBOL(drm_fabric_endpoint_unregister);
+
+/* Borrowed: valid only while drm_fabric_lock is held. */
+struct drm_fabric_port *drm_fabric_port_find(u32 ep_id, u32 port_idx)
+{
+	struct drm_fabric_endpoint *ep;
+
+	lockdep_assert_held(&drm_fabric_lock);
+	ep = drm_fabric_endpoint_find_by_id(ep_id);
+	if (!ep)
+		return NULL;
+	return xa_load(&ep->ports, port_idx);
+}
+
+/*
+ * No refcount of its own: pinned through its owning endpoint and released
+ * with drm_fabric_port_put(). ERR_PTR(-ENOENT) if no such port exists.
+ */
+struct drm_fabric_port *drm_fabric_port_find_get(u32 ep_id, u32 port_idx)
+{
+	struct drm_fabric_port *port;
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		port = drm_fabric_port_find(ep_id, port_idx);
+		if (!port)
+			return ERR_PTR(-ENOENT);
+		drm_fabric_endpoint_get(port->endpoint);
+	}
+
+	return port;
+}
+
+void drm_fabric_port_put(struct drm_fabric_port *port)
+{
+	drm_fabric_endpoint_put(port->endpoint);
+}
+
+static bool drm_fabric_peer_type_valid(enum drm_fabric_peer_type type)
+{
+	switch (type) {
+	case DRM_FABRIC_PEER_TYPE_ACCEL:
+	case DRM_FABRIC_PEER_TYPE_SWITCH:
+		return true;
+	}
+
+	return false;
+}
+
+/**
+ * drm_fabric_port_set_peer() - Set a neighbor (called by the provider)
+ * @port: local port
+ * @peer: descriptor of the endpoint on the other end
+ *
+ * Sets the peer and advances the topology generation on success.
+ *
+ * Context: May sleep. Acquires drm_fabric_lock.
+ * Return: -EINVAL if @peer carries an unknown peer type, -EEXIST if the port
+ * already has a peer. 0 on success.
+ */
+int drm_fabric_port_set_peer(struct drm_fabric_port *port,
+			     const struct drm_fabric_peer *peer)
+{
+	/* Reject a provider's invalid type before it reaches the wire. */
+	if (WARN_ON_ONCE(!drm_fabric_peer_type_valid(peer->peer_type)))
+		return -EINVAL;
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		if (port->has_peer)
+			return -EEXIST;
+		port->peer = *peer;
+		port->has_peer = true;
+		drm_fabric_base_seq_inc();
+	}
+
+	return 0;
+}
+EXPORT_SYMBOL(drm_fabric_port_set_peer);
+
+/**
+ * drm_fabric_port_unset_peer() - Remove a neighbor (called by the provider)
+ * @port: local port
+ *
+ * Inverse of drm_fabric_port_set_peer().
+ * Removes the peer and advances the topology generation on success.
+ *
+ * Edge retraction is always explicit (provider- or controller-driven); the
+ * core never removes a peer implicitly.
+ *
+ * Context: May sleep. Acquires drm_fabric_lock.
+ * Return: -ENOENT if no peer set. 0 on success.
+ */
+int drm_fabric_port_unset_peer(struct drm_fabric_port *port)
+{
+	scoped_guard(mutex, &drm_fabric_lock) {
+		if (!port->has_peer)
+			return -ENOENT;
+		drm_fabric_base_seq_inc();
+		port->has_peer = false;
+		memset(&port->peer, 0, sizeof(port->peer));
+	}
+
+	return 0;
+}
+EXPORT_SYMBOL(drm_fabric_port_unset_peer);
+
+static bool drm_fabric_port_oper_state_valid(enum drm_fabric_port_state state)
+{
+	switch (state) {
+	case DRM_FABRIC_PORT_STATE_UNKNOWN:
+	case DRM_FABRIC_PORT_STATE_INACTIVE:
+	case DRM_FABRIC_PORT_STATE_ACTIVE:
+	case DRM_FABRIC_PORT_STATE_DEGRADED:
+		return true;
+	}
+
+	return false;
+}
+
+/**
+ * drm_fabric_port_set_oper() - Update a port's operational state
+ * @port: port to update
+ * @state: new operational state
+ *
+ * Advances the topology generation if the state actually changed. Invalid
+ * states are provider bugs; they are warned about and ignored.
+ *
+ * Context: May sleep. Acquires drm_fabric_lock.
+ */
+void drm_fabric_port_set_oper(struct drm_fabric_port *port,
+			      enum drm_fabric_port_state state)
+{
+	if (WARN_ON_ONCE(!drm_fabric_port_oper_state_valid(state)))
+		return;
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		enum drm_fabric_port_state old = port->oper_state;
+
+		port->oper_state = state;
+		if (old != state)
+			drm_fabric_base_seq_inc();
+	}
+}
+EXPORT_SYMBOL(drm_fabric_port_set_oper);
+
+static int __init drm_fabric_init(void)
+{
+	return 0;
+}
+
+static void __exit drm_fabric_exit(void)
+{
+	WARN_ON(!xa_empty(&drm_fabric_xa));
+	WARN_ON(!xa_empty(&drm_fabric_ep_xa));
+	xa_destroy(&drm_fabric_xa);
+	xa_destroy(&drm_fabric_ep_xa);
+}
+
+module_init(drm_fabric_init);
+module_exit(drm_fabric_exit);
+
+MODULE_AUTHOR("Intel Corporation");
+MODULE_DESCRIPTION("DRM fabric infrastructure");
+MODULE_LICENSE("Dual MIT/GPL");
diff --git a/drivers/gpu/drm/fabric/drm_fabric_internal.h b/drivers/gpu/drm/fabric/drm_fabric_internal.h
new file mode 100644
index 000000000000..d454225a0b81
--- /dev/null
+++ b/drivers/gpu/drm/fabric/drm_fabric_internal.h
@@ -0,0 +1,34 @@
+/* SPDX-License-Identifier: MIT */
+/*
+ * Copyright © 2026 Intel Corporation
+ */
+
+#ifndef __DRM_FABRIC_INTERNAL_H__
+#define __DRM_FABRIC_INTERNAL_H__
+
+#include <linux/mutex.h>
+#include <linux/types.h>
+#include <linux/xarray.h>
+
+#include <drm/drm_fabric.h>
+
+extern struct mutex drm_fabric_lock;
+extern struct xarray drm_fabric_xa;    /* Fabric registry */
+extern struct xarray drm_fabric_ep_xa; /* Endpoint registry */
+
+extern u32 drm_fabric_base_seq;        /* Dump consistency sequence */
+
+struct drm_fabric *drm_fabric_find_by_id(u32 id);
+struct drm_fabric_endpoint *drm_fabric_endpoint_find_by_id(u32 id);
+struct drm_fabric_endpoint *
+drm_fabric_endpoint_find_by_dev_name(const char *devname, const char *busname);
+struct drm_fabric_port *drm_fabric_port_find(u32 ep_id, u32 port_idx);
+
+struct drm_fabric_port *drm_fabric_port_find_get(u32 ep_id, u32 port_idx);
+void drm_fabric_port_put(struct drm_fabric_port *port);
+struct drm_fabric_endpoint *drm_fabric_endpoint_get(struct drm_fabric_endpoint *ep);
+void drm_fabric_endpoint_put(struct drm_fabric_endpoint *ep);
+struct drm_fabric *drm_fabric_get(struct drm_fabric *fabric);
+void drm_fabric_put(struct drm_fabric *fabric);
+
+#endif /* __DRM_FABRIC_INTERNAL_H__ */
diff --git a/include/drm/drm_fabric.h b/include/drm/drm_fabric.h
new file mode 100644
index 000000000000..68d8acec2d38
--- /dev/null
+++ b/include/drm/drm_fabric.h
@@ -0,0 +1,259 @@
+/* SPDX-License-Identifier: MIT */
+/*
+ * Copyright © 2026 Intel Corporation
+ */
+
+/*
+ * DRM Fabric driver API: common object model for GPU interconnect topology
+ * (fabric, endpoint, port and peer relationships).
+ */
+
+#ifndef __DRM_FABRIC_H__
+#define __DRM_FABRIC_H__
+
+#include <linux/completion.h>
+#include <linux/refcount.h>
+#include <linux/types.h>
+#include <linux/xarray.h>
+
+enum drm_fabric_type {
+	/* Zero is invalid; concrete fabric types start at 1. */
+	DRM_FABRIC_TYPE_SYNTHETIC = 1,
+};
+
+enum drm_fabric_port_state {
+	DRM_FABRIC_PORT_STATE_UNKNOWN,
+	DRM_FABRIC_PORT_STATE_INACTIVE,
+	DRM_FABRIC_PORT_STATE_ACTIVE,
+	DRM_FABRIC_PORT_STATE_DEGRADED,
+};
+
+enum drm_fabric_peer_type {
+	DRM_FABRIC_PEER_TYPE_ACCEL = 1,
+	DRM_FABRIC_PEER_TYPE_SWITCH,
+};
+
+struct device;
+
+/**
+ * struct drm_fabric_port_desc - Port descriptor for drm_fabric_endpoint_register()
+ */
+struct drm_fabric_port_desc {
+	/** @index: per-endpoint port index */
+	u32 index;
+	/** @max_lane_count: maximum provider-reported link width capability, 0 if not reported */
+	u32 max_lane_count;
+	/**
+	 * @max_lane_signaling_rate_mbps: maximum provider-reported per-lane
+	 * signaling rate in decimal megabits per second before encoding, FEC
+	 * and protocol overhead, 0 if not reported. A capability, not the
+	 * negotiated rate: multiplying it by @max_lane_count does not give
+	 * usable bandwidth.
+	 */
+	u32 max_lane_signaling_rate_mbps;
+};
+
+/**
+ * struct drm_fabric_endpoint_desc - Endpoint descriptor for drm_fabric_endpoint_register()
+ */
+struct drm_fabric_endpoint_desc {
+	/**
+	 * @fabric_ep_id: accelerator identity within the fabric's identity
+	 * domain, unique among its members and stable for the duration of
+	 * membership; a duplicate is rejected with -EEXIST. Distinct from the
+	 * core-assigned &drm_fabric_endpoint.id.
+	 */
+	u64 fabric_ep_id;
+	/** @name: human-readable endpoint name */
+	const char *name;
+	/** @parent: required backing device, provides dev_name and bus */
+	struct device *parent;
+	/** @ops: provider driver callbacks */
+	const struct drm_fabric_ops *ops;
+	/**
+	 * @priv: provider cookie reachable from every @ops callback via @ep
+	 * or @port. The core stores it, never dereferences it, never frees it.
+	 */
+	void *priv;
+
+	/** @ports: fixed set of ports, copied by the core */
+	const struct drm_fabric_port_desc *ports;
+	/** @num_ports: number of entries in @ports */
+	unsigned int num_ports;
+};
+
+/**
+ * struct drm_fabric_desc - Fabric descriptor for drm_fabric_register()
+ */
+struct drm_fabric_desc {
+	/** @type: fabric interconnect technology */
+	enum drm_fabric_type type;
+	/** @name: human-readable fabric name */
+	const char *name;
+	/** @instance_id: vendor-unique instance identifier */
+	u64 instance_id;
+};
+
+/**
+ * struct drm_fabric - Fabric object
+ */
+struct drm_fabric {
+	/** @id: kernel-local identifier, assigned by the core */
+	u32 id;
+	/** @type: fabric interconnect technology */
+	enum drm_fabric_type type;
+	/** @instance_id: vendor-unique identifier within @type */
+	u64 instance_id;
+	/** @name: human-readable fabric name */
+	char name[32];
+
+	/** @refs: reference count */
+	refcount_t refs;
+};
+
+/**
+ * struct drm_fabric_endpoint - Endpoint object
+ */
+struct drm_fabric_endpoint {
+	/** @id: kernel-local identifier, assigned by the core */
+	u32 id;
+	/** @fabric_ep_id: provider's stable fabric-local identity */
+	u64 fabric_ep_id;
+	/** @name: human-readable endpoint name */
+	char name[32];
+	/** @parent: backing device, provides dev_name and bus_name */
+	struct device *parent;
+
+	/** @fabric: parent fabric */
+	struct drm_fabric *fabric;
+
+	/** @ops: provider driver callbacks */
+	const struct drm_fabric_ops *ops;
+	/** @priv: provider cookie, as supplied at registration */
+	void *priv;
+
+	/** @ports: xarray of &struct drm_fabric_port owned by this endpoint */
+	struct xarray ports;
+	/** @num_ports: number of ports in @ports */
+	unsigned int num_ports;
+
+	/** @refs: reference count */
+	refcount_t refs;
+	/** @unregistered: completed once the endpoint is fully unregistered */
+	struct completion unregistered;
+};
+
+/**
+ * drm_fabric_endpoint_fabric_id() - Wire fabric-id for an endpoint
+ * @ep: endpoint to query
+ *
+ * Return: the parent fabric id.
+ */
+static inline u32
+drm_fabric_endpoint_fabric_id(const struct drm_fabric_endpoint *ep)
+{
+	return ep->fabric->id;
+}
+
+/**
+ * struct drm_fabric_peer - Directly adjacent far-end identity
+ *
+ * A value descriptor, not a reference to a live object. While its owning port
+ * remains registered, it is retained until explicitly retracted even if the
+ * object it names stops resolving locally. See "Peer semantics" in
+ * Documentation/gpu/drm-fabric.rst.
+ */
+struct drm_fabric_peer {
+	/**
+	 * @peer_id: identity of the far-end object, read per @peer_type: an
+	 * accelerator's fabric_ep_id, or an opaque switch identity. The two
+	 * are separate namespaces, so one value names different objects
+	 * under each type, and neither has to resolve locally.
+	 */
+	u64 peer_id;
+
+	/** @peer_type: kind of far-end device; selects the @peer_id namespace */
+	enum drm_fabric_peer_type peer_type;
+	/** @port_index: far-end port index within the object @peer_id names */
+	u32 port_index;
+};
+
+/**
+ * struct drm_fabric_port - Port object
+ */
+struct drm_fabric_port {
+	/** @index: per-endpoint port index */
+	u32 index;
+	/** @oper_state: operational (link) state */
+	enum drm_fabric_port_state oper_state;
+	/** @max_lane_count: as in &struct drm_fabric_port_desc */
+	u32 max_lane_count;
+	/** @max_lane_signaling_rate_mbps: as in &struct drm_fabric_port_desc */
+	u32 max_lane_signaling_rate_mbps;
+
+	/** @has_peer: whether @peer holds a valid descriptor */
+	bool has_peer;
+	/** @peer: neighbor description, valid only while @has_peer is set */
+	struct drm_fabric_peer peer;
+
+	/** @endpoint: parent endpoint */
+	struct drm_fabric_endpoint *endpoint;
+};
+
+/**
+ * struct drm_fabric_port_stats - Per-port statistics for the port_stats_get() callback
+ *
+ * Counters are monotonic for the lifetime of the provider's registration and
+ * are not clearable through this uAPI. Link error accounting belongs to DRM
+ * RAS and is deliberately absent here.
+ */
+struct drm_fabric_port_stats {
+	/** @read_bytes: bytes received on the port */
+	u64 read_bytes;
+	/** @write_bytes: bytes transmitted on the port */
+	u64 write_bytes;
+	/** @link_down_count: link-down transitions */
+	u64 link_down_count;
+	/** @retrain_count: link retrain events */
+	u64 retrain_count;
+};
+
+/**
+ * struct drm_fabric_ops - Provider driver callbacks
+ *
+ * A callback that is not supplied makes the matching netlink operation return
+ * -EOPNOTSUPP.
+ */
+struct drm_fabric_ops {
+	/**
+	 * @port_stats_get: read per-port statistics. The core pins the port
+	 * through its endpoint and calls without drm_fabric_lock held, so this
+	 * may sleep. It must not re-enter a core API that takes the lock or take
+	 * a provider lock from which the core may be called.
+	 *
+	 * Populate every field or return -EOPNOTSUPP. Zero is a valid count,
+	 * not an unsupported marker.
+	 */
+	int (*port_stats_get)(struct drm_fabric_port *port,
+			      struct drm_fabric_port_stats *stats);
+};
+
+struct drm_fabric *drm_fabric_register(const struct drm_fabric_desc *desc);
+int drm_fabric_unregister(struct drm_fabric *fabric);
+
+struct drm_fabric_endpoint *
+drm_fabric_endpoint_register(struct drm_fabric *fabric,
+			     const struct drm_fabric_endpoint_desc *desc);
+void drm_fabric_endpoint_unregister(struct drm_fabric_endpoint *ep);
+
+struct drm_fabric_port *
+drm_fabric_endpoint_port(struct drm_fabric_endpoint *ep, u32 port_index);
+
+int drm_fabric_port_set_peer(struct drm_fabric_port *port,
+			     const struct drm_fabric_peer *peer);
+int drm_fabric_port_unset_peer(struct drm_fabric_port *port);
+
+void drm_fabric_port_set_oper(struct drm_fabric_port *port,
+			      enum drm_fabric_port_state state);
+
+#endif /* __DRM_FABRIC_H__ */
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [RFC PATCH 02/12] drm/fabric: add query uAPI and generated headers
  2026-08-24  8:09 [RFC PATCH 0/12] drm/fabric: vendor-neutral topology infrastructure for scale-up accelerator interconnects Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 01/12] drm/fabric: add core object model and provider API Konstantin Sinyuk
@ 2026-08-24  8:09 ` Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 03/12] drm/fabric: implement query netlink operations Konstantin Sinyuk
                   ` (9 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Konstantin Sinyuk @ 2026-08-24  8:09 UTC (permalink / raw)
  To: dri-devel
  Cc: Maarten Lankhorst, Francois Dugast, David Airlie, Simona Vetter,
	Maxime Ripard, Thomas Zimmermann, Jonathan Corbet, Shuah Khan,
	Donald Hunter, Jakub Kicinski, David S. Miller, Eric Dumazet,
	Paolo Abeni, Simon Horman, Ilia Levi, Rodrigo Vivi, linux-doc,
	linux-kselftest, netdev, linux-kernel

From: Ilia Levi <ilia.levi@intel.com>

Add a YNL specification for the drm-fabric Generic Netlink family and
generate its userspace and kernel headers, giving the object model from
the previous patch a userspace-visible wire format.

The read-only ABI provides FABRIC_GET, ENDPOINT_GET, PORT_GET and
PORT_STATS_GET as single lookups and multipart dumps. Notifications report
fabric and endpoint lifecycle, peer adjacency and port state through the
monitor multicast group.

The wire format separates core-assigned object IDs from provider-defined
endpoint and peer identities and carries a topology-generation token.
Document the family, the YAML-as-contract rule and the append-only
stability guarantees in Documentation/gpu/drm-fabric.rst, together with
the full identity and consistency semantics.

Generate the headers from the tree root with:

  tools/net/ynl/pyynl/ynl_gen_c.py --mode uapi --header \
      --spec Documentation/netlink/specs/drm_fabric.yaml \
      -o include/uapi/drm/drm_fabric.h
  tools/net/ynl/pyynl/ynl_gen_c.py --mode kernel --header \
      --spec Documentation/netlink/specs/drm_fabric.yaml \
      -o drivers/gpu/drm/fabric/drm_fabric_nl.h

The generated operation and policy source references the doit and dumpit
handlers, so it lands with their implementation in the next patch. The
generated uAPI header replaces the enums defined locally in the previous
patch.

Signed-off-by: Ilia Levi <ilia.levi@intel.com>
Co-developed-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Signed-off-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Assisted-by: GitHub-Copilot:claude-opus-4.8
---
 Documentation/gpu/drm-fabric.rst            |  35 ++
 Documentation/netlink/specs/drm_fabric.yaml | 427 ++++++++++++++++++++
 drivers/gpu/drm/fabric/drm_fabric_nl.h      |  36 ++
 include/drm/drm_fabric.h                    |  21 +-
 include/uapi/drm/drm_fabric.h               | 134 ++++++
 5 files changed, 635 insertions(+), 18 deletions(-)
 create mode 100644 Documentation/netlink/specs/drm_fabric.yaml
 create mode 100644 drivers/gpu/drm/fabric/drm_fabric_nl.h
 create mode 100644 include/uapi/drm/drm_fabric.h

diff --git a/Documentation/gpu/drm-fabric.rst b/Documentation/gpu/drm-fabric.rst
index 99b189dc614e..d2c9e694b67f 100644
--- a/Documentation/gpu/drm-fabric.rst
+++ b/Documentation/gpu/drm-fabric.rst
@@ -140,3 +140,38 @@ error return, warns and performs no teardown. Endpoint registration rejects a
 departed parent the same way. These checks prove current address membership
 only: they cannot tell an earlier incarnation from another object registered
 later at the same address.
+
+Generic Netlink family
+======================
+
+DRM Fabric is exposed through the ``drm-fabric`` Generic Netlink family.
+Dump enumeration and asynchronous notifications fit this multi-object model
+better than one-value-per-file sysfs. The family follows the YAML/ynl
+discipline used by DRM RAS; devlink's device hierarchy does not represent a
+fabric spanning multiple DRM devices.
+
+YAML specification
+------------------
+
+The interface is described in a YAML specification
+``Documentation/netlink/specs/drm_fabric.yaml``, which is the source of truth for
+the wire format. It auto-generates the uAPI header
+(``include/uapi/drm/drm_fabric.h``) and the kernel glue via
+``tools/net/ynl/pyynl/ynl_gen_c.py``. Generated files must never be edited by
+hand; regenerate them with ``tools/net/ynl/ynl-regen.sh`` after any spec change.
+
+uAPI stability
+--------------
+
+The YAML specification is the contract. New attributes, commands and enum values
+are added append-only; existing attribute numbers, command numbers and meanings
+are never reused. Requests are strictly validated, so an unknown attribute in a
+request is rejected; user space should ignore attributes it does not recognize
+in replies and notifications. The family is versioned through
+``DRM_FABRIC_FAMILY_VERSION``.
+
+Kernel-local ``fabric-id`` and ``endpoint-id`` values identify live registry
+objects and are not persistent hardware identities: they remain valid for the
+lifetime of the registered object, but may disappear or be reused after the
+object is unregistered. ``instance-id`` and ``fabric-ep-id`` carry
+provider-defined identity, whose scope is described by the containing object.
diff --git a/Documentation/netlink/specs/drm_fabric.yaml b/Documentation/netlink/specs/drm_fabric.yaml
new file mode 100644
index 000000000000..778443d92a0d
--- /dev/null
+++ b/Documentation/netlink/specs/drm_fabric.yaml
@@ -0,0 +1,427 @@
+# SPDX-License-Identifier: ((GPL-2.0 WITH Linux-syscall-note) OR BSD-3-Clause)
+---
+name: drm-fabric
+protocol: genetlink
+uapi-header: drm/drm_fabric.h
+
+doc: |
+  Common object model for GPU and accelerator interconnect topology
+  (xGMI, UALink and similar), exposed over Generic Netlink.
+
+  A @fabric groups @endpoint objects; each @endpoint owns a fixed set of
+  @port objects; and a @port may carry a @peer naming the directly adjacent
+  far end. The core records direct adjacency only, and a @peer need not
+  resolve locally.
+
+  This read-only interface is confined to the initial network namespace.
+  Providers own topology; userspace queries it and receives notifications.
+  Replies and notifications carry a topology-generation token so an
+  interrupted or changed dump can be discarded and retried.
+
+definitions:
+  -
+    name: type
+    type: enum
+    value-start: 1
+    doc: >-
+      Fabric interconnect technology. New types are added with the provider
+      that first reports them. Zero is not a valid type.
+    entries:
+      - synthetic
+
+  -
+    name: port-state
+    type: enum
+    doc: Operational (link) state of a port, as observed by the driver.
+    entries:
+      - unknown
+      - inactive
+      - active
+      - degraded
+
+  -
+    name: peer-type
+    type: enum
+    value-start: 1
+    doc: Kind of device directly adjacent at a port's far end.
+    entries: [accel, switch]
+
+attribute-sets:
+  -
+    name: drm-fabric
+    enum-name: drm-fabric-a
+    doc: Top-level attributes, shared by all drm-fabric operations.
+    attributes:
+      -
+        name: fabric
+        type: nest
+        nested-attributes: fabric
+        doc: A fabric object (full nest).
+      -
+        name: endpoint
+        type: nest
+        nested-attributes: endpoint
+        doc: An endpoint object (full nest).
+      -
+        name: port
+        type: nest
+        nested-attributes: port
+        doc: A port object (full nest).
+      -
+        name: port-stats
+        type: nest
+        nested-attributes: port-stats
+        doc: Per-port statistics (full nest).
+      -
+        name: fabric-id
+        type: u32
+        doc: Kernel-local fabric identifier, assigned by the core.
+      -
+        name: endpoint-id
+        type: u32
+        doc: Kernel-local endpoint identifier, assigned by the core.
+      -
+        name: port-index
+        type: u32
+        doc: Per-endpoint port index.
+      -
+        name: dev-name
+        type: string
+        doc: Bus-specific identity (e.g. PCI BDF 0000:c1:00.0).
+      -
+        name: bus-name
+        type: string
+        doc: Disambiguate dev-name by bus (e.g. pci).
+      -
+        name: peer
+        type: nest
+        nested-attributes: peer
+        doc: A port's neighbor description (full nest).
+      -
+        name: topology-generation
+        type: u32
+        doc: >-
+          Family-global topology change token, not an event counter. Nonzero,
+          advances on every committed topology-visible transition and may wrap,
+          but zero is never emitted. Compare for equality only; treat any
+          change, or NLM_F_DUMP_INTR on a dump, as a reason to re-dump. A GET
+          or DUMP reply carries its snapshot's generation; a notification
+          carries the generation of its change. Statistics reads do not
+          advance it.
+
+  -
+    name: fabric
+    name-prefix: drm-fabric-a-fabric-attrs-
+    enum-name: drm-fabric-a-fabric-attrs
+    doc: Attributes describing a fabric object.
+    attributes:
+      -
+        name: pad
+        type: pad
+      -
+        name: fabric-id
+        type: u32
+        doc: Kernel-local fabric identifier, assigned by the core.
+      -
+        name: type
+        type: u32
+        enum: type
+        doc: Fabric interconnect technology, see enum type.
+      -
+        name: name
+        type: string
+        doc: Human-readable fabric name.
+      -
+        name: instance-id
+        type: u64
+        doc: Vendor-unique fabric instance identifier (e.g. a hive ID).
+
+  -
+    name: endpoint
+    name-prefix: drm-fabric-a-endpoint-attrs-
+    enum-name: drm-fabric-a-endpoint-attrs
+    doc: Attributes describing an endpoint object.
+    attributes:
+      -
+        name: pad
+        type: pad
+      -
+        name: endpoint-id
+        type: u32
+        doc: Kernel-local endpoint identifier, assigned by the core.
+      -
+        name: fabric-id
+        type: u32
+        doc: Identifier of the parent fabric.
+      -
+        name: fabric-ep-id
+        type: u64
+        doc: Vendor's stable fabric-local endpoint identifier.
+      -
+        name: name
+        type: string
+        doc: Human-readable endpoint name.
+      -
+        name: dev-name
+        type: string
+        doc: Bus-specific identity (e.g. PCI BDF 0000:c1:00.0).
+      -
+        name: bus-name
+        type: string
+        doc: The bus of the backing device (e.g. pci).
+
+  -
+    name: port
+    name-prefix: drm-fabric-a-port-attrs-
+    enum-name: drm-fabric-a-port-attrs
+    doc: Attributes describing a port object.
+    attributes:
+      -
+        name: port-index
+        type: u32
+        doc: Per-endpoint port index.
+      -
+        name: endpoint-id
+        type: u32
+        doc: Identifier of the endpoint that owns this port.
+      -
+        name: oper-state
+        type: u32
+        enum: port-state
+        doc: Port's operational state.
+
+      -
+        name: max-lane-count
+        type: u32
+        doc: >-
+          Maximum provider-reported lane count (link width capability).
+          0 means unknown or not reported.
+      -
+        name: max-lane-signaling-rate-mbps
+        type: u32
+        doc: >-
+          Maximum provider-reported per-lane signaling rate in decimal
+          megabits per second, before encoding, FEC and protocol overhead.
+          Zero means unknown. This is a capability, not the negotiated rate
+          or usable payload bandwidth.
+      -
+        name: peer
+        type: nest
+        nested-attributes: peer
+        doc: Peer info, absent if the port has no neighbor set.
+
+  -
+    name: peer
+    name-prefix: drm-fabric-a-peer-attrs-
+    enum-name: drm-fabric-a-peer-attrs
+    doc: Attributes describing a port's neighbor (peer).
+    attributes:
+      -
+        name: pad
+        type: pad
+      -
+        name: peer-id
+        type: u64
+        doc: >-
+          Type-qualified identity of the directly adjacent far-end object.
+          For type=accel, this is the far-end accelerator's fabric-ep-id; for
+          type=switch, it is an opaque provider-defined identity naming no
+          local endpoint. Accelerator and switch identities occupy separate
+          namespaces, and neither must resolve locally.
+      -
+        name: type
+        type: u32
+        enum: peer-type
+        doc: Kind of peer device; selects the namespace and meaning of peer-id.
+      -
+        name: port-index
+        type: u32
+        doc: Far-end port index, in the peer's own port numbering.
+
+  -
+    name: port-stats
+    name-prefix: drm-fabric-a-port-stats-attrs-
+    enum-name: drm-fabric-a-port-stats-attrs
+    doc: |-
+      Per-port statistics. All-or-none: on success every base counter is
+      present and zero is a valid count. A provider unable to supply the
+      complete base set returns -EOPNOTSUPP.
+    attributes:
+      -
+        name: pad
+        type: pad
+      -
+        name: endpoint-id
+        type: u32
+        doc: Identifier of the endpoint that owns the port.
+      -
+        name: port-index
+        type: u32
+        doc: Per-endpoint port index.
+      -
+        name: read-bytes
+        type: u64
+        doc: Bytes received on the port.
+      -
+        name: write-bytes
+        type: u64
+        doc: Bytes transmitted on the port.
+      -
+        name: link-down-count
+        type: u64
+        doc: Number of link-down transitions.
+      -
+        name: retrain-count
+        type: u64
+        doc: Number of link retrain events.
+
+operations:
+  enum-name: drm-fabric-cmd
+  list:
+    -
+      name: fabric-get
+      doc: Enumerate registered fabrics
+      attribute-set: drm-fabric
+      do:
+        request:
+          attributes:
+            - fabric-id
+        reply:
+          attributes:
+            - fabric
+            - topology-generation
+      dump:
+        reply:
+          attributes:
+            - fabric
+            - topology-generation
+
+    -
+      name: endpoint-get
+      doc: Enumerate registered endpoints, optionally filtered by fabric-id
+      attribute-set: drm-fabric
+      do:
+        request:
+          attributes:
+            - endpoint-id
+            - dev-name
+            - bus-name
+        reply:
+          attributes:
+            - endpoint
+            - topology-generation
+      dump:
+        request:
+          attributes:
+            - fabric-id
+        reply:
+          attributes:
+            - endpoint
+            - topology-generation
+
+    -
+      name: port-get
+      doc: Enumerate ports, optionally filtered by endpoint-id
+      attribute-set: drm-fabric
+      do:
+        request:
+          attributes:
+            - endpoint-id
+            - port-index
+        reply:
+          attributes:
+            - port
+            - topology-generation
+      dump:
+        request:
+          attributes:
+            - endpoint-id
+        reply:
+          attributes:
+            - port
+            - topology-generation
+
+    -
+      name: port-stats-get
+      doc: Query port-scoped statistics
+      attribute-set: drm-fabric
+      do:
+        request:
+          attributes:
+            - endpoint-id
+            - port-index
+        reply:
+          attributes:
+            - port-stats
+      dump:
+        request:
+          attributes:
+            - endpoint-id
+        reply:
+          attributes:
+            - port-stats
+
+    -
+      name: port-change-ntf
+      doc: |
+        Port state change notification. Reuses the port-get
+        reply shape (full port nest).
+      notify: port-get
+
+    -
+      name: port-peer-create-ntf
+      doc: A port's neighbor has been set.
+      attribute-set: drm-fabric
+      event:
+        attributes:
+          - endpoint-id
+          - port-index
+          - peer
+          - topology-generation
+
+    -
+      name: port-peer-delete-ntf
+      doc: A port's neighbor has been unset.
+      attribute-set: drm-fabric
+      event:
+        attributes:
+          - endpoint-id
+          - port-index
+          - peer
+          - topology-generation
+
+    -
+      name: endpoint-create-ntf
+      doc: |
+        Endpoint registration notification, fired after the endpoint is
+        committed to the registry. Reuses the endpoint-get reply shape
+        (full endpoint nest).
+      notify: endpoint-get
+
+    -
+      name: endpoint-delete-ntf
+      doc: |
+        Endpoint unregistration notification, fired before the endpoint is
+        removed from the registry so its identity is still resolvable.
+        Reuses the endpoint-get reply shape (full endpoint nest).
+      notify: endpoint-get
+
+    -
+      name: fabric-create-ntf
+      doc: |
+        Fabric registration notification, fired after the fabric is committed
+        to the registry. Reuses the fabric-get reply shape (full fabric nest).
+      notify: fabric-get
+
+    -
+      name: fabric-delete-ntf
+      doc: |
+        Fabric removal notification, fired before the fabric is removed from
+        the registry so its identity is still resolvable. Reuses the fabric-get
+        reply shape (full fabric nest).
+      notify: fabric-get
+
+mcast-groups:
+  list:
+    -
+      name: monitor
diff --git a/drivers/gpu/drm/fabric/drm_fabric_nl.h b/drivers/gpu/drm/fabric/drm_fabric_nl.h
new file mode 100644
index 000000000000..8cedc004260f
--- /dev/null
+++ b/drivers/gpu/drm/fabric/drm_fabric_nl.h
@@ -0,0 +1,36 @@
+/* SPDX-License-Identifier: ((GPL-2.0 WITH Linux-syscall-note) OR BSD-3-Clause) */
+/* Do not edit directly, auto-generated from: */
+/*	Documentation/netlink/specs/drm_fabric.yaml */
+/* YNL-GEN kernel header */
+/* To regenerate run: tools/net/ynl/ynl-regen.sh */
+
+#ifndef _LINUX_DRM_FABRIC_GEN_H
+#define _LINUX_DRM_FABRIC_GEN_H
+
+#include <net/netlink.h>
+#include <net/genetlink.h>
+
+#include <uapi/drm/drm_fabric.h>
+
+int drm_fabric_nl_fabric_get_doit(struct sk_buff *skb, struct genl_info *info);
+int drm_fabric_nl_fabric_get_dumpit(struct sk_buff *skb,
+				    struct netlink_callback *cb);
+int drm_fabric_nl_endpoint_get_doit(struct sk_buff *skb,
+				    struct genl_info *info);
+int drm_fabric_nl_endpoint_get_dumpit(struct sk_buff *skb,
+				      struct netlink_callback *cb);
+int drm_fabric_nl_port_get_doit(struct sk_buff *skb, struct genl_info *info);
+int drm_fabric_nl_port_get_dumpit(struct sk_buff *skb,
+				  struct netlink_callback *cb);
+int drm_fabric_nl_port_stats_get_doit(struct sk_buff *skb,
+				      struct genl_info *info);
+int drm_fabric_nl_port_stats_get_dumpit(struct sk_buff *skb,
+					struct netlink_callback *cb);
+
+enum {
+	DRM_FABRIC_NLGRP_MONITOR,
+};
+
+extern struct genl_family drm_fabric_nl_family;
+
+#endif /* _LINUX_DRM_FABRIC_GEN_H */
diff --git a/include/drm/drm_fabric.h b/include/drm/drm_fabric.h
index 68d8acec2d38..136100b3da67 100644
--- a/include/drm/drm_fabric.h
+++ b/include/drm/drm_fabric.h
@@ -4,8 +4,8 @@
  */
 
 /*
- * DRM Fabric driver API: common object model for GPU interconnect topology
- * (fabric, endpoint, port and peer relationships).
+ * Common object model for GPU interconnect topology: fabric, endpoint, port
+ * and peer relationships.
  */
 
 #ifndef __DRM_FABRIC_H__
@@ -16,22 +16,7 @@
 #include <linux/types.h>
 #include <linux/xarray.h>
 
-enum drm_fabric_type {
-	/* Zero is invalid; concrete fabric types start at 1. */
-	DRM_FABRIC_TYPE_SYNTHETIC = 1,
-};
-
-enum drm_fabric_port_state {
-	DRM_FABRIC_PORT_STATE_UNKNOWN,
-	DRM_FABRIC_PORT_STATE_INACTIVE,
-	DRM_FABRIC_PORT_STATE_ACTIVE,
-	DRM_FABRIC_PORT_STATE_DEGRADED,
-};
-
-enum drm_fabric_peer_type {
-	DRM_FABRIC_PEER_TYPE_ACCEL = 1,
-	DRM_FABRIC_PEER_TYPE_SWITCH,
-};
+#include <uapi/drm/drm_fabric.h>
 
 struct device;
 
diff --git a/include/uapi/drm/drm_fabric.h b/include/uapi/drm/drm_fabric.h
new file mode 100644
index 000000000000..b6c7bd6e35f0
--- /dev/null
+++ b/include/uapi/drm/drm_fabric.h
@@ -0,0 +1,134 @@
+/* SPDX-License-Identifier: ((GPL-2.0 WITH Linux-syscall-note) OR BSD-3-Clause) */
+/* Do not edit directly, auto-generated from: */
+/*	Documentation/netlink/specs/drm_fabric.yaml */
+/* YNL-GEN uapi header */
+/* To regenerate run: tools/net/ynl/ynl-regen.sh */
+
+#ifndef _UAPI_LINUX_DRM_FABRIC_H
+#define _UAPI_LINUX_DRM_FABRIC_H
+
+#define DRM_FABRIC_FAMILY_NAME		"drm-fabric"
+#define DRM_FABRIC_FAMILY_VERSION	1
+
+/*
+ * Fabric interconnect technology. New types are added with the provider that
+ * first reports them. Zero is not a valid type.
+ */
+enum drm_fabric_type {
+	DRM_FABRIC_TYPE_SYNTHETIC = 1,
+};
+
+/*
+ * Operational (link) state of a port, as observed by the driver.
+ */
+enum drm_fabric_port_state {
+	DRM_FABRIC_PORT_STATE_UNKNOWN,
+	DRM_FABRIC_PORT_STATE_INACTIVE,
+	DRM_FABRIC_PORT_STATE_ACTIVE,
+	DRM_FABRIC_PORT_STATE_DEGRADED,
+};
+
+/*
+ * Kind of device directly adjacent at a port's far end.
+ */
+enum drm_fabric_peer_type {
+	DRM_FABRIC_PEER_TYPE_ACCEL = 1,
+	DRM_FABRIC_PEER_TYPE_SWITCH,
+};
+
+enum drm_fabric_a {
+	DRM_FABRIC_A_FABRIC = 1,
+	DRM_FABRIC_A_ENDPOINT,
+	DRM_FABRIC_A_PORT,
+	DRM_FABRIC_A_PORT_STATS,
+	DRM_FABRIC_A_FABRIC_ID,
+	DRM_FABRIC_A_ENDPOINT_ID,
+	DRM_FABRIC_A_PORT_INDEX,
+	DRM_FABRIC_A_DEV_NAME,
+	DRM_FABRIC_A_BUS_NAME,
+	DRM_FABRIC_A_PEER,
+	DRM_FABRIC_A_TOPOLOGY_GENERATION,
+
+	__DRM_FABRIC_A_MAX,
+	DRM_FABRIC_A_MAX = (__DRM_FABRIC_A_MAX - 1)
+};
+
+enum drm_fabric_a_fabric_attrs {
+	DRM_FABRIC_A_FABRIC_ATTRS_PAD = 1,
+	DRM_FABRIC_A_FABRIC_ATTRS_FABRIC_ID,
+	DRM_FABRIC_A_FABRIC_ATTRS_TYPE,
+	DRM_FABRIC_A_FABRIC_ATTRS_NAME,
+	DRM_FABRIC_A_FABRIC_ATTRS_INSTANCE_ID,
+
+	__DRM_FABRIC_A_FABRIC_ATTRS_MAX,
+	DRM_FABRIC_A_FABRIC_ATTRS_MAX = (__DRM_FABRIC_A_FABRIC_ATTRS_MAX - 1)
+};
+
+enum drm_fabric_a_endpoint_attrs {
+	DRM_FABRIC_A_ENDPOINT_ATTRS_PAD = 1,
+	DRM_FABRIC_A_ENDPOINT_ATTRS_ENDPOINT_ID,
+	DRM_FABRIC_A_ENDPOINT_ATTRS_FABRIC_ID,
+	DRM_FABRIC_A_ENDPOINT_ATTRS_FABRIC_EP_ID,
+	DRM_FABRIC_A_ENDPOINT_ATTRS_NAME,
+	DRM_FABRIC_A_ENDPOINT_ATTRS_DEV_NAME,
+	DRM_FABRIC_A_ENDPOINT_ATTRS_BUS_NAME,
+
+	__DRM_FABRIC_A_ENDPOINT_ATTRS_MAX,
+	DRM_FABRIC_A_ENDPOINT_ATTRS_MAX = (__DRM_FABRIC_A_ENDPOINT_ATTRS_MAX - 1)
+};
+
+enum drm_fabric_a_port_attrs {
+	DRM_FABRIC_A_PORT_ATTRS_PORT_INDEX = 1,
+	DRM_FABRIC_A_PORT_ATTRS_ENDPOINT_ID,
+	DRM_FABRIC_A_PORT_ATTRS_OPER_STATE,
+	DRM_FABRIC_A_PORT_ATTRS_MAX_LANE_COUNT,
+	DRM_FABRIC_A_PORT_ATTRS_MAX_LANE_SIGNALING_RATE_MBPS,
+	DRM_FABRIC_A_PORT_ATTRS_PEER,
+
+	__DRM_FABRIC_A_PORT_ATTRS_MAX,
+	DRM_FABRIC_A_PORT_ATTRS_MAX = (__DRM_FABRIC_A_PORT_ATTRS_MAX - 1)
+};
+
+enum drm_fabric_a_peer_attrs {
+	DRM_FABRIC_A_PEER_ATTRS_PAD = 1,
+	DRM_FABRIC_A_PEER_ATTRS_PEER_ID,
+	DRM_FABRIC_A_PEER_ATTRS_TYPE,
+	DRM_FABRIC_A_PEER_ATTRS_PORT_INDEX,
+
+	__DRM_FABRIC_A_PEER_ATTRS_MAX,
+	DRM_FABRIC_A_PEER_ATTRS_MAX = (__DRM_FABRIC_A_PEER_ATTRS_MAX - 1)
+};
+
+enum drm_fabric_a_port_stats_attrs {
+	DRM_FABRIC_A_PORT_STATS_ATTRS_PAD = 1,
+	DRM_FABRIC_A_PORT_STATS_ATTRS_ENDPOINT_ID,
+	DRM_FABRIC_A_PORT_STATS_ATTRS_PORT_INDEX,
+	DRM_FABRIC_A_PORT_STATS_ATTRS_READ_BYTES,
+	DRM_FABRIC_A_PORT_STATS_ATTRS_WRITE_BYTES,
+	DRM_FABRIC_A_PORT_STATS_ATTRS_LINK_DOWN_COUNT,
+	DRM_FABRIC_A_PORT_STATS_ATTRS_RETRAIN_COUNT,
+
+	__DRM_FABRIC_A_PORT_STATS_ATTRS_MAX,
+	DRM_FABRIC_A_PORT_STATS_ATTRS_MAX = (__DRM_FABRIC_A_PORT_STATS_ATTRS_MAX - 1)
+};
+
+enum drm_fabric_cmd {
+	DRM_FABRIC_CMD_FABRIC_GET = 1,
+	DRM_FABRIC_CMD_ENDPOINT_GET,
+	DRM_FABRIC_CMD_PORT_GET,
+	DRM_FABRIC_CMD_PORT_STATS_GET,
+	DRM_FABRIC_CMD_PORT_CHANGE_NTF,
+	DRM_FABRIC_CMD_PORT_PEER_CREATE_NTF,
+	DRM_FABRIC_CMD_PORT_PEER_DELETE_NTF,
+	DRM_FABRIC_CMD_ENDPOINT_CREATE_NTF,
+	DRM_FABRIC_CMD_ENDPOINT_DELETE_NTF,
+	DRM_FABRIC_CMD_FABRIC_CREATE_NTF,
+	DRM_FABRIC_CMD_FABRIC_DELETE_NTF,
+
+	__DRM_FABRIC_CMD_MAX,
+	DRM_FABRIC_CMD_MAX = (__DRM_FABRIC_CMD_MAX - 1)
+};
+
+#define DRM_FABRIC_MCGRP_MONITOR	"monitor"
+
+#endif /* _UAPI_LINUX_DRM_FABRIC_H */
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [RFC PATCH 03/12] drm/fabric: implement query netlink operations
  2026-08-24  8:09 [RFC PATCH 0/12] drm/fabric: vendor-neutral topology infrastructure for scale-up accelerator interconnects Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 01/12] drm/fabric: add core object model and provider API Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 02/12] drm/fabric: add query uAPI and generated headers Konstantin Sinyuk
@ 2026-08-24  8:09 ` Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 04/12] drm/fabric: add read-only synthetic provider Konstantin Sinyuk
                   ` (8 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Konstantin Sinyuk @ 2026-08-24  8:09 UTC (permalink / raw)
  To: dri-devel
  Cc: Maarten Lankhorst, Francois Dugast, David Airlie, Simona Vetter,
	Maxime Ripard, Thomas Zimmermann, Jonathan Corbet, Shuah Khan,
	Donald Hunter, Jakub Kicinski, David S. Miller, Eric Dumazet,
	Paolo Abeni, Simon Horman, Ilia Levi, Rodrigo Vivi, linux-doc,
	linux-kselftest, netdev, linux-kernel

Register the drm-fabric Generic Netlink family and connect the four
read-only query commands to the core object model, adding the generated
operation and policy source that dispatches to them. Their reply shapes
are:

  fabric-get       -> fabric, topology-generation
  endpoint-get     -> endpoint, topology-generation
  port-get         -> port, topology-generation
  port-stats-get   -> port-stats

Support single-object lookup and multipart dumps for fabrics, endpoints and
ports, with optional fabric and endpoint filters. Preserve the nested
endpoint/port dump cursor across batches, including endpoint removal and
terminal port indices.

Port statistics are optional: a targeted unsupported request returns
-EOPNOTSUPP, while a dump skips unsupported ports and continues; other
provider errors terminate the dump. Use topology-generation for dump
consistency and report concurrent changes with NLM_F_DUMP_INTR.

Emit fabric, endpoint, port-state and peer notifications through the
monitor multicast group. Advance topology-generation before emitting each
notification, so the event carries the same post-change value as a later
query.

Queries and notifications are confined to init_net. The query commands,
notifications and dump-consistency behavior are documented in
Documentation/gpu/drm-fabric.rst.

Co-developed-by: Ilia Levi <ilia.levi@intel.com>
Signed-off-by: Ilia Levi <ilia.levi@intel.com>
Signed-off-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Assisted-by: GitHub-Copilot:claude-opus-4.8
---
 Documentation/gpu/drm-fabric.rst             |  148 ++-
 drivers/gpu/drm/fabric/Kconfig               |    6 +-
 drivers/gpu/drm/fabric/Makefile              |    2 +-
 drivers/gpu/drm/fabric/drm_fabric.c          |   29 +-
 drivers/gpu/drm/fabric/drm_fabric_internal.h |   19 +
 drivers/gpu/drm/fabric/drm_fabric_netlink.c  | 1035 ++++++++++++++++++
 drivers/gpu/drm/fabric/drm_fabric_nl.c       |  125 +++
 7 files changed, 1345 insertions(+), 19 deletions(-)
 create mode 100644 drivers/gpu/drm/fabric/drm_fabric_netlink.c
 create mode 100644 drivers/gpu/drm/fabric/drm_fabric_nl.c

diff --git a/Documentation/gpu/drm-fabric.rst b/Documentation/gpu/drm-fabric.rst
index d2c9e694b67f..a8b33d6cd618 100644
--- a/Documentation/gpu/drm-fabric.rst
+++ b/Documentation/gpu/drm-fabric.rst
@@ -18,8 +18,8 @@ Key Goals:
   (xGMI, UALink and similar), enabling data-center discovery and monitoring.
 * Support read-only enumeration, monitoring and state queries for
   provider-owned topology.
-* Offer a flexible, future-proof interface that can be extended with new fabric
-  types and attributes without breaking the uAPI.
+* Allow new attributes and fabric types to be added without reusing existing
+  wire identifiers, so the uAPI extends without breaking existing consumers.
 * Allow multiple endpoints and ports per provider, so drivers can model
   accelerator attachments, links and their peers.
 
@@ -84,10 +84,14 @@ an accelerator on another node, or a local endpoint that merely unregistered
 -- so only the provider knows when a port's physical adjacency actually
 changed, and only the provider retracts or replaces the descriptor.
 
-Endpoint teardown removes the endpoint's owned half-edges without generating
-a separate event for each port: the delete already describes the transition,
-so removing an endpoint advances the topology generation once rather than
-once per child port.
+``port-peer-delete-ntf`` reports an explicitly retracted half-edge; it is
+not emitted when a peer merely becomes locally unresolvable, so its absence
+is not evidence the far end is still reachable.
+
+Endpoint teardown removes the endpoint's owned half-edges without
+generating a separate event for each port: the delete already describes
+the transition, so removing an endpoint advances the topology generation
+once rather than once per child port.
 
 Driver API
 ----------
@@ -175,3 +179,135 @@ objects and are not persistent hardware identities: they remain valid for the
 lifetime of the registered object, but may disappear or be reused after the
 object is unregistered. ``instance-id`` and ``fabric-ep-id`` carry
 provider-defined identity, whose scope is described by the containing object.
+
+Query operations
+================
+
+User space enumerates topology with four read-only commands, each supporting a
+single lookup (``do``) and a bulk dump (``dump``):
+
+* ``fabric-get`` -- enumerate fabrics (``do`` by ``fabric-id``, ``dump`` for all).
+* ``endpoint-get`` -- enumerate endpoints, optionally filtered by ``fabric-id``,
+  or resolve one by ``endpoint-id`` or backing ``dev-name``/``bus-name``.
+* ``port-get`` -- enumerate ports, filtered by ``endpoint-id``. Ports may report
+  the provider's maximum capability as ``max-lane-count`` and
+  ``max-lane-signaling-rate-mbps`` (zero means unknown); these are maxima, not
+  the currently negotiated width or rate.
+* ``port-stats-get`` -- per-port statistics, filtered by ``endpoint-id``. A
+  targeted request for a port whose provider does not implement
+  ``port_stats_get`` returns ``-EOPNOTSUPP``. During a dump, ports without
+  statistics support are omitted and enumeration continues with later ports;
+  any other provider error ends the dump.
+
+Notifications
+-------------
+
+Subscribe to the ``monitor`` multicast group to receive asynchronous change
+notifications. Full-object notifications reuse the shape of their matching
+``get`` reply and are declared with ``notify:``; peer-link notifications carry
+a partial payload and are declared with ``event:``.
+
+.. list-table::
+   :header-rows: 1
+
+   * - Notification
+     - Trigger
+     - Payload
+   * - ``fabric-create-ntf``
+     - a fabric is registered by a provider
+     - reuses ``fabric-get``
+   * - ``fabric-delete-ntf``
+     - a provider-owned fabric is unregistered
+     - reuses ``fabric-get``
+   * - ``endpoint-create-ntf``
+     - an endpoint is registered
+     - reuses ``endpoint-get``
+   * - ``endpoint-delete-ntf``
+     - an endpoint is unregistered
+     - reuses ``endpoint-get``
+   * - ``port-change-ntf``
+     - a port's operational state changes
+     - reuses ``port-get``
+   * - ``port-peer-create-ntf``
+     - a port's peer descriptor is set by its provider
+     - partial (``event:``)
+   * - ``port-peer-delete-ntf``
+     - a port's peer descriptor is explicitly unset by its provider; never
+       emitted for an implicit half-edge loss (see `Peer semantics`_)
+     - partial (``event:``)
+
+Notifications are best-effort. A listener that detects loss, restarts, or
+receives an interrupted dump must rebuild state with the query commands.
+
+Topology generation and dump consistency
+----------------------------------------
+
+The core keeps a nonzero generation counter and advances it whenever topology or
+exposed state changes. It is surfaced as the ``topology-generation`` attribute on
+``fabric-get``, ``endpoint-get`` and ``port-get`` replies and on the topology
+notifications. A provider-reported change advances the generation before its
+notification is serialized, so an event carries the post-change value that a
+later ``get``/``dump`` will also report.
+
+``topology-generation`` is a change token, not a timestamp, liveness counter or
+event count: a changed value means topology changed, but the delta between two
+values has no defined meaning and the counter may wrap (it skips zero). Statistics
+reads do not advance it.
+
+The same value backs dump consistency. Every multipart dump samples it and calls
+``genl_dump_check_consistent()``; if it changes between dump batches, Generic
+Netlink marks the dump with ``NLM_F_DUMP_INTR``, meaning the snapshot may be torn.
+User space must then discard the partial result and retry the complete dump. The
+generation does not apply to statistics reads.
+
+Only a dump that serialized at least one entry can carry ``NLM_F_DUMP_INTR``,
+because Generic Netlink arms the consistency check on the first entry it emits.
+A dump that yields no entries at all cannot report interruption, so user space
+should treat an empty result as advisory and re-read ``topology-generation``
+before concluding that the topology is empty.
+
+Namespaces
+----------
+
+DRM Fabric objects describe host-global hardware and are not scoped per network
+namespace. Query and dump operations (``*-get``) are therefore accepted only from
+the initial network namespace; a request from any other network namespace fails
+with ``-EPERM``. The Generic Netlink family is registered ``netnsok`` (so it
+resolves in any network namespace and can return that policy error rather than
+being invisible), but the operations themselves remain confined to ``init_net``.
+Monitor notifications are likewise emitted only into ``init_net``, so a listener
+that joins the multicast group from another network namespace never receives
+them.
+
+Examples
+========
+
+Query the topology with the in-tree YNL tool, pointing it at the spec:
+
+.. code-block:: bash
+
+    # List all fabrics
+    ./tools/net/ynl/pyynl/cli.py \
+        --spec Documentation/netlink/specs/drm_fabric.yaml \
+        --dump fabric-get
+
+Replies follow the shapes described above; a provider must be registered for
+the topology to be non-empty.
+
+List the endpoints of a fabric:
+
+.. code-block:: bash
+
+    ./tools/net/ynl/pyynl/cli.py \
+        --spec Documentation/netlink/specs/drm_fabric.yaml \
+        --dump endpoint-get --json '{"fabric-id": 1}'
+
+Query a single port:
+
+.. code-block:: bash
+
+    ./tools/net/ynl/pyynl/cli.py \
+        --spec Documentation/netlink/specs/drm_fabric.yaml \
+        --do port-get --json '{"endpoint-id": 1, "port-index": 0}'
+
+The family name on the wire is ``drm-fabric``.
diff --git a/drivers/gpu/drm/fabric/Kconfig b/drivers/gpu/drm/fabric/Kconfig
index 21fbfae9863d..7e9c569fd258 100644
--- a/drivers/gpu/drm/fabric/Kconfig
+++ b/drivers/gpu/drm/fabric/Kconfig
@@ -4,9 +4,9 @@ config DRM_FABRIC
 	tristate "DRM fabric support"
 	depends on DRM && NET
 	help
-	  Enable DRM fabric support. This infrastructure provides the
-	  core object model and provider API for registered accelerator
-	  interconnect topologies.
+	  Enable DRM fabric support. This infrastructure exposes
+	  registered accelerator interconnect topologies to userspace
+	  through the drm-fabric generic netlink family.
 
 	  To compile this as a module, choose M here: the module will be
 	  called drm-fabric.
diff --git a/drivers/gpu/drm/fabric/Makefile b/drivers/gpu/drm/fabric/Makefile
index 3a76f31f1e83..cf9d9d6be3d3 100644
--- a/drivers/gpu/drm/fabric/Makefile
+++ b/drivers/gpu/drm/fabric/Makefile
@@ -1,4 +1,4 @@
 # SPDX-License-Identifier: GPL-2.0
 
 obj-$(CONFIG_DRM_FABRIC) += drm-fabric.o
-drm-fabric-y := drm_fabric.o
+drm-fabric-y := drm_fabric.o drm_fabric_netlink.o drm_fabric_nl.o
diff --git a/drivers/gpu/drm/fabric/drm_fabric.c b/drivers/gpu/drm/fabric/drm_fabric.c
index 8769d7bdcde1..ce331656af70 100644
--- a/drivers/gpu/drm/fabric/drm_fabric.c
+++ b/drivers/gpu/drm/fabric/drm_fabric.c
@@ -186,7 +186,7 @@ struct drm_fabric *drm_fabric_register(const struct drm_fabric_desc *desc)
 			       xa_limit_32b, GFP_KERNEL);
 		if (ret)
 			return ERR_PTR(ret);
-		drm_fabric_base_seq_inc();
+		drm_fabric_emit_fabric_create(fabric, drm_fabric_base_seq_inc());
 	}
 
 	return_ptr(fabric);
@@ -270,7 +270,9 @@ int drm_fabric_unregister(struct drm_fabric *fabric)
 		 */
 		if (WARN_ON_ONCE(drm_fabric_has_members(fabric)))
 			return -EBUSY;
-		drm_fabric_base_seq_inc();
+		/* Emit before the erase, while @fabric is still live. */
+		drm_fabric_emit_fabric_delete(fabric,
+					      drm_fabric_base_seq_inc());
 		xa_erase(&drm_fabric_xa, fabric->id);
 	}
 
@@ -410,7 +412,7 @@ drm_fabric_endpoint_register(struct drm_fabric *fabric,
 			break;
 		drm_fabric_get(fabric);
 
-		drm_fabric_base_seq_inc();
+		drm_fabric_emit_endpoint_create(ep, drm_fabric_base_seq_inc());
 	}
 
 	if (ret) {
@@ -499,7 +501,8 @@ void drm_fabric_endpoint_unregister(struct drm_fabric_endpoint *ep)
 	scoped_guard(mutex, &drm_fabric_lock) {
 		if (WARN_ON_ONCE(!drm_fabric_ep_is_registered(ep)))
 			return;
-		drm_fabric_base_seq_inc();
+		/* Emit before the erase, while @ep is still live. */
+		drm_fabric_emit_endpoint_delete(ep, drm_fabric_base_seq_inc());
 		xa_erase(&drm_fabric_ep_xa, ep->id);
 	}
 
@@ -587,7 +590,8 @@ int drm_fabric_port_set_peer(struct drm_fabric_port *port,
 			return -EEXIST;
 		port->peer = *peer;
 		port->has_peer = true;
-		drm_fabric_base_seq_inc();
+		drm_fabric_emit_port_peer_create(port, peer,
+						 drm_fabric_base_seq_inc());
 	}
 
 	return 0;
@@ -612,7 +616,9 @@ int drm_fabric_port_unset_peer(struct drm_fabric_port *port)
 	scoped_guard(mutex, &drm_fabric_lock) {
 		if (!port->has_peer)
 			return -ENOENT;
-		drm_fabric_base_seq_inc();
+		/* Emit before clearing to show the peer being removed. */
+		drm_fabric_emit_port_peer_delete(port, &port->peer,
+						 drm_fabric_base_seq_inc());
 		port->has_peer = false;
 		memset(&port->peer, 0, sizeof(port->peer));
 	}
@@ -654,19 +660,24 @@ void drm_fabric_port_set_oper(struct drm_fabric_port *port,
 		enum drm_fabric_port_state old = port->oper_state;
 
 		port->oper_state = state;
-		if (old != state)
-			drm_fabric_base_seq_inc();
+		if (old != state) {
+			u32 gen = drm_fabric_base_seq_inc();
+
+			drm_fabric_emit_port_change(port, gen);
+		}
 	}
 }
 EXPORT_SYMBOL(drm_fabric_port_set_oper);
 
 static int __init drm_fabric_init(void)
 {
-	return 0;
+	return drm_fabric_netlink_register();
 }
 
 static void __exit drm_fabric_exit(void)
 {
+	drm_fabric_netlink_unregister();
+
 	WARN_ON(!xa_empty(&drm_fabric_xa));
 	WARN_ON(!xa_empty(&drm_fabric_ep_xa));
 	xa_destroy(&drm_fabric_xa);
diff --git a/drivers/gpu/drm/fabric/drm_fabric_internal.h b/drivers/gpu/drm/fabric/drm_fabric_internal.h
index d454225a0b81..7fc5bc3f33f9 100644
--- a/drivers/gpu/drm/fabric/drm_fabric_internal.h
+++ b/drivers/gpu/drm/fabric/drm_fabric_internal.h
@@ -18,6 +18,9 @@ extern struct xarray drm_fabric_ep_xa; /* Endpoint registry */
 
 extern u32 drm_fabric_base_seq;        /* Dump consistency sequence */
 
+int drm_fabric_netlink_register(void);
+void drm_fabric_netlink_unregister(void);
+
 struct drm_fabric *drm_fabric_find_by_id(u32 id);
 struct drm_fabric_endpoint *drm_fabric_endpoint_find_by_id(u32 id);
 struct drm_fabric_endpoint *
@@ -31,4 +34,20 @@ void drm_fabric_endpoint_put(struct drm_fabric_endpoint *ep);
 struct drm_fabric *drm_fabric_get(struct drm_fabric *fabric);
 void drm_fabric_put(struct drm_fabric *fabric);
 
+/* Emits use the post-change generation. */
+void drm_fabric_emit_endpoint_create(struct drm_fabric_endpoint *ep, u32 generation);
+void drm_fabric_emit_endpoint_delete(struct drm_fabric_endpoint *ep, u32 generation);
+
+void drm_fabric_emit_port_peer_create(struct drm_fabric_port *port,
+				      const struct drm_fabric_peer *peer,
+				      u32 generation);
+void drm_fabric_emit_port_peer_delete(struct drm_fabric_port *port,
+				      const struct drm_fabric_peer *peer,
+				      u32 generation);
+
+void drm_fabric_emit_port_change(struct drm_fabric_port *port, u32 generation);
+
+void drm_fabric_emit_fabric_create(struct drm_fabric *fabric, u32 generation);
+void drm_fabric_emit_fabric_delete(struct drm_fabric *fabric, u32 generation);
+
 #endif /* __DRM_FABRIC_INTERNAL_H__ */
diff --git a/drivers/gpu/drm/fabric/drm_fabric_netlink.c b/drivers/gpu/drm/fabric/drm_fabric_netlink.c
new file mode 100644
index 000000000000..fef2d4c8f5bb
--- /dev/null
+++ b/drivers/gpu/drm/fabric/drm_fabric_netlink.c
@@ -0,0 +1,1035 @@
+// SPDX-License-Identifier: MIT
+/*
+ * Copyright © 2026 Intel Corporation
+ */
+
+#include <linux/cleanup.h>
+#include <linux/device.h>
+#include <linux/module.h>
+#include <net/genetlink.h>
+#include <net/net_namespace.h>
+
+#include <drm/drm_fabric.h>
+#include <uapi/drm/drm_fabric.h>
+
+#include "drm_fabric_internal.h"
+#include "drm_fabric_nl.h"
+
+struct drm_fabric_dump_ctx {
+	unsigned long idx;
+};
+
+static struct drm_fabric_dump_ctx *
+drm_fabric_dump_context(struct netlink_callback *cb)
+{
+	return (struct drm_fabric_dump_ctx *)cb->ctx;
+}
+
+/* Resume cursor for the nested per-port dumps (PORT_GET, PORT_STATS_GET). */
+struct drm_fabric_port_dump_ctx {
+	unsigned long ep_idx;
+	unsigned long port_idx;
+};
+
+static struct drm_fabric_port_dump_ctx *
+drm_fabric_port_dump_context(struct netlink_callback *cb)
+{
+	return (struct drm_fabric_port_dump_ctx *)cb->ctx;
+}
+
+/* netnsok so a non-init_net caller gets -EPERM, not a missing family. */
+static int drm_fabric_nl_host_only(const struct net *net)
+{
+	return net_eq(net, &init_net) ? 0 : -EPERM;
+}
+
+static int drm_fabric_fill_fabric(struct sk_buff *skb,
+				  struct drm_fabric *fabric)
+{
+	struct nlattr *nest;
+
+	nest = nla_nest_start(skb, DRM_FABRIC_A_FABRIC);
+	if (!nest)
+		return -EMSGSIZE;
+
+	if (nla_put_u32(skb, DRM_FABRIC_A_FABRIC_ATTRS_FABRIC_ID, fabric->id) ||
+	    nla_put_u32(skb, DRM_FABRIC_A_FABRIC_ATTRS_TYPE, fabric->type) ||
+	    nla_put_string(skb, DRM_FABRIC_A_FABRIC_ATTRS_NAME, fabric->name) ||
+	    nla_put_u64_64bit(skb, DRM_FABRIC_A_FABRIC_ATTRS_INSTANCE_ID,
+			      fabric->instance_id, DRM_FABRIC_A_FABRIC_ATTRS_PAD)) {
+		nla_nest_cancel(skb, nest);
+		return -EMSGSIZE;
+	}
+
+	nla_nest_end(skb, nest);
+	return 0;
+}
+
+static int drm_fabric_fill_endpoint(struct sk_buff *skb,
+				    struct drm_fabric_endpoint *ep)
+{
+	struct nlattr *nest;
+
+	nest = nla_nest_start(skb, DRM_FABRIC_A_ENDPOINT);
+	if (!nest)
+		return -EMSGSIZE;
+
+	if (nla_put_u32(skb, DRM_FABRIC_A_ENDPOINT_ATTRS_ENDPOINT_ID, ep->id) ||
+	    nla_put_u32(skb, DRM_FABRIC_A_ENDPOINT_ATTRS_FABRIC_ID,
+			drm_fabric_endpoint_fabric_id(ep)) ||
+	    nla_put_u64_64bit(skb, DRM_FABRIC_A_ENDPOINT_ATTRS_FABRIC_EP_ID,
+			      ep->fabric_ep_id, DRM_FABRIC_A_ENDPOINT_ATTRS_PAD) ||
+	    nla_put_string(skb, DRM_FABRIC_A_ENDPOINT_ATTRS_NAME, ep->name) ||
+	    nla_put_string(skb, DRM_FABRIC_A_ENDPOINT_ATTRS_DEV_NAME,
+			   dev_name(ep->parent)) ||
+	    nla_put_string(skb, DRM_FABRIC_A_ENDPOINT_ATTRS_BUS_NAME,
+			   dev_bus_name(ep->parent))) {
+		nla_nest_cancel(skb, nest);
+		return -EMSGSIZE;
+	}
+
+	nla_nest_end(skb, nest);
+	return 0;
+}
+
+static int drm_fabric_fill_peer(struct sk_buff *skb,
+				struct drm_fabric_port *port)
+{
+	struct nlattr *nest;
+
+	if (!port->has_peer)
+		return 0;
+
+	nest = nla_nest_start(skb, DRM_FABRIC_A_PORT_ATTRS_PEER);
+	if (!nest)
+		return -EMSGSIZE;
+
+	if (nla_put_u64_64bit(skb, DRM_FABRIC_A_PEER_ATTRS_PEER_ID,
+			      port->peer.peer_id, DRM_FABRIC_A_PEER_ATTRS_PAD) ||
+	    nla_put_u32(skb, DRM_FABRIC_A_PEER_ATTRS_TYPE,
+			port->peer.peer_type) ||
+	    nla_put_u32(skb, DRM_FABRIC_A_PEER_ATTRS_PORT_INDEX,
+			port->peer.port_index)) {
+		nla_nest_cancel(skb, nest);
+		return -EMSGSIZE;
+	}
+
+	nla_nest_end(skb, nest);
+	return 0;
+}
+
+static int drm_fabric_fill_port(struct sk_buff *skb,
+				struct drm_fabric_port *port)
+{
+	struct nlattr *nest;
+	int ret;
+
+	nest = nla_nest_start(skb, DRM_FABRIC_A_PORT);
+	if (!nest)
+		return -EMSGSIZE;
+
+	if (nla_put_u32(skb, DRM_FABRIC_A_PORT_ATTRS_PORT_INDEX, port->index) ||
+	    nla_put_u32(skb, DRM_FABRIC_A_PORT_ATTRS_ENDPOINT_ID, port->endpoint->id) ||
+	    nla_put_u32(skb, DRM_FABRIC_A_PORT_ATTRS_OPER_STATE,
+			port->oper_state) ||
+	    nla_put_u32(skb, DRM_FABRIC_A_PORT_ATTRS_MAX_LANE_COUNT,
+			port->max_lane_count) ||
+	    nla_put_u32(skb, DRM_FABRIC_A_PORT_ATTRS_MAX_LANE_SIGNALING_RATE_MBPS,
+			port->max_lane_signaling_rate_mbps)) {
+		nla_nest_cancel(skb, nest);
+		return -EMSGSIZE;
+	}
+
+	ret = drm_fabric_fill_peer(skb, port);
+	if (ret) {
+		nla_nest_cancel(skb, nest);
+		return ret;
+	}
+
+	nla_nest_end(skb, nest);
+	return 0;
+}
+
+static int drm_fabric_fill_port_stats(struct sk_buff *skb,
+				      struct drm_fabric_port *port,
+				      struct drm_fabric_port_stats *stats)
+{
+	struct nlattr *nest;
+
+	nest = nla_nest_start(skb, DRM_FABRIC_A_PORT_STATS);
+	if (!nest)
+		return -EMSGSIZE;
+
+	if (nla_put_u32(skb, DRM_FABRIC_A_PORT_STATS_ATTRS_ENDPOINT_ID,
+			port->endpoint->id) ||
+	    nla_put_u32(skb, DRM_FABRIC_A_PORT_STATS_ATTRS_PORT_INDEX, port->index) ||
+	    nla_put_u64_64bit(skb, DRM_FABRIC_A_PORT_STATS_ATTRS_READ_BYTES,
+			      stats->read_bytes, DRM_FABRIC_A_PORT_STATS_ATTRS_PAD) ||
+	    nla_put_u64_64bit(skb, DRM_FABRIC_A_PORT_STATS_ATTRS_WRITE_BYTES,
+			      stats->write_bytes, DRM_FABRIC_A_PORT_STATS_ATTRS_PAD) ||
+	    nla_put_u64_64bit(skb, DRM_FABRIC_A_PORT_STATS_ATTRS_LINK_DOWN_COUNT,
+			      stats->link_down_count, DRM_FABRIC_A_PORT_STATS_ATTRS_PAD) ||
+	    nla_put_u64_64bit(skb, DRM_FABRIC_A_PORT_STATS_ATTRS_RETRAIN_COUNT,
+			      stats->retrain_count, DRM_FABRIC_A_PORT_STATS_ATTRS_PAD)) {
+		nla_nest_cancel(skb, nest);
+		return -EMSGSIZE;
+	}
+
+	nla_nest_end(skb, nest);
+	return 0;
+}
+
+int drm_fabric_nl_fabric_get_doit(struct sk_buff *skb, struct genl_info *info)
+{
+	struct drm_fabric *fabric;
+	struct sk_buff *msg;
+	u32 fabric_id;
+	void *hdr;
+	int ret;
+
+	ret = drm_fabric_nl_host_only(genl_info_net(info));
+	if (ret)
+		return ret;
+
+	if (GENL_REQ_ATTR_CHECK(info, DRM_FABRIC_A_FABRIC_ID))
+		return -EINVAL;
+
+	fabric_id = nla_get_u32(info->attrs[DRM_FABRIC_A_FABRIC_ID]);
+
+	msg = nlmsg_new(NLMSG_DEFAULT_SIZE, GFP_KERNEL);
+	if (!msg)
+		return -ENOMEM;
+
+	hdr = genlmsg_put(msg, info->snd_portid, info->snd_seq,
+			  &drm_fabric_nl_family, 0,
+			  DRM_FABRIC_CMD_FABRIC_GET);
+	if (!hdr) {
+		nlmsg_free(msg);
+		return -EMSGSIZE;
+	}
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		fabric = drm_fabric_find_by_id(fabric_id);
+		if (!fabric) {
+			nlmsg_free(msg);
+			return -ENOENT;
+		}
+
+		ret = drm_fabric_fill_fabric(msg, fabric);
+		if (ret) {
+			nlmsg_free(msg);
+			return ret;
+		}
+
+		if (nla_put_u32(msg, DRM_FABRIC_A_TOPOLOGY_GENERATION,
+				drm_fabric_base_seq)) {
+			nlmsg_free(msg);
+			return -EMSGSIZE;
+		}
+	}
+
+	genlmsg_end(msg, hdr);
+	return genlmsg_reply(msg, info);
+}
+
+int drm_fabric_nl_fabric_get_dumpit(struct sk_buff *skb,
+				    struct netlink_callback *cb)
+{
+	struct drm_fabric_dump_ctx *ctx = drm_fabric_dump_context(cb);
+	struct drm_fabric *fabric;
+	unsigned long fidx;
+	void *hdr;
+	int ret;
+
+	ret = drm_fabric_nl_host_only(genl_info_net(genl_info_dump(cb)));
+	if (ret)
+		return ret;
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		cb->seq = drm_fabric_base_seq;
+		xa_for_each_start(&drm_fabric_xa, fidx, fabric, ctx->idx) {
+			hdr = genlmsg_put(skb, NETLINK_CB(cb->skb).portid,
+					  cb->nlh->nlmsg_seq,
+					  &drm_fabric_nl_family, NLM_F_MULTI,
+					  DRM_FABRIC_CMD_FABRIC_GET);
+			if (!hdr) {
+				ret = -EMSGSIZE;
+				break;
+			}
+			genl_dump_check_consistent(cb, hdr);
+
+			if (nla_put_u32(skb, DRM_FABRIC_A_TOPOLOGY_GENERATION,
+					drm_fabric_base_seq)) {
+				genlmsg_cancel(skb, hdr);
+				ret = -EMSGSIZE;
+				break;
+			}
+
+			ret = drm_fabric_fill_fabric(skb, fabric);
+			if (ret) {
+				genlmsg_cancel(skb, hdr);
+				break;
+			}
+
+			genlmsg_end(skb, hdr);
+		}
+	}
+
+	if (ret == -EMSGSIZE) {
+		ctx->idx = fidx;
+		return skb->len;
+	}
+	return ret;
+}
+
+/* Identified by core-assigned id, or by bus + dev name. */
+static struct drm_fabric_endpoint *
+drm_fabric_resolve_endpoint(struct genl_info *info)
+{
+	struct nlattr **attrs = info->attrs;
+	const char *devname = NULL;
+	const char *busname = NULL;
+	struct drm_fabric_endpoint *ep;
+
+	lockdep_assert_held(&drm_fabric_lock);
+
+	if (attrs[DRM_FABRIC_A_DEV_NAME])
+		devname = nla_data(attrs[DRM_FABRIC_A_DEV_NAME]);
+	if (attrs[DRM_FABRIC_A_BUS_NAME])
+		busname = nla_data(attrs[DRM_FABRIC_A_BUS_NAME]);
+
+	if (attrs[DRM_FABRIC_A_ENDPOINT_ID]) {
+		u32 ep_id = nla_get_u32(attrs[DRM_FABRIC_A_ENDPOINT_ID]);
+
+		ep = drm_fabric_endpoint_find_by_id(ep_id);
+		if (!ep)
+			return ERR_PTR(-ENOENT);
+
+		if (devname && strcmp(dev_name(ep->parent), devname))
+			return ERR_PTR(-EINVAL);
+		if (busname && strcmp(dev_bus_name(ep->parent), busname))
+			return ERR_PTR(-EINVAL);
+
+		return ep;
+	}
+
+	if (!devname)
+		return ERR_PTR(-EINVAL);
+
+	ep = drm_fabric_endpoint_find_by_dev_name(devname, busname);
+	if (IS_ERR(ep))
+		return ep;
+	if (!ep)
+		return ERR_PTR(-ENOENT);
+
+	return ep;
+}
+
+int drm_fabric_nl_endpoint_get_doit(struct sk_buff *skb,
+				    struct genl_info *info)
+{
+	struct drm_fabric_endpoint *ep;
+	struct sk_buff *msg;
+	void *hdr;
+	int ret;
+
+	ret = drm_fabric_nl_host_only(genl_info_net(info));
+	if (ret)
+		return ret;
+
+	msg = nlmsg_new(NLMSG_DEFAULT_SIZE, GFP_KERNEL);
+	if (!msg)
+		return -ENOMEM;
+
+	hdr = genlmsg_put(msg, info->snd_portid, info->snd_seq,
+			  &drm_fabric_nl_family, 0,
+			  DRM_FABRIC_CMD_ENDPOINT_GET);
+	if (!hdr) {
+		nlmsg_free(msg);
+		return -EMSGSIZE;
+	}
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		ep = drm_fabric_resolve_endpoint(info);
+		if (IS_ERR(ep)) {
+			nlmsg_free(msg);
+			return PTR_ERR(ep);
+		}
+
+		ret = drm_fabric_fill_endpoint(msg, ep);
+		if (ret) {
+			nlmsg_free(msg);
+			return ret;
+		}
+
+		if (nla_put_u32(msg, DRM_FABRIC_A_TOPOLOGY_GENERATION,
+				drm_fabric_base_seq)) {
+			nlmsg_free(msg);
+			return -EMSGSIZE;
+		}
+	}
+
+	genlmsg_end(msg, hdr);
+	return genlmsg_reply(msg, info);
+}
+
+int drm_fabric_nl_endpoint_get_dumpit(struct sk_buff *skb,
+				      struct netlink_callback *cb)
+{
+	const struct genl_info *info = genl_info_dump(cb);
+	struct drm_fabric_dump_ctx *ctx = drm_fabric_dump_context(cb);
+	u32 filter_fabric_id = 0;
+	bool has_filter = false;
+	struct drm_fabric_endpoint *ep;
+	unsigned long eidx;
+	void *hdr;
+	int ret;
+
+	ret = drm_fabric_nl_host_only(genl_info_net(info));
+	if (ret)
+		return ret;
+
+	if (info->attrs[DRM_FABRIC_A_FABRIC_ID]) {
+		filter_fabric_id =
+			nla_get_u32(info->attrs[DRM_FABRIC_A_FABRIC_ID]);
+		has_filter = true;
+	}
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		cb->seq = drm_fabric_base_seq;
+		xa_for_each_start(&drm_fabric_ep_xa, eidx, ep, ctx->idx) {
+			if (has_filter &&
+			    drm_fabric_endpoint_fabric_id(ep) != filter_fabric_id)
+				continue;
+
+			hdr = genlmsg_put(skb, NETLINK_CB(cb->skb).portid,
+					  cb->nlh->nlmsg_seq,
+					  &drm_fabric_nl_family, NLM_F_MULTI,
+					  DRM_FABRIC_CMD_ENDPOINT_GET);
+			if (!hdr) {
+				ret = -EMSGSIZE;
+				break;
+			}
+			genl_dump_check_consistent(cb, hdr);
+
+			if (nla_put_u32(skb, DRM_FABRIC_A_TOPOLOGY_GENERATION,
+					drm_fabric_base_seq)) {
+				genlmsg_cancel(skb, hdr);
+				ret = -EMSGSIZE;
+				break;
+			}
+
+			ret = drm_fabric_fill_endpoint(skb, ep);
+			if (ret) {
+				genlmsg_cancel(skb, hdr);
+				break;
+			}
+
+			genlmsg_end(skb, hdr);
+		}
+	}
+
+	if (ret == -EMSGSIZE) {
+		ctx->idx = eidx;
+		return skb->len;
+	}
+	return ret;
+}
+
+static int drm_fabric_port_key(struct genl_info *info, u32 *ep_id, u32 *port_idx)
+{
+	if (GENL_REQ_ATTR_CHECK(info, DRM_FABRIC_A_ENDPOINT_ID) ||
+	    GENL_REQ_ATTR_CHECK(info, DRM_FABRIC_A_PORT_INDEX))
+		return -EINVAL;
+
+	*ep_id = nla_get_u32(info->attrs[DRM_FABRIC_A_ENDPOINT_ID]);
+	*port_idx = nla_get_u32(info->attrs[DRM_FABRIC_A_PORT_INDEX]);
+	return 0;
+}
+
+int drm_fabric_nl_port_get_doit(struct sk_buff *skb,
+				struct genl_info *info)
+{
+	struct drm_fabric_port *port;
+	struct sk_buff *msg;
+	u32 ep_id, port_idx;
+	void *hdr;
+	int ret;
+
+	ret = drm_fabric_nl_host_only(genl_info_net(info));
+	if (ret)
+		return ret;
+
+	ret = drm_fabric_port_key(info, &ep_id, &port_idx);
+	if (ret)
+		return ret;
+
+	msg = nlmsg_new(NLMSG_DEFAULT_SIZE, GFP_KERNEL);
+	if (!msg)
+		return -ENOMEM;
+
+	hdr = genlmsg_put(msg, info->snd_portid, info->snd_seq,
+			  &drm_fabric_nl_family, 0,
+			  DRM_FABRIC_CMD_PORT_GET);
+	if (!hdr) {
+		nlmsg_free(msg);
+		return -EMSGSIZE;
+	}
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		port = drm_fabric_port_find(ep_id, port_idx);
+		if (!port) {
+			nlmsg_free(msg);
+			return -ENOENT;
+		}
+
+		ret = drm_fabric_fill_port(msg, port);
+		if (ret) {
+			nlmsg_free(msg);
+			return ret;
+		}
+
+		if (nla_put_u32(msg, DRM_FABRIC_A_TOPOLOGY_GENERATION,
+				drm_fabric_base_seq)) {
+			nlmsg_free(msg);
+			return -EMSGSIZE;
+		}
+	}
+
+	genlmsg_end(msg, hdr);
+	return genlmsg_reply(msg, info);
+}
+
+static int
+drm_fabric_dump_endpoint_ports(struct sk_buff *skb,
+			       struct netlink_callback *cb,
+			       struct drm_fabric_endpoint *ep, u8 cmd,
+			       int (*fill)(struct sk_buff *,
+					   struct drm_fabric_port *),
+			       unsigned long *s_port_idx)
+{
+	struct drm_fabric_port *port;
+	unsigned long pidx;
+	void *hdr;
+	int ret = 0;
+
+	lockdep_assert_held(&drm_fabric_lock);
+
+	xa_for_each_start(&ep->ports, pidx, port, *s_port_idx) {
+		hdr = genlmsg_put(skb, NETLINK_CB(cb->skb).portid,
+				  cb->nlh->nlmsg_seq, &drm_fabric_nl_family,
+				  NLM_F_MULTI, cmd);
+		if (!hdr) {
+			ret = -EMSGSIZE;
+			break;
+		}
+		genl_dump_check_consistent(cb, hdr);
+
+		/* PORT_STATS_GET replies carry no topology-generation. */
+		if (cmd == DRM_FABRIC_CMD_PORT_GET &&
+		    nla_put_u32(skb, DRM_FABRIC_A_TOPOLOGY_GENERATION,
+				drm_fabric_base_seq)) {
+			genlmsg_cancel(skb, hdr);
+			ret = -EMSGSIZE;
+			break;
+		}
+
+		ret = fill(skb, port);
+		if (ret) {
+			genlmsg_cancel(skb, hdr);
+			break;
+		}
+
+		genlmsg_end(skb, hdr);
+	}
+
+	/* Only a partially emitted endpoint keeps its port cursor. */
+	*s_port_idx = (ret == -EMSGSIZE) ? pidx : 0;
+	return ret;
+}
+
+static int drm_fabric_port_dump(struct sk_buff *skb,
+				struct netlink_callback *cb,
+				int cmd,
+				int (*fill)(struct sk_buff *, struct drm_fabric_port *))
+{
+	const struct genl_info *info = genl_info_dump(cb);
+	struct drm_fabric_port_dump_ctx *ctx = drm_fabric_port_dump_context(cb);
+	u32 filter_ep_id = 0;
+	bool has_ep_filter = false;
+	struct drm_fabric_endpoint *ep;
+	unsigned long eidx;
+	int ret;
+
+	ret = drm_fabric_nl_host_only(genl_info_net(info));
+	if (ret)
+		return ret;
+
+	if (info->attrs[DRM_FABRIC_A_ENDPOINT_ID]) {
+		filter_ep_id = nla_get_u32(info->attrs[DRM_FABRIC_A_ENDPOINT_ID]);
+		has_ep_filter = true;
+	}
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		bool first = true;
+
+		cb->seq = drm_fabric_base_seq;
+		xa_for_each_start(&drm_fabric_ep_xa, eidx, ep, ctx->ep_idx) {
+			/*
+			 * port_idx belongs to ep_idx; honor it only if
+			 * that endpoint still resolves.
+			 */
+			if (first) {
+				if (eidx != ctx->ep_idx)
+					ctx->port_idx = 0;
+				first = false;
+			}
+
+			if (has_ep_filter && ep->id != filter_ep_id) {
+				ctx->port_idx = 0;
+				continue;
+			}
+
+			ret = drm_fabric_dump_endpoint_ports(skb, cb, ep,
+							     cmd,
+							     fill,
+							     &ctx->port_idx);
+			if (ret)
+				break;
+		}
+	}
+
+	if (ret == -EMSGSIZE) {
+		ctx->ep_idx = eidx;
+		return skb->len;
+	}
+	return ret;
+}
+
+int drm_fabric_nl_port_get_dumpit(struct sk_buff *skb,
+				  struct netlink_callback *cb)
+{
+	return drm_fabric_port_dump(skb, cb, DRM_FABRIC_CMD_PORT_GET,
+				    drm_fabric_fill_port);
+}
+
+/* Advance the cursor to the next port at or after it, or return NULL. */
+static struct drm_fabric_port *
+drm_fabric_stats_dump_next(unsigned long *ep_idx, unsigned long *port_idx,
+			   bool has_ep_filter, u32 filter_ep_id)
+{
+	struct drm_fabric_endpoint *ep;
+	struct drm_fabric_port *port;
+	unsigned long eidx;
+	bool first = true;
+
+	lockdep_assert_held(&drm_fabric_lock);
+
+	xa_for_each_start(&drm_fabric_ep_xa, eidx, ep, *ep_idx) {
+		if (first) {
+			if (eidx != *ep_idx)
+				*port_idx = 0;
+			first = false;
+		}
+
+		if (has_ep_filter && ep->id != filter_ep_id) {
+			*port_idx = 0;
+			continue;
+		}
+
+		port = xa_find(&ep->ports, port_idx, ULONG_MAX, XA_PRESENT);
+		if (port) {
+			*ep_idx = eidx;
+			return port;
+		}
+
+		*port_idx = 0;
+	}
+
+	/* Mark the endpoint cursor exhausted. */
+	*ep_idx = ULONG_MAX;
+	return NULL;
+}
+
+/*
+ * Port indices are provider-chosen u32s, so port_index may be U32_MAX;
+ * adding one there would wrap to 0 and re-dump the endpoint forever.
+ */
+static void
+drm_fabric_stats_cursor_advance(unsigned long *ep_idx, unsigned long *port_idx,
+				u32 port_index)
+{
+	if (port_index == U32_MAX) {
+		(*ep_idx)++;
+		*port_idx = 0;
+	} else {
+		*port_idx = port_index + 1;
+	}
+}
+
+/* Advance past an unsupported port so a resumed dump cannot retry it. */
+static void
+drm_fabric_stats_dump_skip(struct sk_buff *skb, void *hdr,
+			   struct drm_fabric_port_dump_ctx *ctx,
+			   struct drm_fabric_port *port,
+			   u32 port_index)
+{
+	genlmsg_cancel(skb, hdr);
+	drm_fabric_port_put(port);
+	drm_fabric_stats_cursor_advance(&ctx->ep_idx, &ctx->port_idx,
+					port_index);
+}
+
+int drm_fabric_nl_port_stats_get_doit(struct sk_buff *skb,
+				      struct genl_info *info)
+{
+	const struct drm_fabric_ops *ops;
+	struct drm_fabric_port_stats stats = {};
+	struct drm_fabric_port *port;
+	struct sk_buff *msg;
+	u32 ep_id, port_idx;
+	void *hdr;
+	int ret;
+
+	ret = drm_fabric_nl_host_only(genl_info_net(info));
+	if (ret)
+		return ret;
+
+	ret = drm_fabric_port_key(info, &ep_id, &port_idx);
+	if (ret)
+		return ret;
+
+	/*
+	 * Pins the port through its endpoint across the unlocked provider
+	 * callback; endpoint_unregister() waits on this pin, so it cannot be
+	 * freed underneath us.
+	 */
+	port = drm_fabric_port_find_get(ep_id, port_idx);
+	if (IS_ERR(port))
+		return PTR_ERR(port);
+
+	ops = port->endpoint->ops;
+	if (!ops || !ops->port_stats_get) {
+		ret = -EOPNOTSUPP;
+		goto out_put;
+	}
+
+	lockdep_assert_not_held(&drm_fabric_lock);
+	ret = ops->port_stats_get(port, &stats);
+	if (ret)
+		goto out_put;
+
+	msg = nlmsg_new(NLMSG_DEFAULT_SIZE, GFP_KERNEL);
+	if (!msg) {
+		ret = -ENOMEM;
+		goto out_put;
+	}
+
+	hdr = genlmsg_put(msg, info->snd_portid, info->snd_seq,
+			  &drm_fabric_nl_family, 0,
+			  DRM_FABRIC_CMD_PORT_STATS_GET);
+	if (!hdr) {
+		ret = -EMSGSIZE;
+		goto out_free;
+	}
+
+	ret = drm_fabric_fill_port_stats(msg, port, &stats);
+	if (ret)
+		goto out_free;
+
+	genlmsg_end(msg, hdr);
+	drm_fabric_port_put(port);
+	return genlmsg_reply(msg, info);
+
+out_free:
+	nlmsg_free(msg);
+out_put:
+	drm_fabric_port_put(port);
+	return ret;
+}
+
+/*
+ * Statistics callbacks may sleep. Pin the endpoint, drop the lock, then
+ * resample cb->seq so a mutation in that window sets NLM_F_DUMP_INTR.
+ */
+int drm_fabric_nl_port_stats_get_dumpit(struct sk_buff *skb,
+					struct netlink_callback *cb)
+{
+	const struct genl_info *info = genl_info_dump(cb);
+	struct drm_fabric_port_dump_ctx *ctx = drm_fabric_port_dump_context(cb);
+	u32 filter_ep_id = 0;
+	bool has_ep_filter = false;
+	int ret;
+
+	ret = drm_fabric_nl_host_only(genl_info_net(info));
+	if (ret)
+		return ret;
+
+	if (info->attrs[DRM_FABRIC_A_ENDPOINT_ID]) {
+		filter_ep_id = nla_get_u32(info->attrs[DRM_FABRIC_A_ENDPOINT_ID]);
+		has_ep_filter = true;
+	}
+
+	for (;;) {
+		const struct drm_fabric_ops *ops;
+		struct drm_fabric_port_stats stats = {};
+		struct drm_fabric_endpoint *ep;
+		struct drm_fabric_port *port;
+		u32 port_index;
+		void *hdr;
+
+		scoped_guard(mutex, &drm_fabric_lock) {
+			/*
+			 * Sampled before the cursor check so even a batch
+			 * that emits nothing can report a late mutation.
+			 */
+			cb->seq = drm_fabric_base_seq;
+
+			port = drm_fabric_stats_dump_next(&ctx->ep_idx,
+							  &ctx->port_idx,
+							  has_ep_filter,
+							  filter_ep_id);
+			/*
+			 * Exhausted: 0 terminates the dump with NLMSG_DONE
+			 * instead of re-running the parked cursor.
+			 */
+			if (!port)
+				return 0;
+
+			ep = port->endpoint;
+			ops = ep->ops;
+			port_index = port->index;
+			drm_fabric_endpoint_get(ep);
+		}
+
+		/* Reserve the reply before invoking the sleeping provider callback. */
+		hdr = genlmsg_put(skb, NETLINK_CB(cb->skb).portid,
+				  cb->nlh->nlmsg_seq, &drm_fabric_nl_family,
+				  NLM_F_MULTI, DRM_FABRIC_CMD_PORT_STATS_GET);
+		if (!hdr) {
+			drm_fabric_port_put(port);
+			return skb->len;
+		}
+
+		if (!ops || !ops->port_stats_get) {
+			drm_fabric_stats_dump_skip(skb, hdr, ctx, port, port_index);
+			continue;
+		}
+
+		lockdep_assert_not_held(&drm_fabric_lock);
+		ret = ops->port_stats_get(port, &stats);
+		if (ret == -EOPNOTSUPP) {
+			drm_fabric_stats_dump_skip(skb, hdr, ctx, port, port_index);
+			continue;
+		}
+		if (ret) {
+			genlmsg_cancel(skb, hdr);
+			drm_fabric_port_put(port);
+			break;
+		}
+
+		genl_dump_check_consistent(cb, hdr);
+
+		if (drm_fabric_fill_port_stats(skb, port, &stats)) {
+			genlmsg_cancel(skb, hdr);
+			drm_fabric_port_put(port);
+			return skb->len;
+		}
+
+		genlmsg_end(skb, hdr);
+		drm_fabric_port_put(port);
+
+		drm_fabric_stats_cursor_advance(&ctx->ep_idx, &ctx->port_idx,
+						port_index);
+	}
+
+	return ret;
+}
+
+void drm_fabric_emit_port_change(struct drm_fabric_port *port, u32 generation)
+{
+	struct sk_buff *msg;
+	void *hdr;
+
+	lockdep_assert_held(&drm_fabric_lock);
+
+	msg = nlmsg_new(NLMSG_DEFAULT_SIZE, GFP_KERNEL);
+	if (!msg)
+		return;
+
+	hdr = genlmsg_put(msg, 0, 0, &drm_fabric_nl_family, 0, DRM_FABRIC_CMD_PORT_CHANGE_NTF);
+	if (!hdr) {
+		nlmsg_free(msg);
+		return;
+	}
+
+	if (nla_put_u32(msg, DRM_FABRIC_A_TOPOLOGY_GENERATION, generation) ||
+	    drm_fabric_fill_port(msg, port)) {
+		nlmsg_free(msg);
+		return;
+	}
+
+	genlmsg_end(msg, hdr);
+	genlmsg_multicast(&drm_fabric_nl_family, msg, 0,
+			  DRM_FABRIC_NLGRP_MONITOR, GFP_KERNEL);
+}
+
+static void drm_fabric_port_peer_event_send(enum drm_fabric_cmd cmd,
+					    struct drm_fabric_port *port,
+					    const struct drm_fabric_peer *peer,
+					    u32 generation)
+{
+	struct sk_buff *msg;
+	void *hdr;
+	struct nlattr *nest;
+
+	lockdep_assert_held(&drm_fabric_lock);
+
+	msg = nlmsg_new(NLMSG_DEFAULT_SIZE, GFP_KERNEL);
+	if (!msg)
+		return;
+
+	hdr = genlmsg_put(msg, 0, 0, &drm_fabric_nl_family, 0, cmd);
+	if (!hdr) {
+		nlmsg_free(msg);
+		return;
+	}
+
+	if (nla_put_u32(msg, DRM_FABRIC_A_TOPOLOGY_GENERATION, generation) ||
+	    nla_put_u32(msg, DRM_FABRIC_A_ENDPOINT_ID, port->endpoint->id) ||
+	    nla_put_u32(msg, DRM_FABRIC_A_PORT_INDEX, port->index)) {
+		nlmsg_free(msg);
+		return;
+	}
+
+	nest = nla_nest_start(msg, DRM_FABRIC_A_PEER);
+	if (!nest) {
+		nlmsg_free(msg);
+		return;
+	}
+
+	if (nla_put_u64_64bit(msg, DRM_FABRIC_A_PEER_ATTRS_PEER_ID,
+			      peer->peer_id, DRM_FABRIC_A_PEER_ATTRS_PAD) ||
+	    nla_put_u32(msg, DRM_FABRIC_A_PEER_ATTRS_TYPE, peer->peer_type) ||
+	    nla_put_u32(msg, DRM_FABRIC_A_PEER_ATTRS_PORT_INDEX, peer->port_index)) {
+		nla_nest_cancel(msg, nest);
+		nlmsg_free(msg);
+		return;
+	}
+
+	nla_nest_end(msg, nest);
+
+	genlmsg_end(msg, hdr);
+	genlmsg_multicast(&drm_fabric_nl_family, msg, 0,
+			  DRM_FABRIC_NLGRP_MONITOR, GFP_KERNEL);
+}
+
+void drm_fabric_emit_port_peer_create(struct drm_fabric_port *port,
+				   const struct drm_fabric_peer *peer,
+				   u32 generation)
+{
+	drm_fabric_port_peer_event_send(DRM_FABRIC_CMD_PORT_PEER_CREATE_NTF,
+					port, peer, generation);
+}
+
+void drm_fabric_emit_port_peer_delete(struct drm_fabric_port *port,
+				   const struct drm_fabric_peer *peer,
+				   u32 generation)
+{
+	drm_fabric_port_peer_event_send(DRM_FABRIC_CMD_PORT_PEER_DELETE_NTF,
+					port, peer, generation);
+}
+
+static void drm_fabric_endpoint_event_send(enum drm_fabric_cmd cmd,
+					   struct drm_fabric_endpoint *ep,
+					   u32 generation)
+{
+	struct sk_buff *msg;
+	void *hdr;
+
+	lockdep_assert_held(&drm_fabric_lock);
+
+	msg = nlmsg_new(NLMSG_DEFAULT_SIZE, GFP_KERNEL);
+	if (!msg)
+		return;
+
+	hdr = genlmsg_put(msg, 0, 0, &drm_fabric_nl_family, 0, cmd);
+	if (!hdr) {
+		nlmsg_free(msg);
+		return;
+	}
+
+	if (nla_put_u32(msg, DRM_FABRIC_A_TOPOLOGY_GENERATION, generation) ||
+	    drm_fabric_fill_endpoint(msg, ep)) {
+		nlmsg_free(msg);
+		return;
+	}
+
+	genlmsg_end(msg, hdr);
+	genlmsg_multicast(&drm_fabric_nl_family, msg, 0,
+			  DRM_FABRIC_NLGRP_MONITOR, GFP_KERNEL);
+}
+
+void drm_fabric_emit_endpoint_create(struct drm_fabric_endpoint *ep, u32 generation)
+{
+	drm_fabric_endpoint_event_send(DRM_FABRIC_CMD_ENDPOINT_CREATE_NTF, ep,
+				       generation);
+}
+
+void drm_fabric_emit_endpoint_delete(struct drm_fabric_endpoint *ep, u32 generation)
+{
+	drm_fabric_endpoint_event_send(DRM_FABRIC_CMD_ENDPOINT_DELETE_NTF, ep,
+				       generation);
+}
+
+static void drm_fabric_fabric_event_send(enum drm_fabric_cmd cmd,
+					 struct drm_fabric *fabric,
+					 u32 generation)
+{
+	struct sk_buff *msg;
+	void *hdr;
+
+	lockdep_assert_held(&drm_fabric_lock);
+
+	msg = nlmsg_new(NLMSG_DEFAULT_SIZE, GFP_KERNEL);
+	if (!msg)
+		return;
+
+	hdr = genlmsg_put(msg, 0, 0, &drm_fabric_nl_family, 0, cmd);
+	if (!hdr) {
+		nlmsg_free(msg);
+		return;
+	}
+
+	if (nla_put_u32(msg, DRM_FABRIC_A_TOPOLOGY_GENERATION, generation) ||
+	    drm_fabric_fill_fabric(msg, fabric)) {
+		nlmsg_free(msg);
+		return;
+	}
+
+	genlmsg_end(msg, hdr);
+	genlmsg_multicast(&drm_fabric_nl_family, msg, 0,
+			  DRM_FABRIC_NLGRP_MONITOR, GFP_KERNEL);
+}
+
+void drm_fabric_emit_fabric_create(struct drm_fabric *fabric, u32 generation)
+{
+	drm_fabric_fabric_event_send(DRM_FABRIC_CMD_FABRIC_CREATE_NTF, fabric,
+				     generation);
+}
+
+void drm_fabric_emit_fabric_delete(struct drm_fabric *fabric, u32 generation)
+{
+	drm_fabric_fabric_event_send(DRM_FABRIC_CMD_FABRIC_DELETE_NTF, fabric,
+				     generation);
+}
+
+int drm_fabric_netlink_register(void)
+{
+	return genl_register_family(&drm_fabric_nl_family);
+}
+
+void drm_fabric_netlink_unregister(void)
+{
+	genl_unregister_family(&drm_fabric_nl_family);
+}
diff --git a/drivers/gpu/drm/fabric/drm_fabric_nl.c b/drivers/gpu/drm/fabric/drm_fabric_nl.c
new file mode 100644
index 000000000000..032548405146
--- /dev/null
+++ b/drivers/gpu/drm/fabric/drm_fabric_nl.c
@@ -0,0 +1,125 @@
+// SPDX-License-Identifier: ((GPL-2.0 WITH Linux-syscall-note) OR BSD-3-Clause)
+/* Do not edit directly, auto-generated from: */
+/*	Documentation/netlink/specs/drm_fabric.yaml */
+/* YNL-GEN kernel source */
+/* To regenerate run: tools/net/ynl/ynl-regen.sh */
+
+#include <net/netlink.h>
+#include <net/genetlink.h>
+
+#include "drm_fabric_nl.h"
+
+#include <uapi/drm/drm_fabric.h>
+
+/* DRM_FABRIC_CMD_FABRIC_GET - do */
+static const struct nla_policy drm_fabric_fabric_get_nl_policy[DRM_FABRIC_A_FABRIC_ID + 1] = {
+	[DRM_FABRIC_A_FABRIC_ID] = { .type = NLA_U32, },
+};
+
+/* DRM_FABRIC_CMD_ENDPOINT_GET - do */
+static const struct nla_policy drm_fabric_endpoint_get_do_nl_policy[DRM_FABRIC_A_BUS_NAME + 1] = {
+	[DRM_FABRIC_A_ENDPOINT_ID] = { .type = NLA_U32, },
+	[DRM_FABRIC_A_DEV_NAME] = { .type = NLA_NUL_STRING, },
+	[DRM_FABRIC_A_BUS_NAME] = { .type = NLA_NUL_STRING, },
+};
+
+/* DRM_FABRIC_CMD_ENDPOINT_GET - dump */
+static const struct nla_policy drm_fabric_endpoint_get_dump_nl_policy[DRM_FABRIC_A_FABRIC_ID + 1] = {
+	[DRM_FABRIC_A_FABRIC_ID] = { .type = NLA_U32, },
+};
+
+/* DRM_FABRIC_CMD_PORT_GET - do */
+static const struct nla_policy drm_fabric_port_get_do_nl_policy[DRM_FABRIC_A_PORT_INDEX + 1] = {
+	[DRM_FABRIC_A_ENDPOINT_ID] = { .type = NLA_U32, },
+	[DRM_FABRIC_A_PORT_INDEX] = { .type = NLA_U32, },
+};
+
+/* DRM_FABRIC_CMD_PORT_GET - dump */
+static const struct nla_policy drm_fabric_port_get_dump_nl_policy[DRM_FABRIC_A_ENDPOINT_ID + 1] = {
+	[DRM_FABRIC_A_ENDPOINT_ID] = { .type = NLA_U32, },
+};
+
+/* DRM_FABRIC_CMD_PORT_STATS_GET - do */
+static const struct nla_policy drm_fabric_port_stats_get_do_nl_policy[DRM_FABRIC_A_PORT_INDEX + 1] = {
+	[DRM_FABRIC_A_ENDPOINT_ID] = { .type = NLA_U32, },
+	[DRM_FABRIC_A_PORT_INDEX] = { .type = NLA_U32, },
+};
+
+/* DRM_FABRIC_CMD_PORT_STATS_GET - dump */
+static const struct nla_policy drm_fabric_port_stats_get_dump_nl_policy[DRM_FABRIC_A_ENDPOINT_ID + 1] = {
+	[DRM_FABRIC_A_ENDPOINT_ID] = { .type = NLA_U32, },
+};
+
+/* Ops table for drm_fabric */
+static const struct genl_split_ops drm_fabric_nl_ops[] = {
+	{
+		.cmd		= DRM_FABRIC_CMD_FABRIC_GET,
+		.doit		= drm_fabric_nl_fabric_get_doit,
+		.policy		= drm_fabric_fabric_get_nl_policy,
+		.maxattr	= DRM_FABRIC_A_FABRIC_ID,
+		.flags		= GENL_CMD_CAP_DO,
+	},
+	{
+		.cmd	= DRM_FABRIC_CMD_FABRIC_GET,
+		.dumpit	= drm_fabric_nl_fabric_get_dumpit,
+		.flags	= GENL_CMD_CAP_DUMP,
+	},
+	{
+		.cmd		= DRM_FABRIC_CMD_ENDPOINT_GET,
+		.doit		= drm_fabric_nl_endpoint_get_doit,
+		.policy		= drm_fabric_endpoint_get_do_nl_policy,
+		.maxattr	= DRM_FABRIC_A_BUS_NAME,
+		.flags		= GENL_CMD_CAP_DO,
+	},
+	{
+		.cmd		= DRM_FABRIC_CMD_ENDPOINT_GET,
+		.dumpit		= drm_fabric_nl_endpoint_get_dumpit,
+		.policy		= drm_fabric_endpoint_get_dump_nl_policy,
+		.maxattr	= DRM_FABRIC_A_FABRIC_ID,
+		.flags		= GENL_CMD_CAP_DUMP,
+	},
+	{
+		.cmd		= DRM_FABRIC_CMD_PORT_GET,
+		.doit		= drm_fabric_nl_port_get_doit,
+		.policy		= drm_fabric_port_get_do_nl_policy,
+		.maxattr	= DRM_FABRIC_A_PORT_INDEX,
+		.flags		= GENL_CMD_CAP_DO,
+	},
+	{
+		.cmd		= DRM_FABRIC_CMD_PORT_GET,
+		.dumpit		= drm_fabric_nl_port_get_dumpit,
+		.policy		= drm_fabric_port_get_dump_nl_policy,
+		.maxattr	= DRM_FABRIC_A_ENDPOINT_ID,
+		.flags		= GENL_CMD_CAP_DUMP,
+	},
+	{
+		.cmd		= DRM_FABRIC_CMD_PORT_STATS_GET,
+		.doit		= drm_fabric_nl_port_stats_get_doit,
+		.policy		= drm_fabric_port_stats_get_do_nl_policy,
+		.maxattr	= DRM_FABRIC_A_PORT_INDEX,
+		.flags		= GENL_CMD_CAP_DO,
+	},
+	{
+		.cmd		= DRM_FABRIC_CMD_PORT_STATS_GET,
+		.dumpit		= drm_fabric_nl_port_stats_get_dumpit,
+		.policy		= drm_fabric_port_stats_get_dump_nl_policy,
+		.maxattr	= DRM_FABRIC_A_ENDPOINT_ID,
+		.flags		= GENL_CMD_CAP_DUMP,
+	},
+};
+
+static const struct genl_multicast_group drm_fabric_nl_mcgrps[] = {
+	[DRM_FABRIC_NLGRP_MONITOR] = { "monitor", },
+};
+
+struct genl_family drm_fabric_nl_family __ro_after_init = {
+	.name		= DRM_FABRIC_FAMILY_NAME,
+	.version	= DRM_FABRIC_FAMILY_VERSION,
+	.netnsok	= true,
+	.parallel_ops	= true,
+	.module		= THIS_MODULE,
+	.split_ops	= drm_fabric_nl_ops,
+	.n_split_ops	= ARRAY_SIZE(drm_fabric_nl_ops),
+	.mcgrps		= drm_fabric_nl_mcgrps,
+	.n_mcgrps	= ARRAY_SIZE(drm_fabric_nl_mcgrps),
+};
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [RFC PATCH 04/12] drm/fabric: add read-only synthetic provider
  2026-08-24  8:09 [RFC PATCH 0/12] drm/fabric: vendor-neutral topology infrastructure for scale-up accelerator interconnects Konstantin Sinyuk
                   ` (2 preceding siblings ...)
  2026-08-24  8:09 ` [RFC PATCH 03/12] drm/fabric: implement query netlink operations Konstantin Sinyuk
@ 2026-08-24  8:09 ` Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 05/12] drm/fabric: add object-model KUnit tests Konstantin Sinyuk
                   ` (7 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Konstantin Sinyuk @ 2026-08-24  8:09 UTC (permalink / raw)
  To: dri-devel
  Cc: Maarten Lankhorst, Francois Dugast, David Airlie, Simona Vetter,
	Maxime Ripard, Thomas Zimmermann, Jonathan Corbet, Shuah Khan,
	Donald Hunter, Jakub Kicinski, David S. Miller, Eric Dumazet,
	Paolo Abeni, Simon Horman, Ilia Levi, Rodrigo Vivi, linux-doc,
	linux-kselftest, netdev, linux-kernel

Add drm_fabric_sim (fabricsim), a software-only provider modeled on
netdevsim. It registers deterministic linear, mesh and switch-facing
topologies through the same provider API a hardware driver uses, so the
object model and query uAPI can be exercised without accelerator hardware.
The switch shape uses opaque peers that do not resolve to registered
endpoints.

CONFIG_DRM_FABRIC_SIM builds the provider as drm-fabric-sim.ko. Module
parameters select the topology shape and initial endpoint and port counts.

With topology=linear num_endpoints=2, an endpoint is queried using the
in-tree YNL tool:

  $ ./tools/net/ynl/pyynl/cli.py \
        --spec Documentation/netlink/specs/drm_fabric.yaml \
        --do endpoint-get --json '{"endpoint-id": 1}'
  {'endpoint': {'bus-name': 'platform',
                'dev-name': 'fabricsim.1',
                'endpoint-id': 1,
                'fabric-ep-id': 257,
                'fabric-id': 1,
                'name': 'sim-ep1'},
   'topology-generation': 18}

The reply separates the core-assigned endpoint-id from the provider-defined
fabric-ep-id and carries the topology-generation token.

Report maximum link capabilities at registration, operational state through
drm_fabric_port_set_oper() and optional port statistics through the
port_stats_get callback.

Test-only debugfs controls stimulate provider behavior for the selftests.
They are not uAPI; observable results are reported through the drm-fabric
Generic Netlink family.

Signed-off-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Assisted-by: GitHub-Copilot:claude-opus-4.8
---
 Documentation/gpu/drm-fabric.rst        |  46 +-
 drivers/gpu/drm/fabric/Kconfig          |  10 +
 drivers/gpu/drm/fabric/Makefile         |   3 +
 drivers/gpu/drm/fabric/drm_fabric_sim.c | 978 ++++++++++++++++++++++++
 4 files changed, 1034 insertions(+), 3 deletions(-)
 create mode 100644 drivers/gpu/drm/fabric/drm_fabric_sim.c

diff --git a/Documentation/gpu/drm-fabric.rst b/Documentation/gpu/drm-fabric.rst
index a8b33d6cd618..8bd3633d41be 100644
--- a/Documentation/gpu/drm-fabric.rst
+++ b/Documentation/gpu/drm-fabric.rst
@@ -291,10 +291,16 @@ Query the topology with the in-tree YNL tool, pointing it at the spec:
         --spec Documentation/netlink/specs/drm_fabric.yaml \
         --dump fabric-get
 
-Replies follow the shapes described above; a provider must be registered for
-the topology to be non-empty.
+Against drm_fabric_sim loaded with ``topology=linear num_endpoints=2``
+(both non-default, for a minimal example), this returns:
 
-List the endpoints of a fabric:
+.. code-block:: text
+
+    [{'fabric': {'fabric-id': 1, 'instance-id': 2156317438,
+                 'name': 'fabricsim', 'type': 'synthetic'},
+      'topology-generation': 18}]
+
+List the endpoints of that fabric:
 
 .. code-block:: bash
 
@@ -302,6 +308,15 @@ List the endpoints of a fabric:
         --spec Documentation/netlink/specs/drm_fabric.yaml \
         --dump endpoint-get --json '{"fabric-id": 1}'
 
+.. code-block:: text
+
+    [{'endpoint': {'bus-name': 'platform', 'dev-name': 'fabricsim.0',
+                   'endpoint-id': 0, 'fabric-ep-id': 256, 'fabric-id': 1,
+                   'name': 'sim-ep0'}, 'topology-generation': 18},
+     {'endpoint': {'bus-name': 'platform', 'dev-name': 'fabricsim.1',
+                   'endpoint-id': 1, 'fabric-ep-id': 257, 'fabric-id': 1,
+                   'name': 'sim-ep1'}, 'topology-generation': 18}]
+
 Query a single port:
 
 .. code-block:: bash
@@ -310,4 +325,29 @@ Query a single port:
         --spec Documentation/netlink/specs/drm_fabric.yaml \
         --do port-get --json '{"endpoint-id": 1, "port-index": 0}'
 
+.. code-block:: text
+
+    {'port': {'endpoint-id': 1, 'max-lane-count': 4,
+              'max-lane-signaling-rate-mbps': 200000, 'oper-state': 'active',
+              'peer': {'peer-id': 256, 'port-index': 0, 'type': 'accel'},
+              'port-index': 0},
+     'topology-generation': 18}
+
 The family name on the wire is ``drm-fabric``.
+
+Synthetic provider
+==================
+
+``CONFIG_DRM_FABRIC_SIM`` builds ``drm-fabric-sim.ko``, a software-only provider
+modeled on netdevsim (Documentation/networking/devlink/netdevsim.rst) that drives
+the object model and uAPI without real hardware. Module parameters select a
+linear, mesh or switch-shaped topology and bound the number of endpoints and
+ports. The switch shape links every endpoint to an opaque switch peer
+(``peer-type = switch``) whose id does not resolve to an endpoint, exercising the
+directed half-edge model without a first-class switch object.
+
+Its debugfs knobs stimulate synthetic counter activity, operational-state changes
+and runtime endpoint add/remove. These files are unstable test controls and are
+not part of the uAPI; the stable, reviewed interface is the YAML-described
+Generic Netlink family. Tests mutate simulator state through debugfs and observe
+the result over Generic Netlink.
diff --git a/drivers/gpu/drm/fabric/Kconfig b/drivers/gpu/drm/fabric/Kconfig
index 7e9c569fd258..87115356baca 100644
--- a/drivers/gpu/drm/fabric/Kconfig
+++ b/drivers/gpu/drm/fabric/Kconfig
@@ -12,3 +12,13 @@ config DRM_FABRIC
 	  called drm-fabric.
 
 	  If in doubt, say N.
+
+config DRM_FABRIC_SIM
+	tristate "DRM fabric synthetic provider (test-only)"
+	depends on DRM_FABRIC
+	help
+	  Synthetic provider for testing drm_fabric topology and ABI.
+	  Provides debugfs-only hooks for synthetic activity generation,
+	  error injection, and port-state transitions.  These hooks are
+	  NOT part of the drm_fabric uAPI and are used only by selftests.
+	  Does not model UALink protocol traffic or memory semantics.
diff --git a/drivers/gpu/drm/fabric/Makefile b/drivers/gpu/drm/fabric/Makefile
index cf9d9d6be3d3..bc0a6c742164 100644
--- a/drivers/gpu/drm/fabric/Makefile
+++ b/drivers/gpu/drm/fabric/Makefile
@@ -2,3 +2,6 @@
 
 obj-$(CONFIG_DRM_FABRIC) += drm-fabric.o
 drm-fabric-y := drm_fabric.o drm_fabric_netlink.o drm_fabric_nl.o
+
+obj-$(CONFIG_DRM_FABRIC_SIM) += drm-fabric-sim.o
+drm-fabric-sim-y := drm_fabric_sim.o
diff --git a/drivers/gpu/drm/fabric/drm_fabric_sim.c b/drivers/gpu/drm/fabric/drm_fabric_sim.c
new file mode 100644
index 000000000000..7d489c6894bf
--- /dev/null
+++ b/drivers/gpu/drm/fabric/drm_fabric_sim.c
@@ -0,0 +1,978 @@
+// SPDX-License-Identifier: MIT
+/*
+ * Copyright © 2026 Intel Corporation
+ */
+
+#include <linux/cleanup.h>
+#include <linux/debugfs.h>
+#include <linux/err.h>
+#include <linux/kstrtox.h>
+#include <linux/module.h>
+#include <linux/mutex.h>
+#include <linux/platform_device.h>
+#include <linux/slab.h>
+#include <linux/string.h>
+#include <linux/timer.h>
+
+#include <drm/drm_fabric.h>
+#include <uapi/drm/drm_fabric.h>
+
+/**
+ * DOC: fabricsim
+ *
+ * In-kernel synthetic drm_fabric provider for review and CI. It registers a
+ * linear, mesh or switch topology and drives the selftests through debugfs:
+ * synthetic counters, port-state transitions, endpoint hotplug, a bulk
+ * population for dump-resume testing, and fault injection. The debugfs hooks
+ * are not uAPI; userspace observes the resulting state through the Generic
+ * Netlink ABI.
+ */
+
+static char *topology = "mesh";
+module_param(topology, charp, 0444);
+MODULE_PARM_DESC(topology, "Topology shape: linear, mesh, switch (default: mesh)");
+
+static int num_endpoints = 4;
+module_param(num_endpoints, int, 0444);
+MODULE_PARM_DESC(num_endpoints, "Number of endpoints (2-8, default: 4)");
+
+static int ports_per_ep = 4;
+module_param(ports_per_ep, int, 0444);
+MODULE_PARM_DESC(ports_per_ep, "Ports per endpoint (1-16, default: 4)");
+
+struct fabricsim_port_priv {
+	struct drm_fabric_port *port;
+
+	/* Bumped lockless from the timer and debugfs; untorn on 32-bit. */
+	atomic64_t read_bytes;
+	atomic64_t write_bytes;
+	atomic64_t link_down_count;
+	atomic64_t retrain_count;
+
+	/* Blocks enable while disable drains a pending timer re-arm. */
+	struct mutex activity_lock;
+	bool activity_enabled;
+	struct timer_list activity_timer;
+	u32 read_rate;		/* bytes per timer tick (FABRICSIM_TICK_MS) */
+	u32 write_rate;		/* bytes per timer tick (FABRICSIM_TICK_MS) */
+
+	/*
+	 * Test-only: returns -stats_errno, with values above MAX_ERRNO mapped
+	 * to -EIO.
+	 */
+	u32 stats_errno;
+};
+
+struct fabricsim_ep_priv {
+	struct drm_fabric_endpoint *ep;
+	struct platform_device *pdev;	/* backing device for dev-name/bus-name */
+	struct fabricsim_port_priv *ports;
+	int num_ports;
+	int slot;
+	bool runtime;
+	struct dentry *dbg_dir;
+};
+
+#define FABRICSIM_MAX_EPS 512
+/* Initial generated topology only; runtime hotplug uses FABRICSIM_MAX_EPS. */
+#define FABRICSIM_MAX_INIT_EPS 8
+
+static struct drm_fabric *fabricsim_fabric;
+static struct fabricsim_ep_priv *fabricsim_slots[FABRICSIM_MAX_EPS];
+static int fabricsim_init_eps;
+static bool fabricsim_exiting;		/* gate runtime controls during teardown */
+/*
+ * Nests outside drm_fabric_lock and is never taken from the provider ops or the
+ * debugfs port handlers, so the two cannot invert.
+ */
+static DEFINE_MUTEX(fabricsim_lock);
+static struct dentry *fabricsim_debugfs_root;
+
+/* Test-only fault injection (debugfs). Sticky until cleared. */
+static bool fabricsim_fail_register;
+static u32 fabricsim_fail_errno = ENOMEM;
+
+/*
+ * Negative errno an armed fault returns; zero or an out-of-range value
+ * gives -ENOMEM.
+ */
+static int fabricsim_injected_errno(void)
+{
+	u32 e = fabricsim_fail_errno;
+
+	if (e == 0 || e > MAX_ERRNO)
+		return -ENOMEM;
+	return -(int)e;
+}
+
+static int fabricsim_port_stats_get(struct drm_fabric_port *port,
+				    struct drm_fabric_port_stats *stats)
+{
+	struct fabricsim_ep_priv *ep_priv = port->endpoint->priv;
+	struct fabricsim_port_priv *pp;
+
+	/*
+	 * The endpoint is pinned by the core; missing private state is a
+	 * provider bug.
+	 */
+	if (!ep_priv)
+		return -ENOENT;
+
+	/*
+	 * Port i has index i. Index rather than search for a matching ->port,
+	 * which a dump racing registration would not yet see.
+	 */
+	if (port->index >= (u32)ep_priv->num_ports)
+		return -ENOENT;
+
+	pp = &ep_priv->ports[port->index];
+
+	/* Dumps skip -EOPNOTSUPP; any other injected error aborts the dump. */
+	if (pp->stats_errno) {
+		u32 e = pp->stats_errno;
+
+		return e <= MAX_ERRNO ? -(int)e : -EIO;
+	}
+
+	stats->read_bytes = atomic64_read(&pp->read_bytes);
+	stats->write_bytes = atomic64_read(&pp->write_bytes);
+	stats->link_down_count = atomic64_read(&pp->link_down_count);
+	stats->retrain_count = atomic64_read(&pp->retrain_count);
+
+	return 0;
+}
+
+static const struct drm_fabric_ops fabricsim_ops = {
+	.port_stats_get		= fabricsim_port_stats_get,
+};
+
+#define FABRICSIM_TICK_MS 100
+
+static void fabricsim_activity_tick(struct timer_list *t)
+{
+	struct fabricsim_port_priv *pp =
+		container_of(t, struct fabricsim_port_priv, activity_timer);
+
+	/*
+	 * Paired with WRITE_ONCE() in the enable/disable path, so a stopped
+	 * timer stays stopped.
+	 */
+	if (!READ_ONCE(pp->activity_enabled))
+		return;
+
+	atomic64_add(READ_ONCE(pp->read_rate), &pp->read_bytes);
+	atomic64_add(READ_ONCE(pp->write_rate), &pp->write_bytes);
+
+	mod_timer(&pp->activity_timer,
+		  jiffies + msecs_to_jiffies(FABRICSIM_TICK_MS));
+}
+
+static ssize_t fabricsim_activity_write(struct file *file,
+					const char __user *buf,
+					size_t count, loff_t *ppos)
+{
+	struct fabricsim_port_priv *pp = file->private_data;
+	char kbuf[8];
+	int val;
+
+	if (count >= sizeof(kbuf))
+		return -EINVAL;
+	if (copy_from_user(kbuf, buf, count))
+		return -EFAULT;
+	kbuf[count] = '\0';
+
+	if (kstrtoint(kbuf, 10, &val))
+		return -EINVAL;
+
+	/*
+	 * Held across the whole transition, or a concurrent enable could re-arm
+	 * mid-drain.
+	 */
+	scoped_guard(mutex, &pp->activity_lock) {
+		if (val && !pp->activity_enabled) {
+			WRITE_ONCE(pp->activity_enabled, true);
+			mod_timer(&pp->activity_timer,
+				  jiffies + msecs_to_jiffies(FABRICSIM_TICK_MS));
+		} else if (!val && pp->activity_enabled) {
+			WRITE_ONCE(pp->activity_enabled, false);
+			/*
+			 * Not timer_shutdown_sync(): a later enable re-arms.
+			 * A tick past the enabled check can re-arm after one
+			 * timer_delete_sync(), so loop until a drain is clean.
+			 */
+			while (timer_delete_sync(&pp->activity_timer))
+				;
+		}
+	}
+
+	return count;
+}
+
+static ssize_t fabricsim_activity_read(struct file *file,
+				       char __user *buf,
+				       size_t count, loff_t *ppos)
+{
+	struct fabricsim_port_priv *pp = file->private_data;
+	char kbuf[4];
+	int len;
+
+	len = scnprintf(kbuf, sizeof(kbuf), "%d\n",
+			READ_ONCE(pp->activity_enabled) ? 1 : 0);
+	return simple_read_from_buffer(buf, count, ppos, kbuf, len);
+}
+
+/* Per-port debugfs files use fabricsim_port_priv as file->private_data. */
+static const struct file_operations fabricsim_activity_fops = {
+	.owner	= THIS_MODULE,
+	.open	= simple_open,
+	.read	= fabricsim_activity_read,
+	.write	= fabricsim_activity_write,
+};
+
+static ssize_t fabricsim_inject_write(struct file *file,
+				      const char __user *buf,
+				      size_t count, loff_t *ppos)
+{
+	struct fabricsim_port_priv *pp = file->private_data;
+	char kbuf[32];
+
+	if (count >= sizeof(kbuf))
+		return -EINVAL;
+	if (copy_from_user(kbuf, buf, count))
+		return -EFAULT;
+	kbuf[count] = '\0';
+	if (count > 0 && kbuf[count - 1] == '\n')
+		kbuf[count - 1] = '\0';
+
+	if (strcmp(kbuf, "link_down") == 0) {
+		atomic64_inc(&pp->link_down_count);
+		drm_fabric_port_set_oper(pp->port, DRM_FABRIC_PORT_STATE_INACTIVE);
+	} else if (strcmp(kbuf, "degrade") == 0) {
+		drm_fabric_port_set_oper(pp->port, DRM_FABRIC_PORT_STATE_DEGRADED);
+	} else if (strcmp(kbuf, "recover_to_active") == 0) {
+		atomic64_inc(&pp->retrain_count);
+		drm_fabric_port_set_oper(pp->port, DRM_FABRIC_PORT_STATE_ACTIVE);
+	} else {
+		return -EINVAL;
+	}
+
+	return count;
+}
+
+static const struct file_operations fabricsim_inject_fops = {
+	.owner	= THIS_MODULE,
+	.open	= simple_open,
+	.write	= fabricsim_inject_write,
+};
+
+static ssize_t fabricsim_oper_state_write(struct file *file,
+					  const char __user *buf,
+					  size_t count, loff_t *ppos)
+{
+	struct fabricsim_port_priv *pp = file->private_data;
+	char kbuf[16];
+
+	if (count >= sizeof(kbuf))
+		return -EINVAL;
+	if (copy_from_user(kbuf, buf, count))
+		return -EFAULT;
+	kbuf[count] = '\0';
+	if (count > 0 && kbuf[count - 1] == '\n')
+		kbuf[count - 1] = '\0';
+
+	if (strcmp(kbuf, "unknown") == 0)
+		drm_fabric_port_set_oper(pp->port, DRM_FABRIC_PORT_STATE_UNKNOWN);
+	else if (strcmp(kbuf, "inactive") == 0)
+		drm_fabric_port_set_oper(pp->port, DRM_FABRIC_PORT_STATE_INACTIVE);
+	else if (strcmp(kbuf, "active") == 0)
+		drm_fabric_port_set_oper(pp->port, DRM_FABRIC_PORT_STATE_ACTIVE);
+	else if (strcmp(kbuf, "degraded") == 0)
+		drm_fabric_port_set_oper(pp->port, DRM_FABRIC_PORT_STATE_DEGRADED);
+	else
+		return -EINVAL;
+
+	return count;
+}
+
+static ssize_t fabricsim_oper_state_read(struct file *file,
+					 char __user *buf,
+					 size_t count, loff_t *ppos)
+{
+	struct fabricsim_port_priv *pp = file->private_data;
+	const char *state_str;
+	char kbuf[16];
+	int len;
+
+	switch (pp->port->oper_state) {
+	case DRM_FABRIC_PORT_STATE_INACTIVE:
+		state_str = "inactive";
+		break;
+	case DRM_FABRIC_PORT_STATE_ACTIVE:
+		state_str = "active";
+		break;
+	case DRM_FABRIC_PORT_STATE_DEGRADED:
+		state_str = "degraded";
+		break;
+	default:
+		state_str = "unknown";
+		break;
+	}
+
+	len = scnprintf(kbuf, sizeof(kbuf), "%s\n", state_str);
+	return simple_read_from_buffer(buf, count, ppos, kbuf, len);
+}
+
+static const struct file_operations fabricsim_oper_state_fops = {
+	.owner	= THIS_MODULE,
+	.open	= simple_open,
+	.read	= fabricsim_oper_state_read,
+	.write	= fabricsim_oper_state_write,
+};
+
+static void fabricsim_link_linear(void)
+{
+	int i;
+	int port_cursor[FABRICSIM_MAX_INIT_EPS] = {0};
+	struct drm_fabric_peer peer;
+
+	/* Linear chain: ep[0]<->ep[1]<->ep[2]<->...<->ep[N-1] */
+	for (i = 0; i < fabricsim_init_eps - 1; i++) {
+		int pa_idx = port_cursor[i]++;
+		int pb_idx = port_cursor[i + 1]++;
+		struct drm_fabric_endpoint *ep_a = fabricsim_slots[i]->ep;
+		struct drm_fabric_endpoint *ep_b = fabricsim_slots[i + 1]->ep;
+		struct drm_fabric_port *pa, *pb;
+
+		/*
+		 * Interior nodes consume two ports; stop rather than walk off
+		 * an endpoint's port array if it was sized too small.
+		 */
+		if (pa_idx >= fabricsim_slots[i]->num_ports ||
+		    pb_idx >= fabricsim_slots[i + 1]->num_ports)
+			break;
+
+		pa = fabricsim_slots[i]->ports[pa_idx].port;
+		pb = fabricsim_slots[i + 1]->ports[pb_idx].port;
+
+		/* Peers are directed half-edges, so install both directions. */
+		peer.peer_id = ep_b->fabric_ep_id;
+		peer.peer_type = DRM_FABRIC_PEER_TYPE_ACCEL;
+		peer.port_index = pb->index;
+		drm_fabric_port_set_peer(pa, &peer);
+
+		peer.peer_id = ep_a->fabric_ep_id;
+		peer.peer_type = DRM_FABRIC_PEER_TYPE_ACCEL;
+		peer.port_index = pa->index;
+		drm_fabric_port_set_peer(pb, &peer);
+	}
+}
+
+static void fabricsim_link_mesh(void)
+{
+	int i, j, k, port_idx, peer_port_idx;
+	struct drm_fabric_peer peer;
+
+	/* Fully-connected K_N: port j on ep[i] reaches ep[j], shifted past i. */
+	for (i = 0; i < fabricsim_init_eps; i++) {
+		port_idx = 0;
+		for (j = 0; j < fabricsim_init_eps; j++) {
+			if (i == j)
+				continue;
+
+			if (port_idx >= fabricsim_slots[i]->num_ports)
+				break;
+
+			/*
+			 * On endpoint j, i uses the slot obtained by skipping j
+			 * in endpoint order.
+			 */
+			peer_port_idx = 0;
+			for (k = 0; k < fabricsim_init_eps; k++) {
+				if (k == j)
+					continue;
+				if (k == i)
+					break;
+				peer_port_idx++;
+			}
+
+			peer.peer_id = fabricsim_slots[j]->ep->fabric_ep_id;
+			peer.peer_type = DRM_FABRIC_PEER_TYPE_ACCEL;
+			peer.port_index = peer_port_idx;
+			drm_fabric_port_set_peer(fabricsim_slots[i]->ports[port_idx].port,
+						 &peer);
+
+			port_idx++;
+		}
+	}
+}
+
+/*
+ * Opaque switch peer-id, deliberately outside the leaf range (0x100 + slot)
+ * so it never resolves in the endpoint registry.
+ */
+#define FABRICSIM_SWITCH_FABRIC_EP_ID 0x5000ULL
+
+static void fabricsim_link_switch(void)
+{
+	struct drm_fabric_peer peer;
+	int i;
+
+	for (i = 0; i < fabricsim_init_eps; i++) {
+		struct drm_fabric_port *leaf_port =
+			fabricsim_slots[i]->ports[0].port;
+
+		if (!leaf_port)
+			continue;
+
+		/* One directed half-edge from the leaf to an opaque switch. */
+		peer.peer_id = FABRICSIM_SWITCH_FABRIC_EP_ID;
+		peer.peer_type = DRM_FABRIC_PEER_TYPE_SWITCH;
+		peer.port_index = i; /* distinct switch-side port per leaf */
+		drm_fabric_port_set_peer(leaf_port, &peer);
+	}
+}
+
+static void fabricsim_ep_debugfs_create(struct fabricsim_ep_priv *ep_priv)
+{
+	struct dentry *port_dir;
+	char name[32];
+	int j;
+
+	if (IS_ERR_OR_NULL(fabricsim_debugfs_root))
+		return;
+
+	snprintf(name, sizeof(name), "ep%d", ep_priv->slot);
+	ep_priv->dbg_dir = debugfs_create_dir(name, fabricsim_debugfs_root);
+	if (IS_ERR_OR_NULL(ep_priv->dbg_dir)) {
+		ep_priv->dbg_dir = NULL;
+		return;
+	}
+
+	for (j = 0; j < ep_priv->num_ports; j++) {
+		struct fabricsim_port_priv *pp = &ep_priv->ports[j];
+
+		snprintf(name, sizeof(name), "port%d", j);
+		port_dir = debugfs_create_dir(name, ep_priv->dbg_dir);
+
+		debugfs_create_file("activity_enable", 0644,
+				    port_dir, pp, &fabricsim_activity_fops);
+		debugfs_create_file("inject", 0200,
+				    port_dir, pp, &fabricsim_inject_fops);
+		debugfs_create_file("oper_state", 0644,
+				    port_dir, pp, &fabricsim_oper_state_fops);
+		debugfs_create_u32("read_rate", 0644, port_dir, &pp->read_rate);
+		debugfs_create_u32("write_rate", 0644, port_dir, &pp->write_rate);
+		debugfs_create_u32("stats_errno", 0644, port_dir, &pp->stats_errno);
+	}
+}
+
+/*
+ * Create one endpoint at @slot with @nports ports, registered as a member of
+ * the synthetic fabric.  Returns the new ep_priv or an ERR_PTR.  Caller holds
+ * fabricsim_lock.
+ */
+static struct fabricsim_ep_priv *fabricsim_make_ep(int slot, int nports)
+{
+	struct drm_fabric_endpoint_desc edesc = {};
+	struct drm_fabric_port_desc pdescs[16];
+	struct fabricsim_ep_priv *ep_priv;
+	struct platform_device *pdev;
+	char ep_name[32];
+	int j, ret;
+
+	lockdep_assert_held(&fabricsim_lock);
+
+	if (nports < 1)
+		nports = 1;
+	if (nports > 16)
+		nports = 16;
+
+	/* Refuse before any allocation, so there is nothing to roll back. */
+	if (fabricsim_fail_register)
+		return ERR_PTR(fabricsim_injected_errno());
+
+	ep_priv = kzalloc_obj(*ep_priv, GFP_KERNEL);
+	if (!ep_priv)
+		return ERR_PTR(-ENOMEM);
+
+	ep_priv->slot = slot;
+	ep_priv->num_ports = nports;
+
+	pdev = platform_device_register_simple("fabricsim", slot, NULL, 0);
+	if (IS_ERR(pdev)) {
+		ret = PTR_ERR(pdev);
+		goto err_free;
+	}
+	ep_priv->pdev = pdev;
+
+	for (j = 0; j < nports; j++) {
+		pdescs[j].index = j;
+		pdescs[j].max_lane_count = 4;
+		pdescs[j].max_lane_signaling_rate_mbps = 200000; /* 200 Gbps/lane */
+	}
+
+	snprintf(ep_name, sizeof(ep_name), "sim-ep%d", slot);
+	edesc.fabric_ep_id = 0x100 + slot;
+	edesc.name = ep_name;
+	edesc.parent = &pdev->dev;
+	edesc.ops = &fabricsim_ops;
+	edesc.priv = ep_priv;
+	edesc.ports = pdescs;
+	edesc.num_ports = nports;
+
+	/*
+	 * Fill the port array before registering: a racing PORT_STATS_GET can
+	 * hit any port once published.
+	 */
+	ep_priv->ports = kcalloc(nports, sizeof(struct fabricsim_port_priv),
+				 GFP_KERNEL);
+	if (!ep_priv->ports) {
+		ret = -ENOMEM;
+		goto err_pdev;
+	}
+
+	for (j = 0; j < nports; j++) {
+		struct fabricsim_port_priv *pp = &ep_priv->ports[j];
+
+		pp->read_rate = 1024;
+		pp->write_rate = 512;
+		mutex_init(&pp->activity_lock);
+		timer_setup(&pp->activity_timer, fabricsim_activity_tick, 0);
+	}
+
+	ep_priv->ep = drm_fabric_endpoint_register(fabricsim_fabric, &edesc);
+	if (IS_ERR(ep_priv->ep)) {
+		ret = PTR_ERR(ep_priv->ep);
+		goto err_ports;
+	}
+
+	/*
+	 * Fill the simulator port pointers before topology wiring and before
+	 * the debugfs nodes make them externally reachable.
+	 */
+	for (j = 0; j < nports; j++)
+		ep_priv->ports[j].port = drm_fabric_endpoint_port(ep_priv->ep, j);
+
+	fabricsim_ep_debugfs_create(ep_priv);
+
+	return ep_priv;
+
+err_ports:
+	for (j = 0; j < nports; j++)
+		mutex_destroy(&ep_priv->ports[j].activity_lock);
+	kfree(ep_priv->ports);
+err_pdev:
+	platform_device_unregister(ep_priv->pdev);
+err_free:
+	kfree(ep_priv);
+	return ERR_PTR(ret);
+}
+
+/* Peers on other endpoints are left untouched. Caller holds fabricsim_lock. */
+static void fabricsim_destroy_ep(struct fabricsim_ep_priv *ep_priv)
+{
+	int j;
+
+	lockdep_assert_held(&fabricsim_lock);
+
+	debugfs_remove_recursive(ep_priv->dbg_dir);
+
+	for (j = 0; j < ep_priv->num_ports; j++) {
+		struct fabricsim_port_priv *pp = &ep_priv->ports[j];
+
+		/*
+		 * debugfs_remove_recursive() drained writers before the
+		 * activity timer is shut down.
+		 */
+		scoped_guard(mutex, &pp->activity_lock) {
+			WRITE_ONCE(pp->activity_enabled, false);
+			timer_shutdown_sync(&pp->activity_timer);
+		}
+		mutex_destroy(&pp->activity_lock);
+	}
+
+	drm_fabric_endpoint_unregister(ep_priv->ep);
+	kfree(ep_priv->ports);
+	platform_device_unregister(ep_priv->pdev);
+	kfree(ep_priv);
+}
+
+static int fabricsim_add_endpoint(int nports)
+{
+	struct fabricsim_ep_priv *ep_priv;
+	int slot, ret;
+
+	mutex_lock(&fabricsim_lock);
+	if (fabricsim_exiting) {
+		mutex_unlock(&fabricsim_lock);
+		return -ENODEV;
+	}
+
+	for (slot = 0; slot < FABRICSIM_MAX_EPS; slot++)
+		if (!fabricsim_slots[slot])
+			break;
+	if (slot == FABRICSIM_MAX_EPS) {
+		mutex_unlock(&fabricsim_lock);
+		return -ENOSPC;
+	}
+
+	ep_priv = fabricsim_make_ep(slot, nports);
+	if (IS_ERR(ep_priv)) {
+		ret = PTR_ERR(ep_priv);
+		mutex_unlock(&fabricsim_lock);
+		return ret;
+	}
+	ep_priv->runtime = true;
+	fabricsim_slots[slot] = ep_priv;
+	mutex_unlock(&fabricsim_lock);
+
+	return slot;
+}
+
+/* fabricsim_lock stays held across teardown, so the slot remains reserved. */
+static int fabricsim_del_endpoint(int slot)
+{
+	struct fabricsim_ep_priv *ep_priv;
+
+	if (slot < 0 || slot >= FABRICSIM_MAX_EPS)
+		return -EINVAL;
+
+	mutex_lock(&fabricsim_lock);
+	if (fabricsim_exiting) {
+		mutex_unlock(&fabricsim_lock);
+		return -ENODEV;
+	}
+	ep_priv = fabricsim_slots[slot];
+	if (!ep_priv) {
+		mutex_unlock(&fabricsim_lock);
+		return -ENOENT;
+	}
+	fabricsim_destroy_ep(ep_priv);
+	fabricsim_slots[slot] = NULL;
+	mutex_unlock(&fabricsim_lock);
+
+	return 0;
+}
+
+/*
+ * @n single-port member endpoints for the dump-scale selftest: population, not
+ * topology. Returns the count added, or a negative errno only if none were.
+ */
+static int fabricsim_bulk_add(int n)
+{
+	int added = 0;
+	int ret;
+
+	if (n <= 0)
+		return -EINVAL;
+
+	while (added < n) {
+		ret = fabricsim_add_endpoint(1);
+		if (ret < 0)
+			return added ? added : ret;
+		added++;
+	}
+	return added;
+}
+
+/*
+ * Ownership comes from ->runtime, not the slot index: slots are reused, so a
+ * runtime endpoint can sit below the init population.
+ */
+static int fabricsim_bulk_del(void)
+{
+	int slot;
+
+	for (slot = 0; slot < FABRICSIM_MAX_EPS; slot++) {
+		mutex_lock(&fabricsim_lock);
+		if (fabricsim_exiting) {
+			mutex_unlock(&fabricsim_lock);
+			return -ENODEV;
+		}
+		if (fabricsim_slots[slot] && fabricsim_slots[slot]->runtime) {
+			fabricsim_destroy_ep(fabricsim_slots[slot]);
+			fabricsim_slots[slot] = NULL;
+		}
+		mutex_unlock(&fabricsim_lock);
+	}
+	return 0;
+}
+
+static int fabricsim_parse_int(const char __user *buf, size_t count, int dflt)
+{
+	char kbuf[16];
+	int val;
+
+	if (count == 0 || count >= sizeof(kbuf))
+		return dflt;
+	if (copy_from_user(kbuf, buf, count))
+		return dflt;
+	kbuf[count] = '\0';
+	if (kstrtoint(strim(kbuf), 10, &val))
+		return dflt;
+	return val;
+}
+
+static ssize_t fabricsim_add_ep_write(struct file *file, const char __user *buf,
+				      size_t count, loff_t *ppos)
+{
+	int nports = fabricsim_parse_int(buf, count, ports_per_ep);
+	int ret = fabricsim_add_endpoint(nports);
+
+	return ret < 0 ? ret : count;
+}
+
+static ssize_t fabricsim_del_ep_write(struct file *file, const char __user *buf,
+				      size_t count, loff_t *ppos)
+{
+	int slot = fabricsim_parse_int(buf, count, -1);
+	int ret = fabricsim_del_endpoint(slot);
+
+	return ret < 0 ? ret : count;
+}
+
+static const struct file_operations fabricsim_add_ep_fops = {
+	.owner	= THIS_MODULE,
+	.write	= fabricsim_add_ep_write,
+};
+
+static const struct file_operations fabricsim_del_ep_fops = {
+	.owner	= THIS_MODULE,
+	.write	= fabricsim_del_ep_write,
+};
+
+static ssize_t fabricsim_bulk_add_write(struct file *file,
+					const char __user *buf,
+					size_t count, loff_t *ppos)
+{
+	int n = fabricsim_parse_int(buf, count, 0);
+	int ret = fabricsim_bulk_add(n);
+
+	return ret < 0 ? ret : count;
+}
+
+static ssize_t fabricsim_bulk_del_write(struct file *file,
+					const char __user *buf,
+					size_t count, loff_t *ppos)
+{
+	int ret = fabricsim_bulk_del();
+
+	return ret < 0 ? ret : count;
+}
+
+static const struct file_operations fabricsim_bulk_add_fops = {
+	.owner	= THIS_MODULE,
+	.write	= fabricsim_bulk_add_write,
+};
+
+static const struct file_operations fabricsim_bulk_del_fops = {
+	.owner	= THIS_MODULE,
+	.write	= fabricsim_bulk_del_write,
+};
+
+static ssize_t fabricsim_fail_errno_write(struct file *file,
+					  const char __user *buf,
+					  size_t count, loff_t *ppos)
+{
+	u32 val;
+	int ret;
+
+	ret = kstrtou32_from_user(buf, count, 0, &val);
+	if (ret)
+		return ret;
+	/* Zero stays the documented "clear to -ENOMEM" sentinel. */
+	if (val > MAX_ERRNO)
+		return -EINVAL;
+	fabricsim_fail_errno = val;
+	return count;
+}
+
+static ssize_t fabricsim_fail_errno_read(struct file *file, char __user *buf,
+					 size_t count, loff_t *ppos)
+{
+	char kbuf[16];
+	int len;
+
+	len = scnprintf(kbuf, sizeof(kbuf), "%u\n", fabricsim_fail_errno);
+	return simple_read_from_buffer(buf, count, ppos, kbuf, len);
+}
+
+static const struct file_operations fabricsim_fail_errno_fops = {
+	.owner	= THIS_MODULE,
+	.open	= simple_open,
+	.read	= fabricsim_fail_errno_read,
+	.write	= fabricsim_fail_errno_write,
+};
+
+/*
+ * Validate and derive the module parameters before anything is registered.
+ * This runs before allocation, so it needs no unwind path.
+ */
+static int __init fabricsim_setup_params(void)
+{
+	/*
+	 * Reject an unrecognised topology rather than falling back to mesh, so
+	 * a typo cannot fake a shape.
+	 */
+	if (strcmp(topology, "mesh") && strcmp(topology, "linear") &&
+	    strcmp(topology, "switch")) {
+		pr_err("fabricsim: unknown topology \"%s\" (use mesh, linear or switch)\n",
+		       topology);
+		return -EINVAL;
+	}
+
+	if (num_endpoints < 2)
+		num_endpoints = 2;
+	if (num_endpoints > FABRICSIM_MAX_INIT_EPS)
+		num_endpoints = FABRICSIM_MAX_INIT_EPS;
+	if (ports_per_ep < 1)
+		ports_per_ep = 1;
+	if (ports_per_ep > 16)
+		ports_per_ep = 16;
+
+	/*
+	 * A mesh gives every endpoint (N-1) peers, so the busiest endpoint needs
+	 * at least (N-1) ports. The switch shape only needs one port per leaf
+	 * (a single half-edge to the opaque switch), so it is not bumped here.
+	 */
+	if (strcmp(topology, "mesh") == 0 && ports_per_ep < num_endpoints - 1)
+		ports_per_ep = num_endpoints - 1;
+
+	/*
+	 * A linear chain gives every interior node two neighbours, so it needs
+	 * at least two ports; bump a too-small request rather than index past
+	 * the endpoint's port array.
+	 */
+	if (strcmp(topology, "linear") == 0 && num_endpoints > 2 &&
+	    ports_per_ep < 2)
+		ports_per_ep = 2;
+
+	fabricsim_init_eps = num_endpoints;
+
+	return 0;
+}
+
+static int __init fabricsim_init(void)
+{
+	struct drm_fabric_desc fdesc;
+	int i, j, ret;
+
+	ret = fabricsim_setup_params();
+	if (ret)
+		return ret;
+
+	fdesc.type = DRM_FABRIC_TYPE_SYNTHETIC;
+	fdesc.name = "fabricsim";
+	fdesc.instance_id = 0x8086CAFE;
+
+	fabricsim_fabric = drm_fabric_register(&fdesc);
+	if (IS_ERR(fabricsim_fabric))
+		return PTR_ERR(fabricsim_fabric);
+
+	/* Root must exist before the per-endpoint debugfs subtrees. */
+	fabricsim_debugfs_root = debugfs_create_dir("drm_fabric_sim", NULL);
+	if (IS_ERR(fabricsim_debugfs_root))
+		fabricsim_debugfs_root = NULL;
+
+	mutex_lock(&fabricsim_lock);
+	for (i = 0; i < fabricsim_init_eps; i++) {
+		struct fabricsim_ep_priv *ep_priv =
+			fabricsim_make_ep(i, ports_per_ep);
+
+		if (IS_ERR(ep_priv)) {
+			ret = PTR_ERR(ep_priv);
+			mutex_unlock(&fabricsim_lock);
+			goto err_eps;
+		}
+		fabricsim_slots[i] = ep_priv;
+	}
+	mutex_unlock(&fabricsim_lock);
+
+	if (strcmp(topology, "linear") == 0)
+		fabricsim_link_linear();
+	else if (strcmp(topology, "switch") == 0)
+		fabricsim_link_switch();
+	else
+		fabricsim_link_mesh();
+
+	/* A linked port starts ACTIVE; an unlinked one stays as registered. */
+	for (i = 0; i < fabricsim_init_eps; i++) {
+		for (j = 0; j < fabricsim_slots[i]->num_ports; j++) {
+			struct fabricsim_port_priv *pp = &fabricsim_slots[i]->ports[j];
+
+			if (pp->port && pp->port->has_peer)
+				drm_fabric_port_set_oper(pp->port,
+							 DRM_FABRIC_PORT_STATE_ACTIVE);
+		}
+	}
+
+	/* Root-level runtime lifecycle controls (test-only, not uAPI). */
+	if (fabricsim_debugfs_root) {
+		debugfs_create_file("add_endpoint", 0200, fabricsim_debugfs_root,
+				    NULL, &fabricsim_add_ep_fops);
+		debugfs_create_file("del_endpoint", 0200, fabricsim_debugfs_root,
+				    NULL, &fabricsim_del_ep_fops);
+
+		debugfs_create_file("bulk_add", 0200, fabricsim_debugfs_root,
+				    NULL, &fabricsim_bulk_add_fops);
+		debugfs_create_file("bulk_del", 0200, fabricsim_debugfs_root,
+				    NULL, &fabricsim_bulk_del_fops);
+
+		debugfs_create_bool("fail_register", 0644,
+				    fabricsim_debugfs_root,
+				    &fabricsim_fail_register);
+		debugfs_create_file("fail_errno", 0644,
+				    fabricsim_debugfs_root,
+				    NULL, &fabricsim_fail_errno_fops);
+	}
+
+	pr_info("fabricsim: registered %s topology with %d endpoints, %d ports/ep\n",
+		topology, fabricsim_init_eps, ports_per_ep);
+
+	return 0;
+
+err_eps:
+	mutex_lock(&fabricsim_lock);
+	for (i = FABRICSIM_MAX_EPS - 1; i >= 0; i--) {
+		if (fabricsim_slots[i]) {
+			fabricsim_destroy_ep(fabricsim_slots[i]);
+			fabricsim_slots[i] = NULL;
+		}
+	}
+	mutex_unlock(&fabricsim_lock);
+	debugfs_remove_recursive(fabricsim_debugfs_root);
+	WARN_ON(drm_fabric_unregister(fabricsim_fabric));
+	return ret;
+}
+
+static void __exit fabricsim_exit(void)
+{
+	int i;
+
+	/*
+	 * fabricsim_exiting gates add/del first and the root subtree is removed
+	 * last, so no handler can race this teardown.
+	 */
+	mutex_lock(&fabricsim_lock);
+	fabricsim_exiting = true;
+	for (i = FABRICSIM_MAX_EPS - 1; i >= 0; i--) {
+		if (fabricsim_slots[i]) {
+			fabricsim_destroy_ep(fabricsim_slots[i]);
+			fabricsim_slots[i] = NULL;
+		}
+	}
+	mutex_unlock(&fabricsim_lock);
+
+	debugfs_remove_recursive(fabricsim_debugfs_root);
+	WARN_ON(drm_fabric_unregister(fabricsim_fabric));
+
+	pr_info("fabricsim: unloaded\n");
+}
+
+module_init(fabricsim_init);
+module_exit(fabricsim_exit);
+
+MODULE_AUTHOR("Intel Corporation");
+MODULE_AUTHOR("Konstantin Sinyuk <ksinyuk@kernel.org>");
+MODULE_DESCRIPTION("DRM Fabric fabricsim synthetic driver");
+MODULE_LICENSE("Dual MIT/GPL");
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [RFC PATCH 05/12] drm/fabric: add object-model KUnit tests
  2026-08-24  8:09 [RFC PATCH 0/12] drm/fabric: vendor-neutral topology infrastructure for scale-up accelerator interconnects Konstantin Sinyuk
                   ` (3 preceding siblings ...)
  2026-08-24  8:09 ` [RFC PATCH 04/12] drm/fabric: add read-only synthetic provider Konstantin Sinyuk
@ 2026-08-24  8:09 ` Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 06/12] drm/fabric: add YNL query and policy selftests Konstantin Sinyuk
                   ` (6 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Konstantin Sinyuk @ 2026-08-24  8:09 UTC (permalink / raw)
  To: dri-devel
  Cc: Maarten Lankhorst, Francois Dugast, David Airlie, Simona Vetter,
	Maxime Ripard, Thomas Zimmermann, Jonathan Corbet, Shuah Khan,
	Donald Hunter, Jakub Kicinski, David S. Miller, Eric Dumazet,
	Paolo Abeni, Simon Horman, Ilia Levi, Rodrigo Vivi, linux-doc,
	linux-kselftest, netdev, linux-kernel

The object model has invariants a refactor can break: identity scope,
borrowed port lifetime, topology-generation updates and the context in
which provider callbacks run.

Build the tests into drm_fabric.o under CONFIG_DRM_FABRIC_KUNIT_TEST so
no test-only symbols need to be exported.

Coverage includes registration and identity uniqueness, object lifetime,
half-edge peer state, topology-generation changes and provider ops
wiring. Linear and mesh topologies are parameterized. Unregister rejects
a non-member pointer, including one carrying a registered object's
identifier.

Provide .kunitconfig for UML and .kunitconfig.debug for x86-64 with
KASAN, KMEMLEAK, UBSAN, PROVE_LOCKING and DEBUG_ATOMIC_SLEEP.

  $ ./tools/testing/kunit/kunit.py run \
        --kunitconfig=drivers/gpu/drm/fabric/.kunitconfig
  [...]
  Testing complete. Ran 31 tests: passed: 31

Signed-off-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Assisted-by: GitHub-Copilot:claude-opus-4.8
---
 Documentation/gpu/drm-fabric.rst          |    6 +
 drivers/gpu/drm/fabric/.kunitconfig       |    5 +
 drivers/gpu/drm/fabric/.kunitconfig.debug |   16 +
 drivers/gpu/drm/fabric/Kconfig            |   16 +
 drivers/gpu/drm/fabric/Makefile           |    2 +
 drivers/gpu/drm/fabric/drm_fabric.c       |    4 +
 drivers/gpu/drm/fabric/drm_fabric_test.c  | 1312 +++++++++++++++++++++
 7 files changed, 1361 insertions(+)
 create mode 100644 drivers/gpu/drm/fabric/.kunitconfig
 create mode 100644 drivers/gpu/drm/fabric/.kunitconfig.debug
 create mode 100644 drivers/gpu/drm/fabric/drm_fabric_test.c

diff --git a/Documentation/gpu/drm-fabric.rst b/Documentation/gpu/drm-fabric.rst
index 8bd3633d41be..cd98a97e1662 100644
--- a/Documentation/gpu/drm-fabric.rst
+++ b/Documentation/gpu/drm-fabric.rst
@@ -351,3 +351,9 @@ and runtime endpoint add/remove. These files are unstable test controls and are
 not part of the uAPI; the stable, reviewed interface is the YAML-described
 Generic Netlink family. Tests mutate simulator state through debugfs and observe
 the result over Generic Netlink.
+
+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.
diff --git a/drivers/gpu/drm/fabric/.kunitconfig b/drivers/gpu/drm/fabric/.kunitconfig
new file mode 100644
index 000000000000..d6087f45f2ac
--- /dev/null
+++ b/drivers/gpu/drm/fabric/.kunitconfig
@@ -0,0 +1,5 @@
+CONFIG_KUNIT=y
+CONFIG_NET=y
+CONFIG_DRM=y
+CONFIG_DRM_FABRIC=y
+CONFIG_DRM_FABRIC_KUNIT_TEST=y
diff --git a/drivers/gpu/drm/fabric/.kunitconfig.debug b/drivers/gpu/drm/fabric/.kunitconfig.debug
new file mode 100644
index 000000000000..8add027fc766
--- /dev/null
+++ b/drivers/gpu/drm/fabric/.kunitconfig.debug
@@ -0,0 +1,16 @@
+CONFIG_KUNIT=y
+CONFIG_NET=y
+CONFIG_DRM=y
+CONFIG_DRM_FABRIC=y
+CONFIG_DRM_FABRIC_KUNIT_TEST=y
+CONFIG_DEBUG_KERNEL=y
+CONFIG_KASAN=y
+CONFIG_KASAN_GENERIC=y
+CONFIG_DEBUG_KMEMLEAK=y
+CONFIG_PROVE_LOCKING=y
+CONFIG_DEBUG_ATOMIC_SLEEP=y
+CONFIG_UBSAN=y
+CONFIG_UBSAN_BOUNDS=y
+CONFIG_UBSAN_SHIFT=y
+CONFIG_UBSAN_ENUM=y
+CONFIG_UBSAN_BOOL=y
diff --git a/drivers/gpu/drm/fabric/Kconfig b/drivers/gpu/drm/fabric/Kconfig
index 87115356baca..2a70cac85b1d 100644
--- a/drivers/gpu/drm/fabric/Kconfig
+++ b/drivers/gpu/drm/fabric/Kconfig
@@ -22,3 +22,19 @@ config DRM_FABRIC_SIM
 	  error injection, and port-state transitions.  These hooks are
 	  NOT part of the drm_fabric uAPI and are used only by selftests.
 	  Does not model UALink protocol traffic or memory semantics.
+
+config DRM_FABRIC_KUNIT_TEST
+	bool "DRM fabric object-model KUnit tests" if !KUNIT_ALL_TESTS
+	depends on DRM_FABRIC && KUNIT
+	depends on KUNIT=y || DRM_FABRIC=m
+	default KUNIT_ALL_TESTS
+	help
+	  Enable KUnit coverage for the drm_fabric object model.
+
+	  The tests are built into drm_fabric itself, so they need no exported
+	  symbols or test-only accessors in the production source.
+
+	  For more information on KUnit and unit tests in general, please refer
+	  to the KUnit documentation in Documentation/dev-tools/kunit/.
+
+	  If unsure, say N.
diff --git a/drivers/gpu/drm/fabric/Makefile b/drivers/gpu/drm/fabric/Makefile
index bc0a6c742164..05f1c96bb8de 100644
--- a/drivers/gpu/drm/fabric/Makefile
+++ b/drivers/gpu/drm/fabric/Makefile
@@ -5,3 +5,5 @@ drm-fabric-y := drm_fabric.o drm_fabric_netlink.o drm_fabric_nl.o
 
 obj-$(CONFIG_DRM_FABRIC_SIM) += drm-fabric-sim.o
 drm-fabric-sim-y := drm_fabric_sim.o
+
+# The tests are included in drm_fabric.o, not built as a separate object.
diff --git a/drivers/gpu/drm/fabric/drm_fabric.c b/drivers/gpu/drm/fabric/drm_fabric.c
index ce331656af70..bbc3224c3314 100644
--- a/drivers/gpu/drm/fabric/drm_fabric.c
+++ b/drivers/gpu/drm/fabric/drm_fabric.c
@@ -690,3 +690,7 @@ module_exit(drm_fabric_exit);
 MODULE_AUTHOR("Intel Corporation");
 MODULE_DESCRIPTION("DRM fabric infrastructure");
 MODULE_LICENSE("Dual MIT/GPL");
+
+#if IS_ENABLED(CONFIG_DRM_FABRIC_KUNIT_TEST)
+#include "drm_fabric_test.c"
+#endif
diff --git a/drivers/gpu/drm/fabric/drm_fabric_test.c b/drivers/gpu/drm/fabric/drm_fabric_test.c
new file mode 100644
index 000000000000..3457675645f6
--- /dev/null
+++ b/drivers/gpu/drm/fabric/drm_fabric_test.c
@@ -0,0 +1,1312 @@
+// SPDX-License-Identifier: MIT
+/*
+ * Copyright © 2026 Intel Corporation
+ */
+
+/*
+ * KUnit tests for DRM Fabric object lifetime, identity, topology and
+ * provider callback registration.
+ */
+
+#include <kunit/test.h>
+#include <kunit/device.h>
+
+#include <linux/device.h>
+#include <linux/err.h>
+#include <linux/mutex.h>
+#include <linux/string.h>
+
+#include <drm/drm_fabric.h>
+#include <uapi/drm/drm_fabric.h>
+
+#include "drm_fabric_internal.h"
+
+static struct drm_fabric_port *fabrictest_port(struct drm_fabric_endpoint *ep,
+					       u32 index)
+{
+	return drm_fabric_endpoint_port(ep, index);
+}
+
+static struct device *fabrictest_alloc_dev(struct kunit *test)
+{
+	struct device *dev = kunit_device_register(test, "drm_fabric_test");
+
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, dev);
+	return dev;
+}
+
+static void fabrictest_unregister_fabric(void *fab)
+{
+	drm_fabric_unregister(fab);
+}
+
+static void fabrictest_unregister_endpoint(void *ep)
+{
+	drm_fabric_endpoint_unregister(ep);
+}
+
+/* Production reads the generation under the lock; do the same here. */
+static u32 fabrictest_seq_read(void)
+{
+	guard(mutex)(&drm_fabric_lock);
+
+	return drm_fabric_base_seq;
+}
+
+static void fabrictest_seq_write(u32 val)
+{
+	guard(mutex)(&drm_fabric_lock);
+
+	drm_fabric_base_seq = val;
+}
+
+static void fabrictest_restore_seq(void *saved)
+{
+	fabrictest_seq_write(*(u32 *)saved);
+}
+
+/* Restore on exit so a seeded value cannot leak into a later test. */
+static void fabrictest_seed_seq(struct kunit *test, u32 val)
+{
+	u32 *saved = kunit_kzalloc(test, sizeof(*saved), GFP_KERNEL);
+
+	KUNIT_ASSERT_NOT_NULL(test, saved);
+	*saved = fabrictest_seq_read();
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_restore_seq, saved));
+
+	fabrictest_seq_write(val);
+}
+
+static void drm_fabric_test_fabric_register(struct kunit *test)
+{
+	struct drm_fabric_desc desc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-fabric",
+		.instance_id = 0xDEAD,
+	};
+	struct drm_fabric *fab;
+
+	fab = drm_fabric_register(&desc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	KUNIT_EXPECT_EQ(test, fab->type, DRM_FABRIC_TYPE_SYNTHETIC);
+	KUNIT_EXPECT_EQ(test, fab->instance_id, 0xDEADULL);
+}
+
+/*
+ * The non-empty case warns because it is a provider teardown bug, so it is not
+ * exercised here.
+ */
+static void drm_fabric_test_unregister_reports_removal(struct kunit *test)
+{
+	struct drm_fabric_desc desc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC, .name = "unreg-ret",
+	};
+	struct drm_fabric *fab;
+
+	fab = drm_fabric_register(&desc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_EXPECT_EQ(test, drm_fabric_unregister(fab), 0);
+}
+
+static void drm_fabric_test_endpoint_requires_fabric(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 1,
+		.name = "no-fabric",
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric_endpoint *ep;
+
+	ep = drm_fabric_endpoint_register(NULL, &edesc);
+	KUNIT_EXPECT_TRUE(test, IS_ERR(ep));
+	if (IS_ERR(ep))
+		KUNIT_EXPECT_EQ(test, PTR_ERR(ep), -EINVAL);
+}
+
+/* Registration must not walk a NULL port array. */
+static void drm_fabric_test_endpoint_requires_port_array(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC, .name = "no-port-array",
+	};
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 1,
+		.name = "portless",
+		.parent = fabrictest_dev,
+		.ports = NULL,
+		.num_ports = 1,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_EXPECT_TRUE(test, IS_ERR(ep));
+	if (IS_ERR(ep))
+		KUNIT_EXPECT_EQ(test, PTR_ERR(ep), -EINVAL);
+}
+
+/* Reject a non-member pointer without dereferencing it. */
+static void drm_fabric_test_unregister_rejects_non_member(struct kunit *test)
+{
+	struct drm_fabric *candidate;
+	u32 before;
+
+	candidate = kunit_kzalloc(test, sizeof(*candidate), GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, candidate);
+
+	before = fabrictest_seq_read();
+	KUNIT_EXPECT_EQ(test, drm_fabric_unregister(candidate), -ENODEV);
+	/* A rejected teardown emits nothing, so the generation cannot move. */
+	KUNIT_EXPECT_EQ(test, fabrictest_seq_read(), before);
+}
+
+/*
+ * The check must compare the supplied pointer, not resolve the id it carries:
+ * an id-keyed lookup would erase whichever live fabric owns that id.
+ */
+static void drm_fabric_test_unregister_rejects_same_id(struct kunit *test)
+{
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "same-id-owner",
+	};
+	struct drm_fabric *fab, *twin;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	twin = kunit_kzalloc(test, sizeof(*twin), GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, twin);
+	twin->id = fab->id;
+
+	KUNIT_EXPECT_EQ(test, drm_fabric_unregister(twin), -ENODEV);
+
+	/* Returning 0 proves the impostor did not erase the id's owner. */
+	kunit_remove_action(test, fabrictest_unregister_fabric, fab);
+	KUNIT_EXPECT_EQ(test, drm_fabric_unregister(fab), 0);
+}
+
+/*
+ * The endpoint registry is checked directly rather than through
+ * drm_fabric_endpoint_unregister(), whose only report channel for an
+ * unregistered pointer is a one-shot warning.
+ */
+static void drm_fabric_test_ep_membership_rejects_non_member(struct kunit *test)
+{
+	struct drm_fabric_endpoint *ep;
+
+	ep = kunit_kzalloc(test, sizeof(*ep), GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, ep);
+
+	scoped_guard(mutex, &drm_fabric_lock)
+		KUNIT_EXPECT_FALSE(test, drm_fabric_ep_is_registered(ep));
+}
+
+/* Endpoint counterpart of the same-id case: membership is pointer identity. */
+static void drm_fabric_test_ep_membership_rejects_same_id(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "ep-same-id-fab",
+	};
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 7,
+		.name = "ep-same-id-owner",
+		.parent = fabrictest_dev,
+	};
+	struct drm_fabric_endpoint *ep, *twin;
+	struct drm_fabric *fab;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	twin = kunit_kzalloc(test, sizeof(*twin), GFP_KERNEL);
+	KUNIT_ASSERT_NOT_ERR_OR_NULL(test, twin);
+	twin->id = ep->id;
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		KUNIT_EXPECT_FALSE(test, drm_fabric_ep_is_registered(twin));
+		KUNIT_EXPECT_TRUE(test, drm_fabric_ep_is_registered(ep));
+	}
+}
+
+/*
+ * Collapses the register-vs-unregister race: unregister first, then attach to
+ * the stale pointer.
+ */
+static void drm_fabric_test_endpoint_register_stale_fabric(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC, .name = "stale-parent",
+	};
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 1,
+		.name = "orphaned-by-unreg",
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, drm_fabric_unregister(fab), 0);
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_EXPECT_TRUE(test, IS_ERR(ep));
+	if (IS_ERR(ep))
+		KUNIT_EXPECT_EQ(test, PTR_ERR(ep), -ENODEV);
+}
+
+static void drm_fabric_test_instance_id_unique(struct kunit *test)
+{
+	struct drm_fabric_desc a = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC, .name = "iid-a",
+		.instance_id = 0x1357,
+	};
+	struct drm_fabric_desc dup = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC, .name = "iid-dup",
+		.instance_id = 0x1357,
+	};
+	struct drm_fabric_desc zero_a = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC, .name = "iid-z0", .instance_id = 0,
+	};
+	struct drm_fabric_desc zero_b = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC, .name = "iid-z1", .instance_id = 0,
+	};
+	struct drm_fabric *fab, *fab2, *dupf;
+
+	fab = drm_fabric_register(&a);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	dupf = drm_fabric_register(&dup);
+	KUNIT_EXPECT_TRUE(test, IS_ERR(dupf));
+	if (IS_ERR(dupf))
+		KUNIT_EXPECT_EQ(test, PTR_ERR(dupf), -EEXIST);
+
+	fab = drm_fabric_register(&zero_a);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+	/* instance_id 0 is not special-cased. */
+	fab2 = drm_fabric_register(&zero_b);
+	KUNIT_EXPECT_TRUE(test, IS_ERR(fab2));
+	if (IS_ERR(fab2))
+		KUNIT_EXPECT_EQ(test, PTR_ERR(fab2), -EEXIST);
+}
+
+static void drm_fabric_test_endpoint_register(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-ep-fab",
+	};
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0x42,
+		.name = "ep0",
+		.parent = fabrictest_dev,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	KUNIT_EXPECT_PTR_EQ(test, ep->fabric, fab);
+	KUNIT_EXPECT_EQ(test, ep->fabric_ep_id, 0x42ULL);
+}
+
+static void drm_fabric_test_port_register_peer(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-port-fab",
+	};
+	struct drm_fabric_port_desc pdesc = {
+		.index = 0,
+		.max_lane_count = 4,
+		.max_lane_signaling_rate_mbps = 200000,
+	};
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0x10,
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric_peer peer = {
+		.peer_id = 0x20,
+		.peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+		.port_index = 1,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep;
+	struct drm_fabric_port *port;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	port = fabrictest_port(ep, 0);
+	KUNIT_ASSERT_NOT_NULL(test, port);
+
+	KUNIT_EXPECT_EQ(test, port->index, 0);
+	KUNIT_EXPECT_EQ(test, port->max_lane_count, 4);
+	KUNIT_EXPECT_FALSE(test, port->has_peer);
+	KUNIT_EXPECT_EQ(test, ep->num_ports, 1);
+
+	KUNIT_EXPECT_EQ(test, drm_fabric_port_set_peer(port, &peer), 0);
+	KUNIT_EXPECT_TRUE(test, port->has_peer);
+	KUNIT_EXPECT_EQ(test, port->peer.peer_id, 0x20ULL);
+	KUNIT_EXPECT_EQ(test, port->peer.port_index, 1);
+}
+
+/* Topology changes must invalidate an in-progress dump. */
+static void drm_fabric_test_base_seq_advances(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-base-seq",
+	};
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 2 };
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0x40,
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric_peer peer = {
+		.peer_id = 0x41,
+		.peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+		.port_index = 0,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep;
+	struct drm_fabric_port *port;
+	u32 seq;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	port = fabrictest_port(ep, 0);
+	KUNIT_ASSERT_NOT_NULL(test, port);
+
+	seq = fabrictest_seq_read();
+	KUNIT_EXPECT_EQ(test, drm_fabric_port_set_peer(port, &peer), 0);
+	KUNIT_EXPECT_NE(test, fabrictest_seq_read(), seq);
+
+	seq = fabrictest_seq_read();
+	KUNIT_EXPECT_EQ(test, drm_fabric_port_unset_peer(port), 0);
+	KUNIT_EXPECT_NE(test, fabrictest_seq_read(), seq);
+
+	seq = fabrictest_seq_read();
+	drm_fabric_port_set_oper(port, DRM_FABRIC_PORT_STATE_ACTIVE);
+	KUNIT_EXPECT_NE(test, fabrictest_seq_read(), seq);
+
+	/* A no-op transition must not advance the generation. */
+	seq = fabrictest_seq_read();
+	drm_fabric_port_set_oper(port, DRM_FABRIC_PORT_STATE_ACTIVE);
+	KUNIT_EXPECT_EQ(test, fabrictest_seq_read(), seq);
+}
+
+/* The generation is a nonzero u32: wrapping must skip 0, not just increment. */
+static void drm_fabric_test_generation_wrap_nonzero(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-gen-wrap",
+	};
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 2 };
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0x4A,
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep;
+	struct drm_fabric_port *port;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	port = fabrictest_port(ep, 0);
+	KUNIT_ASSERT_NOT_NULL(test, port);
+
+	/* Make the next state change advance the generation once. */
+	drm_fabric_port_set_oper(port, DRM_FABRIC_PORT_STATE_INACTIVE);
+
+	fabrictest_seed_seq(test, U32_MAX);
+	drm_fabric_port_set_oper(port, DRM_FABRIC_PORT_STATE_ACTIVE);
+	KUNIT_EXPECT_NE(test, fabrictest_seq_read(), 0);
+	KUNIT_EXPECT_EQ(test, fabrictest_seq_read(), 1);
+}
+
+static void drm_fabric_test_port_state_transition(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-state",
+	};
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 2 };
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0x30,
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep;
+	struct drm_fabric_port *port;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	port = fabrictest_port(ep, 0);
+	KUNIT_ASSERT_NOT_NULL(test, port);
+
+	KUNIT_EXPECT_EQ(test, port->oper_state, DRM_FABRIC_PORT_STATE_UNKNOWN);
+
+	drm_fabric_port_set_oper(port, DRM_FABRIC_PORT_STATE_ACTIVE);
+	KUNIT_EXPECT_EQ(test, port->oper_state, DRM_FABRIC_PORT_STATE_ACTIVE);
+
+	drm_fabric_port_set_oper(port, DRM_FABRIC_PORT_STATE_DEGRADED);
+	KUNIT_EXPECT_EQ(test, port->oper_state, DRM_FABRIC_PORT_STATE_DEGRADED);
+
+	drm_fabric_port_set_oper(port, DRM_FABRIC_PORT_STATE_INACTIVE);
+	KUNIT_EXPECT_EQ(test, port->oper_state, DRM_FABRIC_PORT_STATE_INACTIVE);
+}
+
+/* An invalid provider state must not be committed. */
+static void drm_fabric_test_port_oper_state_rejects_invalid(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-state-invalid",
+	};
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 2 };
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0x32,
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	enum drm_fabric_port_state bad =
+		(enum drm_fabric_port_state)(DRM_FABRIC_PORT_STATE_DEGRADED + 1);
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep;
+	struct drm_fabric_port *port;
+	u32 seq;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	port = fabrictest_port(ep, 0);
+	KUNIT_ASSERT_NOT_NULL(test, port);
+
+	drm_fabric_port_set_oper(port, DRM_FABRIC_PORT_STATE_ACTIVE);
+	KUNIT_EXPECT_EQ(test, port->oper_state, DRM_FABRIC_PORT_STATE_ACTIVE);
+
+	seq = fabrictest_seq_read();
+	drm_fabric_port_set_oper(port, bad);
+	KUNIT_EXPECT_EQ(test, port->oper_state, DRM_FABRIC_PORT_STATE_ACTIVE);
+	KUNIT_EXPECT_EQ(test, fabrictest_seq_read(), seq);
+
+	drm_fabric_port_set_oper(port, DRM_FABRIC_PORT_STATE_INACTIVE);
+	KUNIT_EXPECT_EQ(test, port->oper_state, DRM_FABRIC_PORT_STATE_INACTIVE);
+}
+
+static void drm_fabric_test_register_rejects_invalid_type(struct kunit *test)
+{
+	struct drm_fabric_desc above = {
+		.type = (enum drm_fabric_type)(DRM_FABRIC_TYPE_SYNTHETIC + 1),
+		.instance_id = 0x5a5a,
+		.name = "test-type-above",
+	};
+	struct drm_fabric_desc zero = {
+		.type = (enum drm_fabric_type)0,
+		.instance_id = 0x5a5a,
+		.name = "test-type-zero",
+	};
+	struct drm_fabric_desc good = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.instance_id = 0x5a5a,
+		.name = "test-type-good",
+	};
+	struct drm_fabric *fab, *bad;
+	u32 seq = fabrictest_seq_read();
+
+	bad = drm_fabric_register(&above);
+	KUNIT_EXPECT_TRUE(test, IS_ERR(bad));
+	if (IS_ERR(bad))
+		KUNIT_EXPECT_EQ(test, PTR_ERR(bad), -EINVAL);
+	else
+		drm_fabric_unregister(bad);
+
+	bad = drm_fabric_register(&zero);
+	KUNIT_EXPECT_TRUE(test, IS_ERR(bad));
+	if (IS_ERR(bad))
+		KUNIT_EXPECT_EQ(test, PTR_ERR(bad), -EINVAL);
+	else
+		drm_fabric_unregister(bad);
+
+	/* Refused before publication, so the generation cannot have advanced. */
+	KUNIT_EXPECT_EQ(test, fabrictest_seq_read(), seq);
+
+	/*
+	 * Same instance id as both refusals: drm_fabric_has_instance() would
+	 * answer -EEXIST here if either had left a registry entry behind.
+	 */
+	fab = drm_fabric_register(&good);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+}
+
+/* Verify reciprocity and far-side port indices for every K_4 edge. */
+static void drm_fabric_test_mesh_kn_topology(struct kunit *test)
+{
+#define KN_EPS 4
+#define KN_PORTS_PER_EP (KN_EPS - 1)
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "mesh-kn",
+		.instance_id = 0x1002,
+	};
+	struct drm_fabric_port_desc pdescs[KN_PORTS_PER_EP];
+	struct drm_fabric_endpoint_desc edesc;
+	struct drm_fabric_peer peer;
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *eps[KN_EPS];
+	struct drm_fabric_port *ports[KN_EPS][KN_PORTS_PER_EP];
+	int i, j, k, port_idx, peer_port;
+
+	memset(pdescs, 0, sizeof(pdescs));
+	for (j = 0; j < KN_PORTS_PER_EP; j++) {
+		pdescs[j].index = j;
+		pdescs[j].max_lane_count = 4;
+		pdescs[j].max_lane_signaling_rate_mbps = 200000;
+	}
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	for (i = 0; i < KN_EPS; i++) {
+		memset(&edesc, 0, sizeof(edesc));
+		edesc.fabric_ep_id = 0x100 + i;
+		edesc.parent = fabrictest_dev;
+		edesc.ports = pdescs;
+		edesc.num_ports = KN_PORTS_PER_EP;
+
+		eps[i] = drm_fabric_endpoint_register(fab, &edesc);
+		KUNIT_ASSERT_FALSE(test, IS_ERR(eps[i]));
+		KUNIT_ASSERT_EQ(test, 0,
+				kunit_add_action_or_reset(test,
+							  fabrictest_unregister_endpoint, eps[i]));
+
+		for (j = 0; j < KN_PORTS_PER_EP; j++) {
+			ports[i][j] = fabrictest_port(eps[i], j);
+			KUNIT_ASSERT_NOT_NULL(test, ports[i][j]);
+		}
+	}
+
+	/* Wire K_4: ep[i] port[j] -> ep[target] */
+	for (i = 0; i < KN_EPS; i++) {
+		port_idx = 0;
+		for (j = 0; j < KN_EPS; j++) {
+			if (i == j)
+				continue;
+
+			/* Compute peer's port index pointing back to us. */
+			peer_port = 0;
+			for (k = 0; k < KN_EPS; k++) {
+				if (k == j)
+					continue;
+				if (k == i)
+					break;
+				peer_port++;
+			}
+
+			peer.peer_id = eps[j]->fabric_ep_id;
+			peer.peer_type = DRM_FABRIC_PEER_TYPE_ACCEL;
+			peer.port_index = peer_port;
+			drm_fabric_port_set_peer(ports[i][port_idx], &peer);
+			port_idx++;
+		}
+	}
+
+	for (i = 0; i < KN_EPS; i++) {
+		for (j = 0; j < KN_PORTS_PER_EP; j++) {
+			struct drm_fabric_port *p = ports[i][j];
+			struct drm_fabric_port *pp;
+			u64 peer_ep_id;
+			u32 peer_pidx;
+			int peer_ep_idx, pi;
+
+			KUNIT_EXPECT_TRUE_MSG(test, p->has_peer,
+					      "ep%d port%d has no peer", i, j);
+			if (!p->has_peer)
+				continue;
+
+			peer_ep_id = p->peer.peer_id;
+			peer_pidx = p->peer.port_index;
+
+			peer_ep_idx = -1;
+			for (pi = 0; pi < KN_EPS; pi++) {
+				if (eps[pi]->fabric_ep_id == peer_ep_id) {
+					peer_ep_idx = pi;
+					break;
+				}
+			}
+			KUNIT_EXPECT_GE_MSG(test, peer_ep_idx, 0,
+					    "ep%d port%d peer EP not found", i, j);
+			if (peer_ep_idx < 0)
+				continue;
+			KUNIT_EXPECT_LT(test, peer_pidx, (u32)KN_PORTS_PER_EP);
+			if (peer_pidx >= (u32)KN_PORTS_PER_EP)
+				continue;
+
+			pp = ports[peer_ep_idx][peer_pidx];
+			KUNIT_EXPECT_TRUE(test, pp->has_peer);
+			KUNIT_EXPECT_EQ(test, pp->peer.peer_id, eps[i]->fabric_ep_id);
+			KUNIT_EXPECT_EQ(test, pp->peer.port_index, (u32)j);
+		}
+	}
+#undef KN_EPS
+#undef KN_PORTS_PER_EP
+}
+
+static int fabrictest_stats_get(struct drm_fabric_port *port,
+				struct drm_fabric_port_stats *stats)
+{
+	stats->read_bytes = 4096;
+	stats->write_bytes = 2048;
+	stats->link_down_count = 2;
+	stats->retrain_count = 3;
+	return 0;
+}
+
+static const struct drm_fabric_ops fabrictest_stats_ops = {
+	.port_stats_get = fabrictest_stats_get,
+};
+
+/* This does not exercise netlink dispatch or error propagation. */
+static void drm_fabric_test_port_stats_ops_registration(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-stats",
+	};
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0x5A,
+		.parent = fabrictest_dev,
+		.ops = &fabrictest_stats_ops,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric_endpoint_desc edesc_noops = {
+		.fabric_ep_id = 0x5B,
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric_port_stats stats = {};
+	struct drm_fabric_endpoint *ep, *ep_noops;
+	struct drm_fabric_port *port;
+	struct drm_fabric *fab;
+	u32 gen;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	port = fabrictest_port(ep, 0);
+	KUNIT_ASSERT_NOT_NULL(test, port);
+
+	KUNIT_ASSERT_NOT_NULL(test, ep->ops);
+	KUNIT_ASSERT_NOT_NULL(test, ep->ops->port_stats_get);
+
+	/* A stats read is not a topology change: seq must not move. */
+	gen = fabrictest_seq_read();
+	KUNIT_EXPECT_EQ(test, ep->ops->port_stats_get(port, &stats), 0);
+	KUNIT_EXPECT_EQ(test, fabrictest_seq_read(), gen);
+	KUNIT_EXPECT_EQ(test, stats.read_bytes, 4096ULL);
+	KUNIT_EXPECT_EQ(test, stats.write_bytes, 2048ULL);
+	KUNIT_EXPECT_EQ(test, stats.link_down_count, 2ULL);
+	KUNIT_EXPECT_EQ(test, stats.retrain_count, 3ULL);
+
+	ep_noops = drm_fabric_endpoint_register(fab, &edesc_noops);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_noops));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_noops));
+	KUNIT_EXPECT_TRUE(test, !ep_noops->ops || !ep_noops->ops->port_stats_get);
+}
+
+/*
+ * Unregistering an endpoint that has a peer link must clear only that
+ * endpoint's own port record; it must not touch the still-registered far
+ * side's peer record. (Contrast with drm_fabric_port_unset_peer(), which
+ * clears a peer explicitly and is covered separately.)
+ */
+static void drm_fabric_test_local_unplug_keeps_edge(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-unplug",
+	};
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
+	struct drm_fabric_endpoint_desc eadesc = {
+		.fabric_ep_id = 0xA0,
+		.name = "unplug-a",
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric_endpoint_desc ebdesc = {
+		.fabric_ep_id = 0xB0,
+		.name = "unplug-b",
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric_peer to_b = {
+		.peer_id = 0xB0, .peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+	};
+	struct drm_fabric_peer to_a = {
+		.peer_id = 0xA0, .peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep_a, *ep_b;
+	struct drm_fabric_port *pa, *pb;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep_a = drm_fabric_endpoint_register(fab, &eadesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_a));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_a));
+
+	ep_b = drm_fabric_endpoint_register(fab, &ebdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_b));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_b));
+
+	pa = fabrictest_port(ep_a, 0);
+	pb = fabrictest_port(ep_b, 0);
+	KUNIT_ASSERT_NOT_NULL(test, pa);
+	KUNIT_ASSERT_NOT_NULL(test, pb);
+
+	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(pa, &to_b), 0);
+	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(pb, &to_a), 0);
+	KUNIT_EXPECT_TRUE(test, pa->has_peer);
+
+	/*
+	 * Remove B without retracting its peer first, modelling abrupt provider
+	 * teardown.
+	 */
+	kunit_release_action(test, fabrictest_unregister_endpoint, ep_b);
+
+	/* The surviving half-edge must be byte-unchanged: no field mutated. */
+	KUNIT_EXPECT_TRUE(test, pa->has_peer);
+	KUNIT_EXPECT_MEMEQ(test, &pa->peer, &to_b, sizeof(pa->peer));
+}
+
+/*
+ * A's peer record names a port index, not an object; registering and then
+ * unregistering an unrelated third endpoint must not perturb it.
+ */
+static void drm_fabric_test_remote_peer_retained(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-remote",
+	};
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
+	struct drm_fabric_endpoint_desc eadesc = {
+		.fabric_ep_id = 0xA0,
+		.name = "remote-a",
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric_endpoint_desc ecdesc = {
+		.fabric_ep_id = 0xC0,
+		.name = "remote-c",
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	/* 0xBEEF has no local endpoint object. */
+	struct drm_fabric_peer remote = {
+		.peer_id = 0xBEEF, .peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep_a, *ep_c;
+	struct drm_fabric_port *pa;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep_a = drm_fabric_endpoint_register(fab, &eadesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_a));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_a));
+
+	pa = fabrictest_port(ep_a, 0);
+	KUNIT_ASSERT_NOT_NULL(test, pa);
+
+	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(pa, &remote), 0);
+	KUNIT_EXPECT_TRUE(test, pa->has_peer);
+
+	ep_c = drm_fabric_endpoint_register(fab, &ecdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_c));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_c));
+
+	kunit_release_action(test, fabrictest_unregister_endpoint, ep_c);
+
+	KUNIT_EXPECT_TRUE(test, pa->has_peer);
+	KUNIT_EXPECT_MEMEQ(test, &pa->peer, &remote, sizeof(pa->peer));
+}
+
+/*
+ * Removing an endpoint with multiple peered ports must bump the topology
+ * generation exactly once, not once per port torn down.
+ */
+static void drm_fabric_test_subtree_delete_single_bump(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-subtree",
+	};
+	struct drm_fabric_port_desc pdescs[3] = {
+		{ .index = 0, .max_lane_count = 4 },
+		{ .index = 1, .max_lane_count = 4 },
+		{ .index = 2, .max_lane_count = 4 },
+	};
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0xD0,
+		.name = "subtree-ep",
+		.parent = fabrictest_dev,
+		.ports = pdescs,
+		.num_ports = 3,
+	};
+	struct drm_fabric_peer peer = {
+		.peer_id = 0xD1, .peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	/* Two of the three ports carry a half-edge. */
+	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(fabrictest_port(ep, 0), &peer), 0);
+	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(fabrictest_port(ep, 1), &peer), 0);
+
+	fabrictest_seed_seq(test, 100);
+	kunit_release_action(test, fabrictest_unregister_endpoint, ep);
+	KUNIT_EXPECT_EQ(test, fabrictest_seq_read(), 101);
+}
+
+static void drm_fabric_test_switch_topology(struct kunit *test)
+{
+#define SW_LEAVES 3
+	/*
+	 * Each leaf carries one half-edge to an opaque switch that is not a
+	 * registered endpoint, so this asserts half-edge serialization, not any
+	 * leaf -> switch -> leaf traversal.
+	 */
+	const u64 sw_id = 0x5000;
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-switch",
+	};
+	struct drm_fabric_port_desc leaf_pdesc = { .index = 0, .max_lane_count = 4 };
+	struct drm_fabric_endpoint_desc edesc;
+	struct drm_fabric_peer peer;
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *leaves[SW_LEAVES];
+	struct drm_fabric_port *leaf_ports[SW_LEAVES];
+	int i;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	for (i = 0; i < SW_LEAVES; i++) {
+		memset(&edesc, 0, sizeof(edesc));
+		edesc.fabric_ep_id = 0x600 + i;
+		edesc.parent = fabrictest_dev;
+		edesc.ports = &leaf_pdesc;
+		edesc.num_ports = 1;
+		leaves[i] = drm_fabric_endpoint_register(fab, &edesc);
+		KUNIT_ASSERT_FALSE(test, IS_ERR(leaves[i]));
+		KUNIT_ASSERT_EQ(test, 0,
+				kunit_add_action_or_reset(test,
+							  fabrictest_unregister_endpoint,
+							  leaves[i]));
+
+		leaf_ports[i] = fabrictest_port(leaves[i], 0);
+		KUNIT_ASSERT_NOT_NULL(test, leaf_ports[i]);
+
+		peer.peer_id = sw_id;
+		peer.peer_type = DRM_FABRIC_PEER_TYPE_SWITCH;
+		peer.port_index = i;
+		KUNIT_ASSERT_EQ(test,
+				drm_fabric_port_set_peer(leaf_ports[i], &peer), 0);
+	}
+
+	/* Switch peer IDs do not share the endpoint identity namespace. */
+	for (i = 0; i < SW_LEAVES; i++) {
+		KUNIT_EXPECT_TRUE(test, leaf_ports[i]->has_peer);
+		KUNIT_EXPECT_EQ(test, leaf_ports[i]->peer.peer_type,
+				(u32)DRM_FABRIC_PEER_TYPE_SWITCH);
+		KUNIT_EXPECT_EQ(test, leaf_ports[i]->peer.peer_id, sw_id);
+		KUNIT_EXPECT_EQ(test, leaf_ports[i]->peer.port_index, (u32)i);
+		KUNIT_EXPECT_NE(test, leaves[i]->fabric_ep_id, sw_id);
+	}
+#undef SW_LEAVES
+}
+
+enum fabrictest_shape { FT_SHAPE_LINEAR, FT_SHAPE_MESH };
+
+struct fabrictest_topo_param {
+	const char		*name;
+	enum fabrictest_shape	shape;
+	int			n_eps;
+	int			ports_per_ep;
+};
+
+static const struct fabrictest_topo_param fabrictest_topo_params[] = {
+	{ "linear-2", FT_SHAPE_LINEAR, 2, 1 },
+	{ "linear-3", FT_SHAPE_LINEAR, 3, 2 },
+	{ "linear-5", FT_SHAPE_LINEAR, 5, 2 },
+	{ "linear-8", FT_SHAPE_LINEAR, 8, 2 },
+	{ "mesh-2",   FT_SHAPE_MESH,   2, 1 },
+	{ "mesh-3",   FT_SHAPE_MESH,   3, 2 },
+	{ "mesh-4",   FT_SHAPE_MESH,   4, 3 },
+	{ "mesh-6",   FT_SHAPE_MESH,   6, 5 },
+};
+
+static void fabrictest_topo_desc(const struct fabrictest_topo_param *p,
+				 char *desc)
+{
+	strscpy(desc, p->name, KUNIT_PARAM_DESC_SIZE);
+}
+
+KUNIT_ARRAY_PARAM(fabrictest_topo, fabrictest_topo_params, fabrictest_topo_desc);
+
+#define FT_MAX_EPS	8
+#define FT_MAX_PORTS	16
+
+/* Count peered ports to validate each generated topology's degree. */
+static int fabrictest_peer_degree(struct drm_fabric_endpoint *ep, int n_ports)
+{
+	int j, deg = 0;
+
+	for (j = 0; j < n_ports; j++) {
+		struct drm_fabric_port *port = fabrictest_port(ep, j);
+
+		if (port && port->has_peer)
+			deg++;
+	}
+	return deg;
+}
+
+/*
+ * Chain eps[0]-eps[1]-...-eps[n-1]; endpoints get one link, interior nodes
+ * get two, each consuming the next free port.
+ */
+static void fabrictest_verify_linear_topology(struct kunit *test,
+					      const struct fabrictest_topo_param *p,
+					      struct drm_fabric_endpoint **eps)
+{
+	int cursor[FT_MAX_EPS] = {0};
+	struct drm_fabric_peer peer;
+	int i;
+
+	for (i = 0; i < p->n_eps - 1; i++) {
+		int ai = cursor[i]++;
+		int bi = cursor[i + 1]++;
+
+		peer = (struct drm_fabric_peer){
+			.peer_id = eps[i + 1]->fabric_ep_id,
+			.peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+			.port_index = bi,
+		};
+		drm_fabric_port_set_peer(fabrictest_port(eps[i], ai), &peer);
+
+		peer = (struct drm_fabric_peer){
+			.peer_id = eps[i]->fabric_ep_id,
+			.peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+			.port_index = ai,
+		};
+		drm_fabric_port_set_peer(fabrictest_port(eps[i + 1], bi), &peer);
+	}
+
+	for (i = 0; i < p->n_eps; i++) {
+		int want = (i == 0 || i == p->n_eps - 1) ? 1 : 2;
+
+		KUNIT_EXPECT_EQ_MSG(test,
+				    fabrictest_peer_degree(eps[i], p->ports_per_ep),
+				    want, "linear ep%d degree", i);
+	}
+}
+
+/*
+ * Fully-connected K_N: every endpoint peers with every other one.
+ */
+static void fabrictest_verify_mesh_topology(struct kunit *test,
+					    const struct fabrictest_topo_param *p,
+					    struct drm_fabric_endpoint **eps)
+{
+	struct drm_fabric_peer peer;
+	int i, j, k;
+
+	for (i = 0; i < p->n_eps; i++) {
+		int port_idx = 0;
+
+		for (j = 0; j < p->n_eps; j++) {
+			int peer_port = 0;
+
+			if (i == j)
+				continue;
+			for (k = 0; k < p->n_eps; k++) {
+				if (k == j)
+					continue;
+				if (k == i)
+					break;
+				peer_port++;
+			}
+			peer = (struct drm_fabric_peer){
+				.peer_id = eps[j]->fabric_ep_id,
+				.peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+				.port_index = peer_port,
+			};
+			drm_fabric_port_set_peer(fabrictest_port(eps[i], port_idx),
+						 &peer);
+			port_idx++;
+		}
+	}
+
+	for (i = 0; i < p->n_eps; i++)
+		KUNIT_EXPECT_EQ_MSG(test,
+				    fabrictest_peer_degree(eps[i], p->ports_per_ep),
+				    p->n_eps - 1, "mesh ep%d degree", i);
+
+	/* Spot-check reciprocity; exhaustive K_4 coverage is tested separately. */
+	for (j = 0; j < p->n_eps - 1; j++) {
+		struct drm_fabric_port *port = fabrictest_port(eps[0], j);
+		struct drm_fabric_port *back;
+		int pi, peer_ep = -1;
+
+		KUNIT_ASSERT_NOT_NULL(test, port);
+		KUNIT_ASSERT_TRUE(test, port->has_peer);
+
+		for (pi = 0; pi < p->n_eps; pi++)
+			if (eps[pi]->fabric_ep_id == port->peer.peer_id) {
+				peer_ep = pi;
+				break;
+			}
+		KUNIT_EXPECT_GE(test, peer_ep, 0);
+		if (peer_ep < 0)
+			continue;
+		back = fabrictest_port(eps[peer_ep], port->peer.port_index);
+		KUNIT_ASSERT_NOT_NULL(test, back);
+		KUNIT_EXPECT_TRUE(test, back->has_peer);
+		KUNIT_EXPECT_EQ(test, back->peer.peer_id,
+				eps[0]->fabric_ep_id);
+	}
+}
+
+/*
+ * Parameterized over fabrictest_topo_params: linear chains and full meshes at
+ * several sizes, checked generically via fabrictest_peer_degree().
+ */
+static void drm_fabric_test_topology_param(struct kunit *test)
+{
+	const struct fabrictest_topo_param *p = test->param_value;
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC, .name = "test-topo",
+	};
+	struct drm_fabric_port_desc pdescs[FT_MAX_PORTS];
+	struct drm_fabric_endpoint *eps[FT_MAX_EPS];
+	struct drm_fabric_endpoint_desc edesc;
+	struct drm_fabric *fab;
+	int i, j;
+
+	KUNIT_ASSERT_LE(test, p->n_eps, FT_MAX_EPS);
+	KUNIT_ASSERT_LE(test, p->ports_per_ep, FT_MAX_PORTS);
+
+	memset(pdescs, 0, sizeof(pdescs));
+	for (j = 0; j < p->ports_per_ep; j++) {
+		pdescs[j].index = j;
+		pdescs[j].max_lane_count = 4;
+		pdescs[j].max_lane_signaling_rate_mbps = 200000;
+	}
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	for (i = 0; i < p->n_eps; i++) {
+		memset(&edesc, 0, sizeof(edesc));
+		edesc.fabric_ep_id = 0x100 + i;
+		edesc.parent = fabrictest_dev;
+		edesc.ports = pdescs;
+		edesc.num_ports = p->ports_per_ep;
+
+		eps[i] = drm_fabric_endpoint_register(fab, &edesc);
+		KUNIT_ASSERT_FALSE(test, IS_ERR(eps[i]));
+		KUNIT_ASSERT_EQ(test, 0,
+				kunit_add_action_or_reset(test,
+							  fabrictest_unregister_endpoint, eps[i]));
+	}
+
+	if (p->shape == FT_SHAPE_LINEAR)
+		fabrictest_verify_linear_topology(test, p, eps);
+	else
+		fabrictest_verify_mesh_topology(test, p, eps);
+}
+
+static struct kunit_case drm_fabric_test_cases[] = {
+	KUNIT_CASE(drm_fabric_test_fabric_register),
+	KUNIT_CASE(drm_fabric_test_unregister_reports_removal),
+	KUNIT_CASE(drm_fabric_test_instance_id_unique),
+	KUNIT_CASE(drm_fabric_test_endpoint_register),
+	KUNIT_CASE(drm_fabric_test_endpoint_requires_fabric),
+	KUNIT_CASE(drm_fabric_test_endpoint_requires_port_array),
+	KUNIT_CASE(drm_fabric_test_endpoint_register_stale_fabric),
+	KUNIT_CASE(drm_fabric_test_unregister_rejects_non_member),
+	KUNIT_CASE(drm_fabric_test_unregister_rejects_same_id),
+	KUNIT_CASE(drm_fabric_test_ep_membership_rejects_non_member),
+	KUNIT_CASE(drm_fabric_test_ep_membership_rejects_same_id),
+	KUNIT_CASE(drm_fabric_test_port_register_peer),
+	KUNIT_CASE(drm_fabric_test_base_seq_advances),
+	KUNIT_CASE(drm_fabric_test_generation_wrap_nonzero),
+	KUNIT_CASE(drm_fabric_test_port_state_transition),
+	KUNIT_CASE(drm_fabric_test_port_oper_state_rejects_invalid),
+	KUNIT_CASE(drm_fabric_test_register_rejects_invalid_type),
+	KUNIT_CASE(drm_fabric_test_mesh_kn_topology),
+	KUNIT_CASE(drm_fabric_test_port_stats_ops_registration),
+	KUNIT_CASE(drm_fabric_test_local_unplug_keeps_edge),
+	KUNIT_CASE(drm_fabric_test_remote_peer_retained),
+	KUNIT_CASE(drm_fabric_test_subtree_delete_single_bump),
+	KUNIT_CASE(drm_fabric_test_switch_topology),
+	KUNIT_CASE_PARAM(drm_fabric_test_topology_param,
+			 fabrictest_topo_gen_params),
+	{}
+};
+
+static struct kunit_suite drm_fabric_test_suite = {
+	.name = "drm_fabric",
+	.test_cases = drm_fabric_test_cases,
+};
+
+kunit_test_suite(drm_fabric_test_suite);
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [RFC PATCH 06/12] drm/fabric: add YNL query and policy selftests
  2026-08-24  8:09 [RFC PATCH 0/12] drm/fabric: vendor-neutral topology infrastructure for scale-up accelerator interconnects Konstantin Sinyuk
                   ` (4 preceding siblings ...)
  2026-08-24  8:09 ` [RFC PATCH 05/12] drm/fabric: add object-model KUnit tests Konstantin Sinyuk
@ 2026-08-24  8:09 ` Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 07/12] drm/fabric: add topology-provisioning core Konstantin Sinyuk
                   ` (5 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Konstantin Sinyuk @ 2026-08-24  8:09 UTC (permalink / raw)
  To: dri-devel
  Cc: Maarten Lankhorst, Francois Dugast, David Airlie, Simona Vetter,
	Maxime Ripard, Thomas Zimmermann, Jonathan Corbet, Shuah Khan,
	Donald Hunter, Jakub Kicinski, David S. Miller, Eric Dumazet,
	Paolo Abeni, Simon Horman, Ilia Levi, Rodrigo Vivi, linux-doc,
	linux-kselftest, netdev, linux-kernel

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 <ksinyuk@kernel.org>
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
+
+
+@contextlib.contextmanager
+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


^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [RFC PATCH 07/12] drm/fabric: add topology-provisioning core
  2026-08-24  8:09 [RFC PATCH 0/12] drm/fabric: vendor-neutral topology infrastructure for scale-up accelerator interconnects Konstantin Sinyuk
                   ` (5 preceding siblings ...)
  2026-08-24  8:09 ` [RFC PATCH 06/12] drm/fabric: add YNL query and policy selftests Konstantin Sinyuk
@ 2026-08-24  8:09 ` Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 08/12] drm/fabric: add provisioning netlink uAPI Konstantin Sinyuk
                   ` (4 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Konstantin Sinyuk @ 2026-08-24  8:09 UTC (permalink / raw)
  To: dri-devel
  Cc: Maarten Lankhorst, Francois Dugast, David Airlie, Simona Vetter,
	Maxime Ripard, Thomas Zimmermann, Jonathan Corbet, Shuah Khan,
	Donald Hunter, Jakub Kicinski, David S. Miller, Eric Dumazet,
	Paolo Abeni, Simon Horman, Ilia Levi, Rodrigo Vivi, linux-doc,
	linux-kselftest, netdev, linux-kernel

From: Ilia Levi <ilia.levi@intel.com>

Everything so far reports topology that a provider composed. Composing it
from userspace needs the core to create an empty fabric, move an endpoint
into or out of one, change administrative state and record a peer. None of
that may leave the object model half-updated.

Add the core mechanisms without exposing provisioning netlink operations.
Deleting a provider-owned or non-empty fabric is refused.

Provider callbacks may sleep and therefore run without drm_fabric_lock.
drm_fabric_mutation_lock serialises validation, the provider callback and
the core update, with targets pinned across the unlocked callback.
Registration and teardown join the same transaction domain, so a successful
callback cannot be invalidated before commit.

Administrative intent stays separate from operational state, and each port
selects one peer writer at registration. A request that already matches
committed state succeeds without invoking the provider.

Registering an orphan endpoint is now valid, so the KUnit case asserting
that a NULL fabric is rejected is repurposed to assert that it yields an
orphan reporting fabric-id 0.

Signed-off-by: Ilia Levi <ilia.levi@intel.com>
Co-developed-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Signed-off-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Assisted-by: GitHub-Copilot:claude-opus-4.8
---
 Documentation/gpu/drm-fabric.rst             |  93 ++--
 drivers/gpu/drm/fabric/drm_fabric.c          | 554 +++++++++++++++----
 drivers/gpu/drm/fabric/drm_fabric_internal.h |  13 +
 drivers/gpu/drm/fabric/drm_fabric_test.c     |  17 +-
 include/drm/drm_fabric.h                     | 108 +++-
 5 files changed, 637 insertions(+), 148 deletions(-)

diff --git a/Documentation/gpu/drm-fabric.rst b/Documentation/gpu/drm-fabric.rst
index bc7b87c766cc..61e2c18bf0b5 100644
--- a/Documentation/gpu/drm-fabric.rst
+++ b/Documentation/gpu/drm-fabric.rst
@@ -18,6 +18,8 @@ Key Goals:
   (xGMI, UALink and similar), enabling data-center discovery and monitoring.
 * Support read-only enumeration, monitoring and state queries for
   provider-owned topology.
+* Support topology provisioning as an optional provider capability, covering
+  fabric and endpoint lifecycle, port administration and peer management.
 * Allow new attributes and fabric types to be added without reusing existing
   wire identifiers, so the uAPI extends without breaking existing consumers.
 * Allow multiple endpoints and ports per provider, so drivers can model
@@ -56,13 +58,14 @@ provider reports: for example, it may be a full mesh with no root, a linear
 chain, or a switch-based topology in which ports terminate at opaque switch
 peers rather than locally registered endpoints.
 
-A *peer* is a value descriptor, not a reference to a live kernel object: its
-``peer-id`` may name a remote accelerator managed by another OS or an opaque
-switch in another trust domain, and need not resolve in the local registry. The
-core stores one directed half-edge and does not require the reverse half-edge to
-exist, so removing an endpoint does not retract peer descriptors held by other
-endpoints. ``peer-type = switch`` only describes the kind of far end; it does
-not create a first-class switch object.
+An endpoint may be registered without a fabric. Such an endpoint is an *orphan*
+and reports ``fabric-id`` 0. A *peer* is a value descriptor, not a reference to
+a live kernel object: its ``peer-id`` may name a remote accelerator managed by
+another OS or an opaque switch in another trust domain, and need not resolve in
+the local registry. The core stores one directed half-edge and does not require
+the reverse half-edge to exist, so removing an endpoint does not retract peer
+descriptors held by other endpoints. ``peer-type = switch`` only describes the
+kind of far end; it does not create a first-class switch object.
 
 .. kernel-doc:: drivers/gpu/drm/fabric/drm_fabric.c
    :doc: DRM Fabric core
@@ -79,10 +82,10 @@ that half-edge. A peer is therefore topology as last set, not proof of live
 connectivity; liveness belongs to the fabric controller.
 
 The core never retracts a half-edge on its own. Failing to resolve a peer
-locally is not the same as the link going away -- the far end may be a switch,
-an accelerator on another node, or a local endpoint that merely unregistered
--- so only the provider knows when a port's physical adjacency actually
-changed, and only the provider retracts or replaces the descriptor.
+locally is not the same as the link going away -- the far end may be a
+switch, an accelerator on another node, or a local endpoint that merely
+unregistered -- so only the provider knows when a port's physical adjacency
+actually changed, and only the provider retracts or replaces the descriptor.
 
 ``port-peer-delete-ntf`` reports an explicitly retracted half-edge; it is
 not emitted when a peer merely becomes locally unresolvable, so its absence
@@ -105,11 +108,15 @@ Driver API
 Design scope and boundaries
 ===========================
 
-Vendor drivers retain hardware discovery, firmware interaction and the
-load/store data path; DRM Fabric represents only the topology and
-provider-reported state of DRM-managed accelerators, which is why it
-belongs in DRM. The interface does not define MMU programming, switch
-policy, key management, live migration, or any required user space daemon.
+The core records direct adjacency only, not end-to-end reachability or switch
+forwarding, which remain with the fabric controller. Vendor drivers retain
+hardware discovery, firmware interaction, memory semantics and the
+hardware-carried data path; DRM Fabric represents only the topology and
+control state of DRM-managed accelerators, which is why it belongs in DRM.
+It does not create a network device or own route computation, switch
+forwarding, transport or congestion control. The interface also does not
+define MMU programming, switch policy, key management, live migration, or
+any required user space daemon.
 
 DRM Fabric does not define in-network collective operations or how an
 endpoint or switch executes them. Such capabilities belong to the
@@ -123,27 +130,49 @@ strengthens the contract rather than breaking it.
 Object lifetime and locking
 ===========================
 
-All registry and object state is protected by ``drm_fabric_lock``. A fabric and
-its endpoints are created and torn down through the provider API; endpoint
-unregister removes the endpoint from the registry and frees its fixed set of
-ports. Fabric membership is tracked, so a provider must remove all member
-endpoints before unregistering a provider-owned fabric: drm_fabric_unregister()
-returns ``-EBUSY`` and leaves the fabric registered if any remain, so the
-provider must retry after removing them rather than treat the fabric as gone.
+Registry membership and object state are protected by ``drm_fabric_lock``;
+topology mutation is additionally serialised by ``drm_fabric_mutation_lock``,
+described below. A fabric and its endpoints are created and torn down through
+the provider API; endpoint unregister removes the endpoint from the registry
+and frees its fixed set of ports. Fabric membership is tracked, so a provider
+must remove all member endpoints before unregistering a provider-owned fabric:
+drm_fabric_unregister() returns ``-EBUSY`` and leaves the fabric registered if
+any remain, so the provider must retry after removing them rather than treat
+the fabric as gone.
 
 Objects are reference counted and a port is pinned through its owning endpoint.
 Endpoint unregister drops the registration reference and waits for outstanding
 pins before freeing the ports; fabric membership holds a fabric reference.
 
-Providers own object lifetime, so a provider must serialise endpoint
-registration against unregistration of the containing fabric. The unregister
-entry points compare the supplied pointer against the registry before
-dereferencing it, so a stale or repeated teardown is rejected: fabric
-unregistration returns ``-ENODEV``, and endpoint unregistration, having no
-error return, warns and performs no teardown. Endpoint registration rejects a
-departed parent the same way. These checks prove current address membership
-only: they cannot tell an earlier incarnation from another object registered
-later at the same address.
+Providers own object lifetime. The unregister entry points compare the supplied
+pointer against the registry before dereferencing it, so a stale or repeated
+teardown is rejected: fabric unregistration returns ``-ENODEV``, and endpoint
+unregistration, having no error return, warns and performs no teardown.
+Endpoint registration rejects a departed parent the same way. These checks
+prove current address membership only: they cannot tell an earlier incarnation
+from another object registered later at the same address. Only the provider
+knows its own object lifecycle.
+
+Netlink mutation commands additionally hold ``drm_fabric_mutation_lock`` across
+target resolution, the provider callback and the core commit. The lock order
+is::
+
+    drm_fabric_mutation_lock -> drm_fabric_lock
+
+Provider lifecycle operations that can invalidate a prepared mutation take the
+same mutation lock: endpoint registration as well as endpoint and fabric
+unregister. A provider-driven teardown therefore cannot race an in-flight
+mutation on the same object, and a registration cannot claim a fabric-scoped
+``fabric-ep-id`` after an attach has validated the identifier but before its
+provider callback completes. A provider must not invoke any of these lifecycle
+operations from one of its own mutation callbacks: the callback already holds
+the mutation lock, so the nested acquisition would self-deadlock. A successful
+callback therefore cannot be invalidated before commit, and the post-callback
+checks are invariant assertions only.
+
+A fabric records whether it was created by a provider or by userspace. Only an
+empty userspace-created fabric may be deleted through the provisioning core;
+provider-owned and non-empty fabrics are rejected.
 
 Generic Netlink family
 ======================
diff --git a/drivers/gpu/drm/fabric/drm_fabric.c b/drivers/gpu/drm/fabric/drm_fabric.c
index bbc3224c3314..1aae5ff68798 100644
--- a/drivers/gpu/drm/fabric/drm_fabric.c
+++ b/drivers/gpu/drm/fabric/drm_fabric.c
@@ -25,14 +25,23 @@
  * drm_fabric_port_unset_peer().
  *
  * The core owns object identity and lifetime: it assigns kernel-local IDs,
- * refcounts objects, serialises access under its internal lock, and advances a
- * topology generation on every change so a concurrent netlink dump can detect
- * a torn snapshot.
+ * refcounts objects, serialises object state under drm_fabric_lock and
+ * topology mutation under drm_fabric_mutation_lock, and advances a topology
+ * generation on every change so a concurrent netlink dump can detect a torn
+ * snapshot. See Documentation/gpu/drm-fabric.rst for the fuller object
+ * lifetime and locking treatment.
  */
 
 /* Global lock for all fabric, endpoint and port state. */
 DEFINE_MUTEX(drm_fabric_lock);
 
+/*
+ * Lock order is mutation_lock -> drm_fabric_lock; a provider callback already
+ * holds this and must not re-enter registration/unregistration.
+ */
+DEFINE_MUTEX(drm_fabric_mutation_lock);
+
+/* ID 0 is the orphan sentinel, so allocated IDs start at 1. */
 DEFINE_XARRAY_ALLOC1(drm_fabric_xa);
 DEFINE_XARRAY_ALLOC(drm_fabric_ep_xa);
 
@@ -101,10 +110,8 @@ drm_fabric_endpoint_find_by_dev_name(const char *devname, const char *busname)
 }
 
 /*
- * Returns true if a member of @fabric already uses @fabric_ep_id. fabric_ep_id
- * is the accelerator's identity within a fabric and is what a peer descriptor
- * names (peer_id for DRM_FABRIC_PEER_TYPE_ACCEL), so it must be unique per
- * fabric or peer resolution is ambiguous.
+ * fabric_ep_id must be unique per fabric or peer resolution is ambiguous;
+ * orphans (@fabric == NULL) do not participate.
  */
 static bool drm_fabric_ep_id_in_use(const struct drm_fabric *fabric, u64 fabric_ep_id)
 {
@@ -113,6 +120,9 @@ static bool drm_fabric_ep_id_in_use(const struct drm_fabric *fabric, u64 fabric_
 
 	lockdep_assert_held(&drm_fabric_lock);
 
+	if (!fabric)
+		return false;
+
 	xa_for_each(&drm_fabric_ep_xa, idx, ep)
 		if (ep->fabric == fabric &&
 		    ep->fabric_ep_id == fabric_ep_id)
@@ -136,40 +146,20 @@ static bool drm_fabric_has_instance(const struct drm_fabric *fabric)
 	return false;
 }
 
-static bool drm_fabric_type_valid(enum drm_fabric_type type)
-{
-	switch (type) {
-	case DRM_FABRIC_TYPE_SYNTHETIC:
-		return true;
-	}
-
-	return false;
-}
-
-/**
- * drm_fabric_register() - Register a new fabric
- * @desc: fabric description (type, instance id, name)
- *
- * Allocates the fabric and inserts it into the registry as provider-owned.
- * Rejects zero and out-of-range types before allocating.
- *
- * Context: May sleep. Acquires drm_fabric_lock.
- * Return: the registered fabric, or an ERR_PTR() on failure, -EINVAL for a
- *         type this kernel does not define.
- */
-struct drm_fabric *drm_fabric_register(const struct drm_fabric_desc *desc)
+/* @owner is set once here, before publication, so no reader needs a lock for it. */
+static struct drm_fabric *
+__drm_fabric_register(const struct drm_fabric_desc *desc,
+		      enum drm_fabric_owner owner)
 {
 	struct drm_fabric *fabric __free(kfree) = NULL;
 
-	if (WARN_ON_ONCE(!drm_fabric_type_valid(desc->type)))
-		return ERR_PTR(-EINVAL);
-
 	fabric = kzalloc_obj(*fabric);
 	if (!fabric)
 		return ERR_PTR(-ENOMEM);
 
 	fabric->type = desc->type;
 	fabric->instance_id = desc->instance_id;
+	fabric->owner = owner;
 	refcount_set(&fabric->refs, 1);
 
 	if (desc->name &&
@@ -191,6 +181,35 @@ struct drm_fabric *drm_fabric_register(const struct drm_fabric_desc *desc)
 
 	return_ptr(fabric);
 }
+
+static bool drm_fabric_type_valid(enum drm_fabric_type type)
+{
+	switch (type) {
+	case DRM_FABRIC_TYPE_SYNTHETIC:
+		return true;
+	}
+
+	return false;
+}
+
+/**
+ * drm_fabric_register() - Register a new fabric
+ * @desc: fabric description (type, instance id, name)
+ *
+ * Allocates the fabric and inserts it into the registry as provider-owned.
+ * Rejects zero and out-of-range types before allocating.
+ *
+ * Context: May sleep. Acquires drm_fabric_lock.
+ * Return: the registered fabric, or an ERR_PTR() on failure, -EINVAL for a
+ *         type this kernel does not define.
+ */
+struct drm_fabric *drm_fabric_register(const struct drm_fabric_desc *desc)
+{
+	if (WARN_ON_ONCE(!drm_fabric_type_valid(desc->type)))
+		return ERR_PTR(-EINVAL);
+
+	return __drm_fabric_register(desc, DRM_FABRIC_OWNER_PROVIDER);
+}
 EXPORT_SYMBOL(drm_fabric_register);
 
 struct drm_fabric *drm_fabric_get(struct drm_fabric *fabric)
@@ -253,27 +272,40 @@ static bool drm_fabric_is_registered(const struct drm_fabric *fabric)
  * from another fabric registered later at the same address, which remains
  * the provider's obligation.
  *
- * Context: May sleep. Acquires drm_fabric_lock.
- * Return: 0 on removal. -ENODEV if @fabric is not registered, checked first.
- *         -EBUSY if members remain; the fabric stays registered and the
- *         provider must remove them before retrying. -EBUSY also warns
- *         because this is a teardown-ordering bug.
+ * Context: May sleep. Acquires drm_fabric_mutation_lock, then drm_fabric_lock.
+ *          Must not be called from a provider mutation callback (endpoint_set /
+ *          port_set / port_peer_*), which already holds the mutation lock and
+ *          would self-deadlock.
+ * Return: 0 once the fabric is removed. -ENODEV if @fabric is not currently
+ *         registered, which takes precedence over the member check. -EBUSY
+ *         if member endpoints remain, in which case the fabric stays fully
+ *         registered and the provider must unregister the members before
+ *         retrying -- a non-empty unregister is a provider teardown-ordering
+ *         bug, so it also warns.
  */
 int drm_fabric_unregister(struct drm_fabric *fabric)
 {
-	scoped_guard(mutex, &drm_fabric_lock) {
-		if (!drm_fabric_is_registered(fabric))
-			return -ENODEV;
-		/*
-		 * Members still reference ep->fabric; freeing it here would
-		 * leave stale pointers.
-		 */
-		if (WARN_ON_ONCE(drm_fabric_has_members(fabric)))
-			return -EBUSY;
-		/* Emit before the erase, while @fabric is still live. */
-		drm_fabric_emit_fabric_delete(fabric,
-					      drm_fabric_base_seq_inc());
-		xa_erase(&drm_fabric_xa, fabric->id);
+	/*
+	 * The mutation lock below blocks a racing unregister or endpoint_set attach
+	 * on this fabric.
+	 */
+	lockdep_assert_not_held(&drm_fabric_mutation_lock);
+
+	scoped_guard(mutex, &drm_fabric_mutation_lock) {
+		scoped_guard(mutex, &drm_fabric_lock) {
+			if (!drm_fabric_is_registered(fabric))
+				return -ENODEV;
+			/*
+			 * Members still reference ep->fabric; freeing it here
+			 * would leave stale pointers.
+			 */
+			if (WARN_ON_ONCE(drm_fabric_has_members(fabric)))
+				return -EBUSY;
+			/* Emit before the erase, while @fabric is still live. */
+			drm_fabric_emit_fabric_delete(fabric,
+						      drm_fabric_base_seq_inc());
+			xa_erase(&drm_fabric_xa, fabric->id);
+		}
 	}
 
 	drm_fabric_put(fabric);
@@ -314,6 +346,8 @@ static int drm_fabric_ports_create(struct drm_fabric_endpoint *ep,
 		port->max_lane_signaling_rate_mbps =
 			descs[i].max_lane_signaling_rate_mbps;
 		port->oper_state = DRM_FABRIC_PORT_STATE_UNKNOWN;
+		port->admin_state = DRM_FABRIC_ADMIN_STATE_DOWN;
+		port->peer_mode = descs[i].peer_mode;
 		port->has_peer = false;
 		port->endpoint = ep;
 
@@ -333,23 +367,30 @@ static int drm_fabric_ports_create(struct drm_fabric_endpoint *ep,
 }
 
 /**
- * drm_fabric_endpoint_register() - Register a provider-owned endpoint
- * @fabric: non-NULL fabric the endpoint belongs to
+ * drm_fabric_endpoint_register() - Register an endpoint
+ * @fabric: fabric the endpoint belongs to
  * @desc: endpoint description, including its fixed set of ports
  *
- * Registers @desc as a member of @fabric and advances the topology generation.
- * @desc->fabric_ep_id must be unique among the fabric's registered endpoints.
+ * Registers the endpoint and emits an ENDPOINT_CREATE event. When the endpoint
+ * joins a fabric its desc->fabric_ep_id must be unique among that fabric's
+ * members; a duplicate is rejected with -EEXIST.
  *
- * The provider must serialise this call against drm_fabric_unregister() of
- * @fabric. A fabric that has already left the registry is rejected, but that
- * check matches on address and cannot distinguish incarnations; only the
- * provider knows its own object lifecycle.
+ * A NULL @fabric registers an orphan endpoint, which a later ENDPOINT_SET
+ * attach can join to a fabric.
  *
- * Context: May sleep. Acquires drm_fabric_lock.
+ * A fabric that has already left the registry is rejected, but that check
+ * matches on address and cannot distinguish incarnations; only the provider
+ * knows its own object lifecycle.
+ *
+ * Publication joins the mutation transaction domain
+ * (drm_fabric_mutation_lock -> drm_fabric_lock), so a provider must not call
+ * this from within a mutation callback (that would self-deadlock).
+ *
+ * Context: May sleep. Acquires drm_fabric_mutation_lock, then drm_fabric_lock.
  * Return: the registered endpoint, or an ERR_PTR() on failure: -EINVAL if
- * @fabric or @desc->parent is NULL, or @desc claims ports without supplying a
- * port array, -ENODEV if @fabric is no longer registered, -EEXIST if
- * @desc->fabric_ep_id is already in use within @fabric.
+ * @desc->parent is NULL, or @desc claims ports without supplying a port array,
+ * -ENODEV if @fabric is no longer registered, -EEXIST if @desc->fabric_ep_id is
+ * already in use within @fabric.
  */
 struct drm_fabric_endpoint *
 drm_fabric_endpoint_register(struct drm_fabric *fabric,
@@ -358,8 +399,7 @@ drm_fabric_endpoint_register(struct drm_fabric *fabric,
 	struct drm_fabric_endpoint *ep __free(kfree) = NULL;
 	int ret;
 
-	if (!fabric)
-		return ERR_PTR(-EINVAL);
+	lockdep_assert_not_held(&drm_fabric_mutation_lock);
 
 	/* Supplies dev_name()/bus for the query paths; pinned below. */
 	if (!desc->parent)
@@ -377,6 +417,7 @@ drm_fabric_endpoint_register(struct drm_fabric *fabric,
 	ep->parent = desc->parent;
 	ep->ops = desc->ops;
 	ep->priv = desc->priv;
+	ep->admin_state = fabric ? DRM_FABRIC_ADMIN_STATE_UP : DRM_FABRIC_ADMIN_STATE_DOWN;
 	refcount_set(&ep->refs, 1);
 	init_completion(&ep->unregistered);
 	xa_init(&ep->ports);
@@ -391,28 +432,31 @@ drm_fabric_endpoint_register(struct drm_fabric *fabric,
 
 	get_device(ep->parent);
 
-	scoped_guard(mutex, &drm_fabric_lock) {
-		/*
-		 * A concurrent drm_fabric_unregister() may have freed @fabric
-		 * since the caller passed it, so validate membership by address
-		 * without dereferencing it.
-		 */
-		if (!drm_fabric_is_registered(fabric)) {
-			ret = -ENODEV;
-			break;
-		}
-
-		if (drm_fabric_ep_id_in_use(fabric, ep->fabric_ep_id)) {
-			ret = -EEXIST;
-			break;
+	ret = 0;
+	/*
+	 * Under the mutation lock a concurrent ENDPOINT_SET attach cannot also
+	 * claim this (fabric, fabric_ep_id).
+	 */
+	scoped_guard(mutex, &drm_fabric_mutation_lock) {
+		scoped_guard(mutex, &drm_fabric_lock) {
+			if (fabric && !drm_fabric_is_registered(fabric)) {
+				ret = -ENODEV;
+				break;
+			}
+
+			if (drm_fabric_ep_id_in_use(fabric, ep->fabric_ep_id)) {
+				ret = -EEXIST;
+				break;
+			}
+
+			ret = xa_alloc(&drm_fabric_ep_xa, &ep->id, ep, xa_limit_32b, GFP_KERNEL);
+			if (ret)
+				break;
+			if (fabric)
+				drm_fabric_get(fabric);
+
+			drm_fabric_emit_endpoint_create(ep, drm_fabric_base_seq_inc());
 		}
-
-		ret = xa_alloc(&drm_fabric_ep_xa, &ep->id, ep, xa_limit_32b, GFP_KERNEL);
-		if (ret)
-			break;
-		drm_fabric_get(fabric);
-
-		drm_fabric_emit_endpoint_create(ep, drm_fabric_base_seq_inc());
 	}
 
 	if (ret) {
@@ -494,26 +538,36 @@ static bool drm_fabric_ep_is_registered(const struct drm_fabric_endpoint *ep)
  * and performs no teardown; there is no error return to report it. As for a
  * fabric, the comparison proves current address membership only.
  *
- * Context: May sleep. Acquires drm_fabric_lock.
+ * Context: May sleep. Acquires drm_fabric_mutation_lock, then drm_fabric_lock.
+ *          Must not be called from a provider mutation callback, which already
+ *          holds the mutation lock and would self-deadlock.
  */
 void drm_fabric_endpoint_unregister(struct drm_fabric_endpoint *ep)
 {
-	scoped_guard(mutex, &drm_fabric_lock) {
-		if (WARN_ON_ONCE(!drm_fabric_ep_is_registered(ep)))
-			return;
-		/* Emit before the erase, while @ep is still live. */
-		drm_fabric_emit_endpoint_delete(ep, drm_fabric_base_seq_inc());
-		xa_erase(&drm_fabric_ep_xa, ep->id);
-	}
+	lockdep_assert_not_held(&drm_fabric_mutation_lock);
 
 	/*
-	 * Drop the registration reference and wait for any in-flight netlink
-	 * operation that pinned the endpoint to complete.
+	 * Blocks a racing mutator: @ep cannot resolve once erased, so no
+	 * ENDPOINT_CHANGE follows.
 	 */
+	scoped_guard(mutex, &drm_fabric_mutation_lock) {
+		scoped_guard(mutex, &drm_fabric_lock) {
+			if (WARN_ON_ONCE(!drm_fabric_ep_is_registered(ep)))
+				return;
+			/* Emit before the erase, while @ep is still live. */
+			drm_fabric_emit_endpoint_delete(ep,
+							drm_fabric_base_seq_inc());
+			xa_erase(&drm_fabric_ep_xa, ep->id);
+		}
+	}
+
+	/* Wait for in-flight operations holding endpoint pins. */
 	drm_fabric_endpoint_put(ep);
 	wait_for_completion(&ep->unregistered);
 
-	drm_fabric_put(ep->fabric);
+	/* An orphan endpoint holds no fabric reference. */
+	if (ep->fabric)
+		drm_fabric_put(ep->fabric);
 
 	drm_fabric_ports_destroy(ep);
 	put_device(ep->parent);
@@ -572,16 +626,25 @@ static bool drm_fabric_peer_type_valid(enum drm_fabric_peer_type type)
  * @port: local port
  * @peer: descriptor of the endpoint on the other end
  *
- * Sets the peer and advances the topology generation on success.
+ * Emits a PORT_PEER_NEW event with new peer details on success. Only valid on a
+ * provider-managed port; a userspace-managed port (%DRM_FABRIC_PEER_MODE_USERSPACE)
+ * is programmed through the PORT_PEER_NEW uAPI instead.
  *
  * Context: May sleep. Acquires drm_fabric_lock.
- * Return: -EINVAL if @peer carries an unknown peer type, -EEXIST if the port
- * already has a peer. 0 on success.
+ * Return: -EOPNOTSUPP on a userspace-managed port, -EINVAL if @peer carries an
+ * unknown peer type, -EEXIST if the port already has a peer, 0 on success.
  */
 int drm_fabric_port_set_peer(struct drm_fabric_port *port,
 			     const struct drm_fabric_peer *peer)
 {
-	/* Reject a provider's invalid type before it reaches the wire. */
+	if (port->peer_mode != DRM_FABRIC_PEER_MODE_PROVIDER)
+		return -EOPNOTSUPP;
+
+	/*
+	 * An unknown type is a provider bug, and the netlink path serialises
+	 * the value verbatim into an enum-typed attribute, so refusing it here
+	 * keeps it off the wire.
+	 */
 	if (WARN_ON_ONCE(!drm_fabric_peer_type_valid(peer->peer_type)))
 		return -EINVAL;
 
@@ -602,17 +665,21 @@ EXPORT_SYMBOL(drm_fabric_port_set_peer);
  * drm_fabric_port_unset_peer() - Remove a neighbor (called by the provider)
  * @port: local port
  *
- * Inverse of drm_fabric_port_set_peer().
- * Removes the peer and advances the topology generation on success.
+ * Inverse of drm_fabric_port_set_peer(). Only valid on a provider-managed port.
+ * Emits a PORT_PEER_DEL event carrying the removed peer on success.
  *
  * Edge retraction is always explicit (provider- or controller-driven); the
  * core never removes a peer implicitly.
  *
  * Context: May sleep. Acquires drm_fabric_lock.
- * Return: -ENOENT if no peer set. 0 on success.
+ * Return: -EOPNOTSUPP on a userspace-managed port, -ENOENT if no peer set,
+ * 0 on success.
  */
 int drm_fabric_port_unset_peer(struct drm_fabric_port *port)
 {
+	if (port->peer_mode != DRM_FABRIC_PEER_MODE_PROVIDER)
+		return -EOPNOTSUPP;
+
 	scoped_guard(mutex, &drm_fabric_lock) {
 		if (!port->has_peer)
 			return -ENOENT;
@@ -669,6 +736,287 @@ void drm_fabric_port_set_oper(struct drm_fabric_port *port,
 }
 EXPORT_SYMBOL(drm_fabric_port_set_oper);
 
+/* FABRIC_NEW yields an empty fabric; endpoints join only via ENDPOINT_SET. */
+int drm_fabric_user_fabric_new(enum drm_fabric_type type, u64 instance_id,
+			       const char *name, u32 *fabric_id_out)
+{
+	struct drm_fabric_desc desc = {
+		.type = type,
+		.instance_id = instance_id,
+		.name = name,
+	};
+	struct drm_fabric *fabric;
+
+	lockdep_assert_held(&drm_fabric_mutation_lock);
+	lockdep_assert_not_held(&drm_fabric_lock);
+
+	/* Userspace-supplied: reject without warning. */
+	if (!drm_fabric_type_valid(type))
+		return -EINVAL;
+
+	/*
+	 * A userspace-owned fabric outlives this call -- only FABRIC_DEL
+	 * removes it -- so pin the module.
+	 */
+	if (!try_module_get(THIS_MODULE))
+		return -ENODEV;
+
+	fabric = __drm_fabric_register(&desc, DRM_FABRIC_OWNER_USERSPACE);
+	if (IS_ERR(fabric)) {
+		module_put(THIS_MODULE);
+		return PTR_ERR(fabric);
+	}
+
+	if (fabric_id_out)
+		*fabric_id_out = fabric->id;
+
+	return 0;
+}
+
+int drm_fabric_user_fabric_del(u32 fabric_id)
+{
+	struct drm_fabric *fabric;
+
+	lockdep_assert_held(&drm_fabric_mutation_lock);
+	lockdep_assert_not_held(&drm_fabric_lock);
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		fabric = xa_load(&drm_fabric_xa, fabric_id);
+		if (!fabric)
+			return -ENOENT;
+		/*
+		 * Userspace-owned fabrics only: a provider's registration pointer
+		 * would dangle.
+		 */
+		if (fabric->owner != DRM_FABRIC_OWNER_USERSPACE)
+			return -EPERM;
+		/* Symmetric with FABRIC_NEW: only an empty fabric may be removed. */
+		if (drm_fabric_has_members(fabric))
+			return -EBUSY;
+		drm_fabric_base_seq_inc();
+		xa_erase(&drm_fabric_xa, fabric->id);
+	}
+
+	/* Release the pin from FABRIC_NEW. */
+	module_put(THIS_MODULE);
+	drm_fabric_put(fabric);
+	return 0;
+}
+
+/* Pins the fabric for use after the lock is dropped, or ERR_PTR(-ENOENT). */
+static struct drm_fabric *drm_fabric_find_get_locked(u32 fabric_id)
+{
+	struct drm_fabric *fabric;
+
+	fabric = drm_fabric_find_by_id(fabric_id);
+	if (!fabric)
+		return ERR_PTR(-ENOENT);
+
+	return drm_fabric_get(fabric);
+}
+
+/*
+ * Caller holds mutation_lock throughout. The target is resolved and pinned
+ * before drm_fabric_lock is dropped for the sleeping callback.
+ */
+int drm_fabric_endpoint_set(struct drm_fabric_endpoint *ep,
+			    const struct drm_fabric_endpoint_change *req)
+{
+	struct drm_fabric_endpoint_change change = *req;
+	const struct drm_fabric_ops *ops = ep->ops;
+	struct drm_fabric *new_fabric = NULL;
+	int ret;
+
+	if (!ops || !ops->endpoint_set)
+		return -EOPNOTSUPP;
+
+	lockdep_assert_held(&drm_fabric_mutation_lock);
+	lockdep_assert_not_held(&drm_fabric_lock);
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		/* No-op administrative request. */
+		if ((change.valid & DRM_FABRIC_EP_CHANGE_ADMIN) &&
+		    change.admin == ep->admin_state)
+			change.valid &= ~DRM_FABRIC_EP_CHANGE_ADMIN;
+
+		if (!change.valid)
+			return 0;
+
+		if (!(change.valid & DRM_FABRIC_EP_CHANGE_FABRIC))
+			break;
+
+		if (change.fabric_id == drm_fabric_endpoint_fabric_id(ep)) {
+			change.valid &= ~DRM_FABRIC_EP_CHANGE_FABRIC;
+			if (!change.valid)
+				return 0;
+			break;
+		}
+
+		/* Detach */
+		if (!change.fabric_id)
+			break;
+
+		/* Attach: endpoint must be orphaned. */
+		if (ep->fabric)
+			return -EBUSY;
+
+		new_fabric = drm_fabric_find_get_locked(change.fabric_id);
+		if (IS_ERR(new_fabric))
+			return PTR_ERR(new_fabric);
+
+		/* Reject early if the target fabric already uses this id. */
+		if (drm_fabric_ep_id_in_use(new_fabric, ep->fabric_ep_id)) {
+			drm_fabric_put(new_fabric);
+			return -EEXIST;
+		}
+	}
+
+	ret = ops->endpoint_set(ep, &change, new_fabric);
+	if (ret) {
+		if (new_fabric)
+			drm_fabric_put(new_fabric);
+		return ret;
+	}
+
+	/*
+	 * mutation_lock rules out a race; the WARN_ON_ONCE rechecks below are
+	 * assertions only.
+	 */
+	scoped_guard(mutex, &drm_fabric_lock) {
+		if (change.valid & DRM_FABRIC_EP_CHANGE_FABRIC) {
+			if (new_fabric) {
+				if (WARN_ON_ONCE(xa_load(&drm_fabric_xa, new_fabric->id) !=
+						 new_fabric)) {
+					drm_fabric_put(new_fabric);
+					return -ENODEV;
+				}
+				if (WARN_ON_ONCE(drm_fabric_ep_id_in_use(new_fabric,
+									 ep->fabric_ep_id))) {
+					drm_fabric_put(new_fabric);
+					return -EEXIST;
+				}
+				ep->fabric = new_fabric;
+			} else {
+				/* Reached only for an attached endpoint. */
+				drm_fabric_put(ep->fabric);
+				ep->fabric = NULL;
+			}
+		}
+
+		if (change.valid & DRM_FABRIC_EP_CHANGE_ADMIN)
+			ep->admin_state = change.admin;
+
+		drm_fabric_base_seq_inc();
+	}
+
+	return 0;
+}
+
+/*
+ * No revalidation is needed: the port stays pinned and admin_state has no
+ * out-of-band writer.
+ */
+int drm_fabric_port_set_admin(struct drm_fabric_port *port,
+			      enum drm_fabric_admin_state admin)
+{
+	const struct drm_fabric_ops *ops = port->endpoint->ops;
+	int ret;
+
+	if (!ops || !ops->port_set)
+		return -EOPNOTSUPP;
+
+	lockdep_assert_held(&drm_fabric_mutation_lock);
+	lockdep_assert_not_held(&drm_fabric_lock);
+
+	/* No-op administrative request. */
+	scoped_guard(mutex, &drm_fabric_lock)
+		if (port->admin_state == admin)
+			return 0;
+
+	ret = ops->port_set(port, admin);
+	if (ret)
+		return ret;
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		port->admin_state = admin;
+		drm_fabric_emit_port_change(port, drm_fabric_base_seq_inc());
+	}
+
+	return 0;
+}
+
+/*
+ * No revalidation is needed: the port stays pinned and has_peer has no
+ * out-of-band writer.
+ */
+int drm_fabric_port_peer_new(struct drm_fabric_port *port,
+			     const struct drm_fabric_peer *peer)
+{
+	const struct drm_fabric_ops *ops = port->endpoint->ops;
+	int ret;
+
+	if (port->peer_mode != DRM_FABRIC_PEER_MODE_USERSPACE)
+		return -EOPNOTSUPP;
+
+	if (!ops || !ops->port_peer_new)
+		return -EOPNOTSUPP;
+
+	lockdep_assert_held(&drm_fabric_mutation_lock);
+	lockdep_assert_not_held(&drm_fabric_lock);
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		if (port->has_peer)
+			return -EEXIST;
+	}
+
+	ret = ops->port_peer_new(port, peer);
+	if (ret)
+		return ret;
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		port->peer = *peer;
+		port->has_peer = true;
+		drm_fabric_emit_port_peer_create(port, peer,
+						 drm_fabric_base_seq_inc());
+	}
+
+	return 0;
+}
+
+/* Same userspace-managed contract as drm_fabric_port_peer_new(). */
+int drm_fabric_port_peer_del(struct drm_fabric_port *port)
+{
+	const struct drm_fabric_ops *ops = port->endpoint->ops;
+	int ret;
+
+	if (port->peer_mode != DRM_FABRIC_PEER_MODE_USERSPACE)
+		return -EOPNOTSUPP;
+
+	if (!ops || !ops->port_peer_del)
+		return -EOPNOTSUPP;
+
+	lockdep_assert_held(&drm_fabric_mutation_lock);
+	lockdep_assert_not_held(&drm_fabric_lock);
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		if (!port->has_peer)
+			return -ENOENT;
+	}
+
+	ret = ops->port_peer_del(port);
+	if (ret)
+		return ret;
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		drm_fabric_emit_port_peer_delete(port, &port->peer,
+						 drm_fabric_base_seq_inc());
+		port->has_peer = false;
+		memset(&port->peer, 0, sizeof(port->peer));
+	}
+
+	return 0;
+}
+
 static int __init drm_fabric_init(void)
 {
 	return drm_fabric_netlink_register();
diff --git a/drivers/gpu/drm/fabric/drm_fabric_internal.h b/drivers/gpu/drm/fabric/drm_fabric_internal.h
index 7fc5bc3f33f9..dc95b9967b64 100644
--- a/drivers/gpu/drm/fabric/drm_fabric_internal.h
+++ b/drivers/gpu/drm/fabric/drm_fabric_internal.h
@@ -13,6 +13,7 @@
 #include <drm/drm_fabric.h>
 
 extern struct mutex drm_fabric_lock;
+extern struct mutex drm_fabric_mutation_lock;
 extern struct xarray drm_fabric_xa;    /* Fabric registry */
 extern struct xarray drm_fabric_ep_xa; /* Endpoint registry */
 
@@ -34,9 +35,21 @@ void drm_fabric_endpoint_put(struct drm_fabric_endpoint *ep);
 struct drm_fabric *drm_fabric_get(struct drm_fabric *fabric);
 void drm_fabric_put(struct drm_fabric *fabric);
 
+int drm_fabric_user_fabric_new(enum drm_fabric_type type, u64 instance_id,
+			       const char *name, u32 *fabric_id_out);
+int drm_fabric_user_fabric_del(u32 fabric_id);
+int drm_fabric_endpoint_set(struct drm_fabric_endpoint *ep,
+			    const struct drm_fabric_endpoint_change *change);
+int drm_fabric_port_set_admin(struct drm_fabric_port *port,
+			      enum drm_fabric_admin_state admin);
+int drm_fabric_port_peer_new(struct drm_fabric_port *port,
+			     const struct drm_fabric_peer *peer);
+int drm_fabric_port_peer_del(struct drm_fabric_port *port);
+
 /* Emits use the post-change generation. */
 void drm_fabric_emit_endpoint_create(struct drm_fabric_endpoint *ep, u32 generation);
 void drm_fabric_emit_endpoint_delete(struct drm_fabric_endpoint *ep, u32 generation);
+void drm_fabric_emit_endpoint_change(struct drm_fabric_endpoint *ep, u32 generation);
 
 void drm_fabric_emit_port_peer_create(struct drm_fabric_port *port,
 				      const struct drm_fabric_peer *peer,
diff --git a/drivers/gpu/drm/fabric/drm_fabric_test.c b/drivers/gpu/drm/fabric/drm_fabric_test.c
index 3457675645f6..863b7cf69068 100644
--- a/drivers/gpu/drm/fabric/drm_fabric_test.c
+++ b/drivers/gpu/drm/fabric/drm_fabric_test.c
@@ -112,13 +112,17 @@ static void drm_fabric_test_unregister_reports_removal(struct kunit *test)
 	KUNIT_EXPECT_EQ(test, drm_fabric_unregister(fab), 0);
 }
 
-static void drm_fabric_test_endpoint_requires_fabric(struct kunit *test)
+/*
+ * A NULL fabric registers an orphan endpoint, which reports fabric-id 0
+ * and can be unregistered.
+ */
+static void drm_fabric_test_endpoint_orphan_register(struct kunit *test)
 {
 	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
 	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
 	struct drm_fabric_endpoint_desc edesc = {
 		.fabric_ep_id = 1,
-		.name = "no-fabric",
+		.name = "orphan",
 		.parent = fabrictest_dev,
 		.ports = &pdesc,
 		.num_ports = 1,
@@ -126,9 +130,10 @@ static void drm_fabric_test_endpoint_requires_fabric(struct kunit *test)
 	struct drm_fabric_endpoint *ep;
 
 	ep = drm_fabric_endpoint_register(NULL, &edesc);
-	KUNIT_EXPECT_TRUE(test, IS_ERR(ep));
-	if (IS_ERR(ep))
-		KUNIT_EXPECT_EQ(test, PTR_ERR(ep), -EINVAL);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_EXPECT_EQ(test, drm_fabric_endpoint_fabric_id(ep), 0);
+
+	drm_fabric_endpoint_unregister(ep);
 }
 
 /* Registration must not walk a NULL port array. */
@@ -1280,7 +1285,7 @@ static struct kunit_case drm_fabric_test_cases[] = {
 	KUNIT_CASE(drm_fabric_test_unregister_reports_removal),
 	KUNIT_CASE(drm_fabric_test_instance_id_unique),
 	KUNIT_CASE(drm_fabric_test_endpoint_register),
-	KUNIT_CASE(drm_fabric_test_endpoint_requires_fabric),
+	KUNIT_CASE(drm_fabric_test_endpoint_orphan_register),
 	KUNIT_CASE(drm_fabric_test_endpoint_requires_port_array),
 	KUNIT_CASE(drm_fabric_test_endpoint_register_stale_fabric),
 	KUNIT_CASE(drm_fabric_test_unregister_rejects_non_member),
diff --git a/include/drm/drm_fabric.h b/include/drm/drm_fabric.h
index 136100b3da67..69e9099a1e57 100644
--- a/include/drm/drm_fabric.h
+++ b/include/drm/drm_fabric.h
@@ -4,8 +4,8 @@
  */
 
 /*
- * Common object model for GPU interconnect topology: fabric, endpoint, port
- * and peer relationships.
+ * DRM Fabric driver API: common object model for GPU interconnect topology
+ * (fabric, endpoint, port and peer relationships).
  */
 
 #ifndef __DRM_FABRIC_H__
@@ -18,8 +18,32 @@
 
 #include <uapi/drm/drm_fabric.h>
 
+enum drm_fabric_admin_state {
+	DRM_FABRIC_ADMIN_STATE_DOWN = 1,
+	DRM_FABRIC_ADMIN_STATE_UP,
+};
+
 struct device;
 
+/**
+ * enum drm_fabric_peer_mode - authority for a port's peer descriptor
+ * @DRM_FABRIC_PEER_MODE_PROVIDER: the provider reports the peer through
+ *	drm_fabric_port_set_peer() / drm_fabric_port_unset_peer(); the userspace
+ *	PORT_PEER_NEW / PORT_PEER_DEL path on the port returns -EOPNOTSUPP.
+ * @DRM_FABRIC_PEER_MODE_USERSPACE: userspace provisions the peer through
+ *	PORT_PEER_NEW / PORT_PEER_DEL (which invoke the provider programming
+ *	hooks); the provider must not call set_peer()/unset_peer() on the port,
+ *	and doing so returns -EOPNOTSUPP.
+ *
+ * One port, one peer descriptor, one source allowed to program it, fixed at
+ * registration. PROVIDER is the zero default, so a port that does not opt in
+ * is provider-managed.
+ */
+enum drm_fabric_peer_mode {
+	DRM_FABRIC_PEER_MODE_PROVIDER = 0,
+	DRM_FABRIC_PEER_MODE_USERSPACE,
+};
+
 /**
  * struct drm_fabric_port_desc - Port descriptor for drm_fabric_endpoint_register()
  */
@@ -36,6 +60,9 @@ struct drm_fabric_port_desc {
 	 * usable bandwidth.
 	 */
 	u32 max_lane_signaling_rate_mbps;
+
+	/** @peer_mode: who may program the port's peer descriptor */
+	enum drm_fabric_peer_mode peer_mode;
 };
 
 /**
@@ -79,6 +106,19 @@ struct drm_fabric_desc {
 	u64 instance_id;
 };
 
+/**
+ * enum drm_fabric_owner - lifecycle owner of a fabric object
+ * @DRM_FABRIC_OWNER_PROVIDER: created by a provider via drm_fabric_register();
+ *	only the provider may unregister it, never userspace FABRIC_DEL.
+ * @DRM_FABRIC_OWNER_USERSPACE: created by userspace via FABRIC_NEW; may be
+ *	deleted by userspace via FABRIC_DEL.
+ */
+enum drm_fabric_owner {
+	/* Zero value, so a kzalloc'd fabric is not deletable via FABRIC_DEL. */
+	DRM_FABRIC_OWNER_PROVIDER = 0,
+	DRM_FABRIC_OWNER_USERSPACE,
+};
+
 /**
  * struct drm_fabric - Fabric object
  */
@@ -91,6 +131,8 @@ struct drm_fabric {
 	u64 instance_id;
 	/** @name: human-readable fabric name */
 	char name[32];
+	/** @owner: lifecycle owner, gates userspace FABRIC_DEL */
+	enum drm_fabric_owner owner;
 
 	/** @refs: reference count */
 	refcount_t refs;
@@ -108,8 +150,13 @@ struct drm_fabric_endpoint {
 	char name[32];
 	/** @parent: backing device, provides dev_name and bus_name */
 	struct device *parent;
+	/**
+	 * @admin_state: administrative state, initially DOWN for an orphan and
+	 *	UP for a fabric member
+	 */
+	enum drm_fabric_admin_state admin_state;
 
-	/** @fabric: parent fabric */
+	/** @fabric: parent fabric, NULL while orphaned */
 	struct drm_fabric *fabric;
 
 	/** @ops: provider driver callbacks */
@@ -132,12 +179,14 @@ struct drm_fabric_endpoint {
  * drm_fabric_endpoint_fabric_id() - Wire fabric-id for an endpoint
  * @ep: endpoint to query
  *
- * Return: the parent fabric id.
+ * An orphaned endpoint (no fabric) reports fabric-id 0 on the wire.
+ *
+ * Return: the parent fabric id, or 0 if the endpoint is orphaned.
  */
 static inline u32
 drm_fabric_endpoint_fabric_id(const struct drm_fabric_endpoint *ep)
 {
-	return ep->fabric->id;
+	return ep->fabric ? ep->fabric->id : 0;
 }
 
 /**
@@ -171,11 +220,15 @@ struct drm_fabric_port {
 	u32 index;
 	/** @oper_state: operational (link) state */
 	enum drm_fabric_port_state oper_state;
+	/** @admin_state: administrative (requested) state */
+	enum drm_fabric_admin_state admin_state;
 	/** @max_lane_count: as in &struct drm_fabric_port_desc */
 	u32 max_lane_count;
 	/** @max_lane_signaling_rate_mbps: as in &struct drm_fabric_port_desc */
 	u32 max_lane_signaling_rate_mbps;
 
+	/** @peer_mode: authority for @peer, fixed at registration */
+	enum drm_fabric_peer_mode peer_mode;
 	/** @has_peer: whether @peer holds a valid descriptor */
 	bool has_peer;
 	/** @peer: neighbor description, valid only while @has_peer is set */
@@ -203,11 +256,34 @@ struct drm_fabric_port_stats {
 	u64 retrain_count;
 };
 
+/**
+ * struct drm_fabric_endpoint_change - Requested endpoint change for the endpoint_set() callback
+ */
+struct drm_fabric_endpoint_change {
+#define DRM_FABRIC_EP_CHANGE_FABRIC	BIT(0)
+#define DRM_FABRIC_EP_CHANGE_ADMIN	BIT(1)
+	/** @valid: bitmask of %DRM_FABRIC_EP_CHANGE_* selecting which fields are meaningful */
+	u32 valid;
+
+	/** @fabric_id: target fabric, 0 to detach (%DRM_FABRIC_EP_CHANGE_FABRIC) */
+	u32 fabric_id;
+	/** @admin: requested admin state (%DRM_FABRIC_EP_CHANGE_ADMIN) */
+	enum drm_fabric_admin_state admin;
+};
+
 /**
  * struct drm_fabric_ops - Provider driver callbacks
  *
- * A callback that is not supplied makes the matching netlink operation return
- * -EOPNOTSUPP.
+ * A callback that is not supplied makes the matching netlink operation
+ * return -EOPNOTSUPP.
+ *
+ * All callbacks are invoked without drm_fabric_lock held and may sleep;
+ * see each callback's own doc below for any further constraint.
+ *
+ * The mutation callbacks (endpoint_set, port_set, port_peer_new,
+ * port_peer_del) run with drm_fabric_mutation_lock held, so a provider
+ * must not call drm_fabric_endpoint_unregister() from inside one: that
+ * call takes the same lock and would self-deadlock.
  */
 struct drm_fabric_ops {
 	/**
@@ -221,6 +297,24 @@ struct drm_fabric_ops {
 	 */
 	int (*port_stats_get)(struct drm_fabric_port *port,
 			      struct drm_fabric_port_stats *stats);
+
+	/** @endpoint_set: attach or detach an endpoint and/or set its admin state */
+	int (*endpoint_set)(struct drm_fabric_endpoint *ep,
+			    const struct drm_fabric_endpoint_change *change,
+			    struct drm_fabric *fabric);
+	/** @port_set: set a port's admin state */
+	int (*port_set)(struct drm_fabric_port *port,
+			enum drm_fabric_admin_state admin);
+	/**
+	 * @port_peer_new: program a port's neighbor. Reached only for a port
+	 * whose peer_mode is %DRM_FABRIC_PEER_MODE_USERSPACE; a
+	 * provider-managed port reports its peer through
+	 * drm_fabric_port_set_peer() instead.
+	 */
+	int (*port_peer_new)(struct drm_fabric_port *port,
+			     const struct drm_fabric_peer *peer);
+	/** @port_peer_del: unprogram a userspace-managed port's neighbor */
+	int (*port_peer_del)(struct drm_fabric_port *port);
 };
 
 struct drm_fabric *drm_fabric_register(const struct drm_fabric_desc *desc);
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [RFC PATCH 08/12] drm/fabric: add provisioning netlink uAPI
  2026-08-24  8:09 [RFC PATCH 0/12] drm/fabric: vendor-neutral topology infrastructure for scale-up accelerator interconnects Konstantin Sinyuk
                   ` (6 preceding siblings ...)
  2026-08-24  8:09 ` [RFC PATCH 07/12] drm/fabric: add topology-provisioning core Konstantin Sinyuk
@ 2026-08-24  8:09 ` Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 09/12] drm/fabric: implement mutation netlink operations Konstantin Sinyuk
                   ` (3 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Konstantin Sinyuk @ 2026-08-24  8:09 UTC (permalink / raw)
  To: dri-devel
  Cc: Maarten Lankhorst, Francois Dugast, David Airlie, Simona Vetter,
	Maxime Ripard, Thomas Zimmermann, Jonathan Corbet, Shuah Khan,
	Donald Hunter, Jakub Kicinski, David S. Miller, Eric Dumazet,
	Paolo Abeni, Simon Horman, Ilia Levi, Rodrigo Vivi, linux-doc,
	linux-kselftest, netdev, linux-kernel

From: Ilia Levi <ilia.levi@intel.com>

Extend the YNL specification with six privileged provisioning commands.

FABRIC_NEW and FABRIC_DEL create and delete empty userspace-owned
fabrics; FABRIC_NEW carries a fabric-new-params nest with the type, name
and instance ID. ENDPOINT_SET changes fabric membership and endpoint
administrative state, PORT_SET changes port administrative state, and
PORT_PEER_NEW and PORT_PEER_DEL manage peer adjacency. All six require
GENL_ADMIN_PERM.

A peer request carries its type-qualified identity and far-end port
index. The descriptor is stored without resolving it to a local object,
as documented in Documentation/gpu/drm-fabric.rst.

Add an endpoint-change notification and extend the existing port-change
and peer notifications to cover userspace-requested transitions.

Generate the headers from the tree root with:

  tools/net/ynl/pyynl/ynl_gen_c.py --mode uapi --header \
      --spec Documentation/netlink/specs/drm_fabric.yaml \
      -o include/uapi/drm/drm_fabric.h
  tools/net/ynl/pyynl/ynl_gen_c.py --mode kernel --header \
      --spec Documentation/netlink/specs/drm_fabric.yaml \
      -o drivers/gpu/drm/fabric/drm_fabric_nl.h

The generated source names the six doit handlers, so it lands with their
implementation in the next patch. Drop the temporary administrative-state
definitions now that the generated headers provide them.

Signed-off-by: Ilia Levi <ilia.levi@intel.com>
Co-developed-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Signed-off-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Assisted-by: GitHub-Copilot:claude-opus-4.8
---
 Documentation/gpu/drm-fabric.rst            | 117 ++++++++++++---
 Documentation/netlink/specs/drm_fabric.yaml | 155 +++++++++++++++++++-
 drivers/gpu/drm/fabric/drm_fabric_nl.h      |  30 ++++
 include/drm/drm_fabric.h                    |   9 +-
 include/uapi/drm/drm_fabric.h               |  28 ++++
 5 files changed, 312 insertions(+), 27 deletions(-)

diff --git a/Documentation/gpu/drm-fabric.rst b/Documentation/gpu/drm-fabric.rst
index 61e2c18bf0b5..4ee7cb7ef6ac 100644
--- a/Documentation/gpu/drm-fabric.rst
+++ b/Documentation/gpu/drm-fabric.rst
@@ -87,9 +87,11 @@ switch, an accelerator on another node, or a local endpoint that merely
 unregistered -- so only the provider knows when a port's physical adjacency
 actually changed, and only the provider retracts or replaces the descriptor.
 
-``port-peer-delete-ntf`` reports an explicitly retracted half-edge; it is
-not emitted when a peer merely becomes locally unresolvable, so its absence
-is not evidence the far end is still reachable.
+``port-peer-delete-ntf`` reports an explicitly retracted half-edge -- through
+the provider's own report or, on a userspace-managed port, a userspace
+``port-peer-del`` request -- and is never emitted when a peer merely becomes
+locally unresolvable, so its absence is not evidence the far end is still
+reachable.
 
 Endpoint teardown removes the endpoint's owned half-edges without
 generating a separate event for each port: the delete already describes
@@ -170,9 +172,9 @@ the mutation lock, so the nested acquisition would self-deadlock. A successful
 callback therefore cannot be invalidated before commit, and the post-callback
 checks are invariant assertions only.
 
-A fabric records whether it was created by a provider or by userspace. Only an
-empty userspace-created fabric may be deleted through the provisioning core;
-provider-owned and non-empty fabrics are rejected.
+A fabric records whether it was created by a provider
+(drm_fabric_register()) or by userspace (``fabric-new``); see
+`Provisioning`_ for the deletion rules.
 
 Generic Netlink family
 ======================
@@ -216,8 +218,9 @@ User space enumerates topology with four read-only commands, each supporting a
 single lookup (``do``) and a bulk dump (``dump``):
 
 * ``fabric-get`` -- enumerate fabrics (``do`` by ``fabric-id``, ``dump`` for all).
-* ``endpoint-get`` -- enumerate endpoints, optionally filtered by ``fabric-id``,
-  or resolve one by ``endpoint-id`` or backing ``dev-name``/``bus-name``.
+* ``endpoint-get`` -- enumerate endpoints, optionally filtered by ``fabric-id``
+  (0 selects orphans), or resolve one by ``endpoint-id`` or by its backing
+  ``dev-name`` with an optional ``bus-name``.
 * ``port-get`` -- enumerate ports, filtered by ``endpoint-id``. Ports may report
   the provider's maximum capability as ``max-lane-count`` and
   ``max-lane-signaling-rate-mbps`` (zero means unknown); these are maxima, not
@@ -243,10 +246,10 @@ a partial payload and are declared with ``event:``.
      - Trigger
      - Payload
    * - ``fabric-create-ntf``
-     - a fabric is registered by a provider
+     - a fabric is registered (provider or ``fabric-new``)
      - reuses ``fabric-get``
    * - ``fabric-delete-ntf``
-     - a provider-owned fabric is unregistered
+     - a fabric is unregistered or removed via ``fabric-del``
      - reuses ``fabric-get``
    * - ``endpoint-create-ntf``
      - an endpoint is registered
@@ -254,15 +257,19 @@ a partial payload and are declared with ``event:``.
    * - ``endpoint-delete-ntf``
      - an endpoint is unregistered
      - reuses ``endpoint-get``
+   * - ``endpoint-change-ntf``
+     - an endpoint's fabric attachment or administrative state changes
+     - reuses ``endpoint-get``
    * - ``port-change-ntf``
-     - a port's operational state changes
+     - a port's operational or administrative state changes
      - reuses ``port-get``
    * - ``port-peer-create-ntf``
-     - a port's peer descriptor is set by its provider
+     - a port's peer descriptor is set (provider or ``port-peer-new``)
      - partial (``event:``)
    * - ``port-peer-delete-ntf``
-     - a port's peer descriptor is explicitly unset by its provider; never
-       emitted for an implicit half-edge loss (see `Peer semantics`_)
+     - a port's peer descriptor is explicitly unset (provider or
+       ``port-peer-del``); never emitted for an implicit half-edge loss
+       (see `Peer semantics`_)
      - partial (``event:``)
 
 Notifications are best-effort. A listener that detects loss, restarts, or
@@ -274,9 +281,9 @@ Topology generation and dump consistency
 The core keeps a nonzero generation counter and advances it whenever topology or
 exposed state changes. It is surfaced as the ``topology-generation`` attribute on
 ``fabric-get``, ``endpoint-get`` and ``port-get`` replies and on the topology
-notifications. A provider-reported change advances the generation before its
-notification is serialized, so an event carries the post-change value that a
-later ``get``/``dump`` will also report.
+notifications. A committed change -- provider-reported or userspace mutation
+-- advances the generation before its notification is serialized, so an event
+carries the post-change value that a later ``get``/``dump`` will also report.
 
 ``topology-generation`` is a change token, not a timestamp, liveness counter or
 event count: a changed value means topology changed, but the delta between two
@@ -308,8 +315,8 @@ Monitor notifications are likewise emitted only into ``init_net``, so a listener
 that joins the multicast group from another network namespace never receives
 them.
 
-Examples
-========
+Query examples
+--------------
 
 Query the topology with the in-tree YNL tool, pointing it at the spec:
 
@@ -364,6 +371,78 @@ Query a single port:
 
 The family name on the wire is ``drm-fabric``.
 
+Provisioning
+============
+
+The provisioning interface requires ``CAP_NET_ADMIN``. ``fabric-new`` and
+``fabric-del`` are core-owned; the rest are serviced by the provider and
+return ``-EOPNOTSUPP`` where it does not implement the matching callback:
+
+* ``fabric-new`` -- create an empty, userspace-owned fabric. The request
+  carries a ``fabric-new-params`` nest with the fabric type, an optional name
+  and a provider-defined instance ID.
+* ``fabric-del`` -- delete an empty, userspace-owned fabric. Refuses a
+  provider-owned fabric (``-EPERM``) and a non-empty one (``-EBUSY``).
+* ``endpoint-set`` -- attach an orphan endpoint, selected by ``endpoint-id`` or
+  by its backing ``dev-name`` with an optional ``bus-name``, to a fabric;
+  detach it with ``fabric-id`` 0; and/or set its administrative state.
+* ``port-set`` -- set a port's administrative state.
+* ``port-peer-new`` / ``port-peer-del`` -- set or unset a port's neighbor
+  half-edge. Valid only on a userspace-managed port; a provider-managed port
+  returns ``-EOPNOTSUPP`` (see `Peer management mode`_).
+
+Mutation commands carry ``GENL_ADMIN_PERM`` and are additionally confined to
+``init_net``. ``GENL_ADMIN_PERM`` only checks ``CAP_NET_ADMIN`` in the caller's
+user namespace, which a process in a nested user and network namespace may
+hold; restricting mutators to ``init_net`` prevents container-local privilege
+from reconfiguring host-global topology.
+
+A userspace-created fabric additionally pins this module for as long as it
+remains registered: ``fabric-new`` takes the pin, ``fabric-del`` releases it.
+
+Peer management mode
+--------------------
+
+A port uses one peer-management mode, fixed by the provider at registration.
+
+In provider-managed mode, the provider reports peer creation and removal
+(drm_fabric_port_set_peer() / drm_fabric_port_unset_peer()) and userspace peer
+provisioning is unsupported (``port-peer-new`` / ``port-peer-del`` return
+``-EOPNOTSUPP``).
+
+In userspace-managed mode, peer creation and removal are requested through the
+provisioning API and the provider does not independently replace or retract the
+peer (drm_fabric_port_set_peer() / drm_fabric_port_unset_peer() return
+``-EOPNOTSUPP``).
+
+One authority per descriptor prevents concurrent updates from both sources.
+Setting an occupied port returns ``-EEXIST``; unsetting an empty port returns
+``-ENOENT``.
+
+Provisioning examples
+---------------------
+
+Provision with the in-tree YNL tool (root, initial namespace):
+
+.. code-block:: bash
+
+    # Create an empty fabric
+    ./tools/net/ynl/pyynl/cli.py \
+        --spec Documentation/netlink/specs/drm_fabric.yaml \
+        --do fabric-new \
+        --json '{"fabric-new-params": {"type": "synthetic", "instance-id": 42}}'
+
+The command returns a core-assigned ``fabric-id``. Use that value in the
+following requests.
+
+.. code-block:: bash
+
+    # Attach an orphan endpoint to the returned fabric
+    ./tools/net/ynl/pyynl/cli.py \
+        --spec Documentation/netlink/specs/drm_fabric.yaml \
+        --do endpoint-set \
+        --json '{"endpoint-id": 5, "fabric-id": <returned-fabric-id>}'
+
 Synthetic provider
 ==================
 
diff --git a/Documentation/netlink/specs/drm_fabric.yaml b/Documentation/netlink/specs/drm_fabric.yaml
index 778443d92a0d..0c9a56f4b387 100644
--- a/Documentation/netlink/specs/drm_fabric.yaml
+++ b/Documentation/netlink/specs/drm_fabric.yaml
@@ -18,6 +18,16 @@ doc: |
   Replies and notifications carry a topology-generation token so an
   interrupted or changed dump can be discarded and retried.
 
+  The provisioning surface (fabric-new, fabric-del, endpoint-set, port-set,
+  port-peer-new and port-peer-del) requires administrative permission and is
+  restricted to the initial network namespace. fabric-new and fabric-del are
+  implemented by the core and do not depend on a provider callback. The rest
+  are serviced by the provider and return -EOPNOTSUPP where it does not
+  implement the matching one.
+
+  The interface is extensible: new fabric types and attributes can be added
+  without breaking the uAPI.
+
 definitions:
   -
     name: type
@@ -46,6 +56,13 @@ definitions:
     doc: Kind of device directly adjacent at a port's far end.
     entries: [accel, switch]
 
+  -
+    name: admin-state
+    type: enum
+    value-start: 1
+    doc: Administrative (requested) state of an endpoint or port.
+    entries: [down, up]
+
 attribute-sets:
   -
     name: drm-fabric
@@ -108,6 +125,17 @@ attribute-sets:
           or DUMP reply carries its snapshot's generation; a notification
           carries the generation of its change. Statistics reads do not
           advance it.
+      # Provisioning request parameters
+      -
+        name: admin-state
+        type: u32
+        enum: admin-state
+        doc: Requested administrative state.
+      -
+        name: fabric-new-params
+        type: nest
+        nested-attributes: fabric-new-params
+        doc: Creation parameters for ``fabric-new`` (full nest).
 
   -
     name: fabric
@@ -136,6 +164,30 @@ attribute-sets:
         type: u64
         doc: Vendor-unique fabric instance identifier (e.g. a hive ID).
 
+  -
+    name: fabric-new-params
+    name-prefix: drm-fabric-a-fabric-new-params-
+    enum-name: drm-fabric-a-fabric-new-params
+    doc: Creation parameters carried by a ``fabric-new`` request.
+    attributes:
+      -
+        name: type
+        type: u32
+        enum: type
+        doc: Fabric interconnect technology, see enum type.
+      -
+        name: name
+        type: string
+        checks:
+          max-len: 31
+        doc: >-
+          Human-readable fabric name. Bounded to the core's storage so an
+          over-long name is rejected rather than silently truncated.
+      -
+        name: instance-id
+        type: u64
+        doc: Vendor-unique fabric instance identifier (e.g. a hive ID).
+
   -
     name: endpoint
     name-prefix: drm-fabric-a-endpoint-attrs-
@@ -152,7 +204,7 @@ attribute-sets:
       -
         name: fabric-id
         type: u32
-        doc: Identifier of the parent fabric.
+        doc: Identifier of the parent fabric, 0 while orphaned.
       -
         name: fabric-ep-id
         type: u64
@@ -169,6 +221,11 @@ attribute-sets:
         name: bus-name
         type: string
         doc: The bus of the backing device (e.g. pci).
+      -
+        name: admin-state
+        type: u32
+        enum: admin-state
+        doc: Endpoint's administrative state.
 
   -
     name: port
@@ -209,6 +266,11 @@ attribute-sets:
         type: nest
         nested-attributes: peer
         doc: Peer info, absent if the port has no neighbor set.
+      -
+        name: admin-state
+        type: u32
+        enum: admin-state
+        doc: Port's administrative state.
 
   -
     name: peer
@@ -421,6 +483,97 @@ operations:
         reply shape (full fabric nest).
       notify: fabric-get
 
+    -
+      name: endpoint-change-ntf
+      doc: |
+        Endpoint state change notification.
+        Reuses the endpoint-get reply shape (full endpoint nest).
+      notify: endpoint-get
+
+    -
+      name: fabric-new
+      doc: Create a new empty fabric
+      attribute-set: drm-fabric
+      flags: [admin-perm]
+      do:
+        pre: drm-fabric-nl-pre-doit
+        post: drm-fabric-nl-post-doit
+        request:
+          attributes:
+            - fabric-new-params
+        reply:
+          attributes:
+            - fabric-id
+
+    -
+      name: fabric-del
+      doc: Delete an empty fabric
+      attribute-set: drm-fabric
+      flags: [admin-perm]
+      do:
+        pre: drm-fabric-nl-pre-doit
+        post: drm-fabric-nl-post-doit
+        request:
+          attributes:
+            - fabric-id
+
+    -
+      name: endpoint-set
+      doc: Attach/detach an endpoint to a fabric and/or set its admin state
+      attribute-set: drm-fabric
+      flags: [admin-perm]
+      do:
+        pre: drm-fabric-nl-endpoint-pre-doit
+        post: drm-fabric-nl-endpoint-post-doit
+        request:
+          attributes:
+            - endpoint-id
+            - dev-name
+            - bus-name
+            - fabric-id
+            - admin-state
+
+    -
+      name: port-set
+      doc: Set a port's administrative state
+      attribute-set: drm-fabric
+      flags: [admin-perm]
+      do:
+        pre: drm-fabric-nl-port-pre-doit
+        post: drm-fabric-nl-port-post-doit
+        request:
+          attributes:
+            - endpoint-id
+            - port-index
+            - admin-state
+
+    -
+      name: port-peer-new
+      doc: Set a port's neighbor
+      attribute-set: drm-fabric
+      flags: [admin-perm]
+      do:
+        pre: drm-fabric-nl-port-pre-doit
+        post: drm-fabric-nl-port-post-doit
+        request:
+          attributes:
+            - endpoint-id
+            - port-index
+            - peer
+
+    -
+      name: port-peer-del
+      doc: Unset a port's neighbor
+      attribute-set: drm-fabric
+      flags: [admin-perm]
+      do:
+        pre: drm-fabric-nl-port-pre-doit
+        post: drm-fabric-nl-port-post-doit
+        request:
+          attributes:
+            - endpoint-id
+            - port-index
+
 mcast-groups:
   list:
     -
diff --git a/drivers/gpu/drm/fabric/drm_fabric_nl.h b/drivers/gpu/drm/fabric/drm_fabric_nl.h
index 8cedc004260f..03b3d22f180c 100644
--- a/drivers/gpu/drm/fabric/drm_fabric_nl.h
+++ b/drivers/gpu/drm/fabric/drm_fabric_nl.h
@@ -12,6 +12,27 @@
 
 #include <uapi/drm/drm_fabric.h>
 
+/* Common nested types */
+extern const struct nla_policy drm_fabric_fabric_new_params_nl_policy[DRM_FABRIC_A_FABRIC_NEW_PARAMS_INSTANCE_ID + 1];
+extern const struct nla_policy drm_fabric_peer_nl_policy[DRM_FABRIC_A_PEER_ATTRS_PORT_INDEX + 1];
+
+int drm_fabric_nl_pre_doit(const struct genl_split_ops *ops,
+			   struct sk_buff *skb, struct genl_info *info);
+int drm_fabric_nl_endpoint_pre_doit(const struct genl_split_ops *ops,
+				    struct sk_buff *skb,
+				    struct genl_info *info);
+int drm_fabric_nl_port_pre_doit(const struct genl_split_ops *ops,
+				struct sk_buff *skb, struct genl_info *info);
+void
+drm_fabric_nl_post_doit(const struct genl_split_ops *ops, struct sk_buff *skb,
+			struct genl_info *info);
+void
+drm_fabric_nl_endpoint_post_doit(const struct genl_split_ops *ops,
+				 struct sk_buff *skb, struct genl_info *info);
+void
+drm_fabric_nl_port_post_doit(const struct genl_split_ops *ops,
+			     struct sk_buff *skb, struct genl_info *info);
+
 int drm_fabric_nl_fabric_get_doit(struct sk_buff *skb, struct genl_info *info);
 int drm_fabric_nl_fabric_get_dumpit(struct sk_buff *skb,
 				    struct netlink_callback *cb);
@@ -26,6 +47,15 @@ int drm_fabric_nl_port_stats_get_doit(struct sk_buff *skb,
 				      struct genl_info *info);
 int drm_fabric_nl_port_stats_get_dumpit(struct sk_buff *skb,
 					struct netlink_callback *cb);
+int drm_fabric_nl_fabric_new_doit(struct sk_buff *skb, struct genl_info *info);
+int drm_fabric_nl_fabric_del_doit(struct sk_buff *skb, struct genl_info *info);
+int drm_fabric_nl_endpoint_set_doit(struct sk_buff *skb,
+				    struct genl_info *info);
+int drm_fabric_nl_port_set_doit(struct sk_buff *skb, struct genl_info *info);
+int drm_fabric_nl_port_peer_new_doit(struct sk_buff *skb,
+				     struct genl_info *info);
+int drm_fabric_nl_port_peer_del_doit(struct sk_buff *skb,
+				     struct genl_info *info);
 
 enum {
 	DRM_FABRIC_NLGRP_MONITOR,
diff --git a/include/drm/drm_fabric.h b/include/drm/drm_fabric.h
index 69e9099a1e57..0479b6435a0e 100644
--- a/include/drm/drm_fabric.h
+++ b/include/drm/drm_fabric.h
@@ -4,8 +4,8 @@
  */
 
 /*
- * DRM Fabric driver API: common object model for GPU interconnect topology
- * (fabric, endpoint, port and peer relationships).
+ * Common object model for GPU interconnect topology: fabric, endpoint, port
+ * and peer relationships.
  */
 
 #ifndef __DRM_FABRIC_H__
@@ -18,11 +18,6 @@
 
 #include <uapi/drm/drm_fabric.h>
 
-enum drm_fabric_admin_state {
-	DRM_FABRIC_ADMIN_STATE_DOWN = 1,
-	DRM_FABRIC_ADMIN_STATE_UP,
-};
-
 struct device;
 
 /**
diff --git a/include/uapi/drm/drm_fabric.h b/include/uapi/drm/drm_fabric.h
index b6c7bd6e35f0..e004200423e8 100644
--- a/include/uapi/drm/drm_fabric.h
+++ b/include/uapi/drm/drm_fabric.h
@@ -36,6 +36,14 @@ enum drm_fabric_peer_type {
 	DRM_FABRIC_PEER_TYPE_SWITCH,
 };
 
+/*
+ * Administrative (requested) state of an endpoint or port.
+ */
+enum drm_fabric_admin_state {
+	DRM_FABRIC_ADMIN_STATE_DOWN = 1,
+	DRM_FABRIC_ADMIN_STATE_UP,
+};
+
 enum drm_fabric_a {
 	DRM_FABRIC_A_FABRIC = 1,
 	DRM_FABRIC_A_ENDPOINT,
@@ -48,6 +56,8 @@ enum drm_fabric_a {
 	DRM_FABRIC_A_BUS_NAME,
 	DRM_FABRIC_A_PEER,
 	DRM_FABRIC_A_TOPOLOGY_GENERATION,
+	DRM_FABRIC_A_ADMIN_STATE,
+	DRM_FABRIC_A_FABRIC_NEW_PARAMS,
 
 	__DRM_FABRIC_A_MAX,
 	DRM_FABRIC_A_MAX = (__DRM_FABRIC_A_MAX - 1)
@@ -64,6 +74,15 @@ enum drm_fabric_a_fabric_attrs {
 	DRM_FABRIC_A_FABRIC_ATTRS_MAX = (__DRM_FABRIC_A_FABRIC_ATTRS_MAX - 1)
 };
 
+enum drm_fabric_a_fabric_new_params {
+	DRM_FABRIC_A_FABRIC_NEW_PARAMS_TYPE = 1,
+	DRM_FABRIC_A_FABRIC_NEW_PARAMS_NAME,
+	DRM_FABRIC_A_FABRIC_NEW_PARAMS_INSTANCE_ID,
+
+	__DRM_FABRIC_A_FABRIC_NEW_PARAMS_MAX,
+	DRM_FABRIC_A_FABRIC_NEW_PARAMS_MAX = (__DRM_FABRIC_A_FABRIC_NEW_PARAMS_MAX - 1)
+};
+
 enum drm_fabric_a_endpoint_attrs {
 	DRM_FABRIC_A_ENDPOINT_ATTRS_PAD = 1,
 	DRM_FABRIC_A_ENDPOINT_ATTRS_ENDPOINT_ID,
@@ -72,6 +91,7 @@ enum drm_fabric_a_endpoint_attrs {
 	DRM_FABRIC_A_ENDPOINT_ATTRS_NAME,
 	DRM_FABRIC_A_ENDPOINT_ATTRS_DEV_NAME,
 	DRM_FABRIC_A_ENDPOINT_ATTRS_BUS_NAME,
+	DRM_FABRIC_A_ENDPOINT_ATTRS_ADMIN_STATE,
 
 	__DRM_FABRIC_A_ENDPOINT_ATTRS_MAX,
 	DRM_FABRIC_A_ENDPOINT_ATTRS_MAX = (__DRM_FABRIC_A_ENDPOINT_ATTRS_MAX - 1)
@@ -84,6 +104,7 @@ enum drm_fabric_a_port_attrs {
 	DRM_FABRIC_A_PORT_ATTRS_MAX_LANE_COUNT,
 	DRM_FABRIC_A_PORT_ATTRS_MAX_LANE_SIGNALING_RATE_MBPS,
 	DRM_FABRIC_A_PORT_ATTRS_PEER,
+	DRM_FABRIC_A_PORT_ATTRS_ADMIN_STATE,
 
 	__DRM_FABRIC_A_PORT_ATTRS_MAX,
 	DRM_FABRIC_A_PORT_ATTRS_MAX = (__DRM_FABRIC_A_PORT_ATTRS_MAX - 1)
@@ -124,6 +145,13 @@ enum drm_fabric_cmd {
 	DRM_FABRIC_CMD_ENDPOINT_DELETE_NTF,
 	DRM_FABRIC_CMD_FABRIC_CREATE_NTF,
 	DRM_FABRIC_CMD_FABRIC_DELETE_NTF,
+	DRM_FABRIC_CMD_ENDPOINT_CHANGE_NTF,
+	DRM_FABRIC_CMD_FABRIC_NEW,
+	DRM_FABRIC_CMD_FABRIC_DEL,
+	DRM_FABRIC_CMD_ENDPOINT_SET,
+	DRM_FABRIC_CMD_PORT_SET,
+	DRM_FABRIC_CMD_PORT_PEER_NEW,
+	DRM_FABRIC_CMD_PORT_PEER_DEL,
 
 	__DRM_FABRIC_CMD_MAX,
 	DRM_FABRIC_CMD_MAX = (__DRM_FABRIC_CMD_MAX - 1)
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [RFC PATCH 09/12] drm/fabric: implement mutation netlink operations
  2026-08-24  8:09 [RFC PATCH 0/12] drm/fabric: vendor-neutral topology infrastructure for scale-up accelerator interconnects Konstantin Sinyuk
                   ` (7 preceding siblings ...)
  2026-08-24  8:09 ` [RFC PATCH 08/12] drm/fabric: add provisioning netlink uAPI Konstantin Sinyuk
@ 2026-08-24  8:09 ` Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 10/12] drm/fabric: make the synthetic provider writable Konstantin Sinyuk
                   ` (2 subsequent siblings)
  11 siblings, 0 replies; 13+ messages in thread
From: Konstantin Sinyuk @ 2026-08-24  8:09 UTC (permalink / raw)
  To: dri-devel
  Cc: Maarten Lankhorst, Francois Dugast, David Airlie, Simona Vetter,
	Maxime Ripard, Thomas Zimmermann, Jonathan Corbet, Shuah Khan,
	Donald Hunter, Jakub Kicinski, David S. Miller, Eric Dumazet,
	Paolo Abeni, Simon Horman, Ilia Levi, Rodrigo Vivi, linux-doc,
	linux-kselftest, netdev, linux-kernel

Connect the six provisioning operations to the core mutation helpers and
add the generated operation and policy source that dispatches to them.

Resolve and pin mutation targets in pre_doit, run each transaction under
drm_fabric_mutation_lock, call the provider without drm_fabric_lock held,
and release references in post_doit. All six require GENL_ADMIN_PERM and
are confined to init_net.

A failed provider callback returns its error with core state unchanged.
Successful mutations emit notifications after commit, carrying the
resulting topology generation.

Co-developed-by: Ilia Levi <ilia.levi@intel.com>
Signed-off-by: Ilia Levi <ilia.levi@intel.com>
Signed-off-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Assisted-by: GitHub-Copilot:claude-opus-4.8
---
 drivers/gpu/drm/fabric/drm_fabric.c         |   5 +-
 drivers/gpu/drm/fabric/drm_fabric_netlink.c | 275 ++++++++++++++++++++
 drivers/gpu/drm/fabric/drm_fabric_nl.c      | 106 ++++++++
 3 files changed, 384 insertions(+), 2 deletions(-)

diff --git a/drivers/gpu/drm/fabric/drm_fabric.c b/drivers/gpu/drm/fabric/drm_fabric.c
index 1aae5ff68798..50c34b4fd722 100644
--- a/drivers/gpu/drm/fabric/drm_fabric.c
+++ b/drivers/gpu/drm/fabric/drm_fabric.c
@@ -793,7 +793,8 @@ int drm_fabric_user_fabric_del(u32 fabric_id)
 		/* Symmetric with FABRIC_NEW: only an empty fabric may be removed. */
 		if (drm_fabric_has_members(fabric))
 			return -EBUSY;
-		drm_fabric_base_seq_inc();
+		/* Notify while the userspace-owned fabric is still addressable by id. */
+		drm_fabric_emit_fabric_delete(fabric, drm_fabric_base_seq_inc());
 		xa_erase(&drm_fabric_xa, fabric->id);
 	}
 
@@ -906,7 +907,7 @@ int drm_fabric_endpoint_set(struct drm_fabric_endpoint *ep,
 		if (change.valid & DRM_FABRIC_EP_CHANGE_ADMIN)
 			ep->admin_state = change.admin;
 
-		drm_fabric_base_seq_inc();
+		drm_fabric_emit_endpoint_change(ep, drm_fabric_base_seq_inc());
 	}
 
 	return 0;
diff --git a/drivers/gpu/drm/fabric/drm_fabric_netlink.c b/drivers/gpu/drm/fabric/drm_fabric_netlink.c
index fef2d4c8f5bb..1a0f293723e9 100644
--- a/drivers/gpu/drm/fabric/drm_fabric_netlink.c
+++ b/drivers/gpu/drm/fabric/drm_fabric_netlink.c
@@ -80,6 +80,7 @@ static int drm_fabric_fill_endpoint(struct sk_buff *skb,
 	    nla_put_u64_64bit(skb, DRM_FABRIC_A_ENDPOINT_ATTRS_FABRIC_EP_ID,
 			      ep->fabric_ep_id, DRM_FABRIC_A_ENDPOINT_ATTRS_PAD) ||
 	    nla_put_string(skb, DRM_FABRIC_A_ENDPOINT_ATTRS_NAME, ep->name) ||
+	    nla_put_u32(skb, DRM_FABRIC_A_ENDPOINT_ATTRS_ADMIN_STATE, ep->admin_state) ||
 	    nla_put_string(skb, DRM_FABRIC_A_ENDPOINT_ATTRS_DEV_NAME,
 			   dev_name(ep->parent)) ||
 	    nla_put_string(skb, DRM_FABRIC_A_ENDPOINT_ATTRS_BUS_NAME,
@@ -132,6 +133,8 @@ static int drm_fabric_fill_port(struct sk_buff *skb,
 	    nla_put_u32(skb, DRM_FABRIC_A_PORT_ATTRS_ENDPOINT_ID, port->endpoint->id) ||
 	    nla_put_u32(skb, DRM_FABRIC_A_PORT_ATTRS_OPER_STATE,
 			port->oper_state) ||
+	    nla_put_u32(skb, DRM_FABRIC_A_PORT_ATTRS_ADMIN_STATE,
+			port->admin_state) ||
 	    nla_put_u32(skb, DRM_FABRIC_A_PORT_ATTRS_MAX_LANE_COUNT,
 			port->max_lane_count) ||
 	    nla_put_u32(skb, DRM_FABRIC_A_PORT_ATTRS_MAX_LANE_SIGNALING_RATE_MBPS,
@@ -846,6 +849,272 @@ int drm_fabric_nl_port_stats_get_dumpit(struct sk_buff *skb,
 	return ret;
 }
 
+/* Fabric create/delete have no target to resolve; only serialize mutation. */
+int drm_fabric_nl_pre_doit(const struct genl_split_ops *ops,
+			   struct sk_buff *skb, struct genl_info *info)
+{
+	int ret = drm_fabric_nl_host_only(genl_info_net(info));
+
+	if (ret)
+		return ret;
+
+	mutex_lock(&drm_fabric_mutation_lock);
+	return 0;
+}
+
+void drm_fabric_nl_post_doit(const struct genl_split_ops *ops,
+			     struct sk_buff *skb, struct genl_info *info)
+{
+	mutex_unlock(&drm_fabric_mutation_lock);
+}
+
+/*
+ * Pin the target in user_ptr[0]. Drop the mutation lock on failure because
+ * post_doit does not run when pre_doit fails.
+ */
+int drm_fabric_nl_endpoint_pre_doit(const struct genl_split_ops *ops,
+				    struct sk_buff *skb, struct genl_info *info)
+{
+	struct drm_fabric_endpoint *ep;
+	int ret = drm_fabric_nl_host_only(genl_info_net(info));
+
+	if (ret)
+		return ret;
+
+	mutex_lock(&drm_fabric_mutation_lock);
+
+	scoped_guard(mutex, &drm_fabric_lock) {
+		ep = drm_fabric_resolve_endpoint(info);
+		if (!IS_ERR(ep))
+			drm_fabric_endpoint_get(ep);
+	}
+
+	if (IS_ERR(ep)) {
+		mutex_unlock(&drm_fabric_mutation_lock);
+		return PTR_ERR(ep);
+	}
+
+	info->user_ptr[0] = ep;
+	return 0;
+}
+
+void drm_fabric_nl_endpoint_post_doit(const struct genl_split_ops *ops,
+				      struct sk_buff *skb,
+				      struct genl_info *info)
+{
+	drm_fabric_endpoint_put(info->user_ptr[0]);
+	mutex_unlock(&drm_fabric_mutation_lock);
+}
+
+int drm_fabric_nl_port_pre_doit(const struct genl_split_ops *ops,
+				struct sk_buff *skb, struct genl_info *info)
+{
+	struct drm_fabric_port *port;
+	u32 ep_id, port_idx;
+	int ret;
+
+	ret = drm_fabric_nl_host_only(genl_info_net(info));
+	if (ret)
+		return ret;
+
+	ret = drm_fabric_port_key(info, &ep_id, &port_idx);
+	if (ret)
+		return ret;
+
+	mutex_lock(&drm_fabric_mutation_lock);
+
+	port = drm_fabric_port_find_get(ep_id, port_idx);
+	if (IS_ERR(port)) {
+		mutex_unlock(&drm_fabric_mutation_lock);
+		return PTR_ERR(port);
+	}
+
+	info->user_ptr[0] = port;
+	return 0;
+}
+
+void drm_fabric_nl_port_post_doit(const struct genl_split_ops *ops,
+				  struct sk_buff *skb, struct genl_info *info)
+{
+	drm_fabric_port_put(info->user_ptr[0]);
+	mutex_unlock(&drm_fabric_mutation_lock);
+}
+
+/* A nested policy cannot mark members required; check type and instance-id here. */
+static int drm_fabric_parse_new_params(struct genl_info *info,
+					enum drm_fabric_type *type,
+					const char **name, u64 *instance_id)
+{
+	struct nlattr *pa[DRM_FABRIC_A_FABRIC_NEW_PARAMS_MAX + 1];
+	struct nlattr *nest;
+	int ret;
+
+	if (GENL_REQ_ATTR_CHECK(info, DRM_FABRIC_A_FABRIC_NEW_PARAMS))
+		return -EINVAL;
+
+	nest = info->attrs[DRM_FABRIC_A_FABRIC_NEW_PARAMS];
+	ret = nla_parse_nested(pa, DRM_FABRIC_A_FABRIC_NEW_PARAMS_MAX, nest,
+			       drm_fabric_fabric_new_params_nl_policy,
+			       info->extack);
+	if (ret)
+		return ret;
+
+	if (NL_REQ_ATTR_CHECK(info->extack, nest, pa,
+			      DRM_FABRIC_A_FABRIC_NEW_PARAMS_TYPE) ||
+	    NL_REQ_ATTR_CHECK(info->extack, nest, pa,
+			      DRM_FABRIC_A_FABRIC_NEW_PARAMS_INSTANCE_ID))
+		return -EINVAL;
+
+	*type = nla_get_u32(pa[DRM_FABRIC_A_FABRIC_NEW_PARAMS_TYPE]);
+	*instance_id = nla_get_u64(pa[DRM_FABRIC_A_FABRIC_NEW_PARAMS_INSTANCE_ID]);
+	*name = pa[DRM_FABRIC_A_FABRIC_NEW_PARAMS_NAME] ?
+		nla_data(pa[DRM_FABRIC_A_FABRIC_NEW_PARAMS_NAME]) : NULL;
+	return 0;
+}
+
+int drm_fabric_nl_fabric_new_doit(struct sk_buff *skb,
+				  struct genl_info *info)
+{
+	enum drm_fabric_type type;
+	const char *name = NULL;
+	u64 instance_id;
+	struct sk_buff *msg;
+	struct nlattr *id_attr;
+	u32 fabric_id;
+	void *hdr;
+	int ret;
+
+	ret = drm_fabric_parse_new_params(info, &type, &name, &instance_id);
+	if (ret)
+		return ret;
+
+	/*
+	 * Reserve the id attribute before publishing: with the space already
+	 * committed the store cannot fail, so there is no create-then-withdraw
+	 * window.
+	 */
+	msg = nlmsg_new(NLMSG_DEFAULT_SIZE, GFP_KERNEL);
+	if (!msg)
+		return -ENOMEM;
+
+	hdr = genlmsg_put(msg, info->snd_portid, info->snd_seq,
+			  &drm_fabric_nl_family, 0,
+			  DRM_FABRIC_CMD_FABRIC_NEW);
+	if (!hdr) {
+		nlmsg_free(msg);
+		return -EMSGSIZE;
+	}
+
+	id_attr = nla_reserve(msg, DRM_FABRIC_A_FABRIC_ID, sizeof(u32));
+	if (!id_attr) {
+		nlmsg_free(msg);
+		return -EMSGSIZE;
+	}
+
+	ret = drm_fabric_user_fabric_new(type, instance_id, name, &fabric_id);
+	if (ret) {
+		nlmsg_free(msg);
+		return ret;
+	}
+
+	/* nla_put_u32() copies a host-order u32 verbatim; so does this store. */
+	*(u32 *)nla_data(id_attr) = fabric_id;
+
+	genlmsg_end(msg, hdr);
+	return genlmsg_reply(msg, info);
+}
+
+int drm_fabric_nl_fabric_del_doit(struct sk_buff *skb,
+				  struct genl_info *info)
+{
+	u32 fabric_id;
+
+	if (GENL_REQ_ATTR_CHECK(info, DRM_FABRIC_A_FABRIC_ID))
+		return -EINVAL;
+
+	fabric_id = nla_get_u32(info->attrs[DRM_FABRIC_A_FABRIC_ID]);
+	return drm_fabric_user_fabric_del(fabric_id);
+}
+
+int drm_fabric_nl_endpoint_set_doit(struct sk_buff *skb,
+				    struct genl_info *info)
+{
+	struct drm_fabric_endpoint *ep = info->user_ptr[0];
+	struct drm_fabric_endpoint_change change = {};
+
+	if (info->attrs[DRM_FABRIC_A_FABRIC_ID]) {
+		change.valid |= DRM_FABRIC_EP_CHANGE_FABRIC;
+		change.fabric_id =
+			nla_get_u32(info->attrs[DRM_FABRIC_A_FABRIC_ID]);
+	}
+
+	if (info->attrs[DRM_FABRIC_A_ADMIN_STATE]) {
+		change.valid |= DRM_FABRIC_EP_CHANGE_ADMIN;
+		change.admin =
+			nla_get_u32(info->attrs[DRM_FABRIC_A_ADMIN_STATE]);
+	}
+
+	if (!change.valid)
+		return -EINVAL;
+
+	return drm_fabric_endpoint_set(ep, &change);
+}
+
+int drm_fabric_nl_port_set_doit(struct sk_buff *skb,
+				struct genl_info *info)
+{
+	struct drm_fabric_port *port = info->user_ptr[0];
+	enum drm_fabric_admin_state admin;
+
+	if (GENL_REQ_ATTR_CHECK(info, DRM_FABRIC_A_ADMIN_STATE))
+		return -EINVAL;
+
+	admin = nla_get_u32(info->attrs[DRM_FABRIC_A_ADMIN_STATE]);
+
+	return drm_fabric_port_set_admin(port, admin);
+}
+
+int drm_fabric_nl_port_peer_new_doit(struct sk_buff *skb,
+				     struct genl_info *info)
+{
+	struct drm_fabric_port *port = info->user_ptr[0];
+	struct nlattr *pa[DRM_FABRIC_A_PEER_ATTRS_MAX + 1];
+	struct drm_fabric_peer peer = {};
+	int ret;
+
+	if (GENL_REQ_ATTR_CHECK(info, DRM_FABRIC_A_PEER))
+		return -EINVAL;
+
+	ret = nla_parse_nested(pa, DRM_FABRIC_A_PEER_ATTRS_MAX,
+			       info->attrs[DRM_FABRIC_A_PEER],
+			       drm_fabric_peer_nl_policy, info->extack);
+	if (ret)
+		return ret;
+
+	/*
+	 * A nested policy cannot require members; require the complete peer
+	 * descriptor here.
+	 */
+	if (!pa[DRM_FABRIC_A_PEER_ATTRS_PEER_ID] ||
+	    !pa[DRM_FABRIC_A_PEER_ATTRS_TYPE] ||
+	    !pa[DRM_FABRIC_A_PEER_ATTRS_PORT_INDEX])
+		return -EINVAL;
+
+	peer.peer_id = nla_get_u64(pa[DRM_FABRIC_A_PEER_ATTRS_PEER_ID]);
+	peer.peer_type = nla_get_u32(pa[DRM_FABRIC_A_PEER_ATTRS_TYPE]);
+	peer.port_index = nla_get_u32(pa[DRM_FABRIC_A_PEER_ATTRS_PORT_INDEX]);
+
+	return drm_fabric_port_peer_new(port, &peer);
+}
+
+int drm_fabric_nl_port_peer_del_doit(struct sk_buff *skb,
+				     struct genl_info *info)
+{
+	struct drm_fabric_port *port = info->user_ptr[0];
+
+	return drm_fabric_port_peer_del(port);
+}
+
 void drm_fabric_emit_port_change(struct drm_fabric_port *port, u32 generation)
 {
 	struct sk_buff *msg;
@@ -982,6 +1251,12 @@ void drm_fabric_emit_endpoint_delete(struct drm_fabric_endpoint *ep, u32 generat
 				       generation);
 }
 
+void drm_fabric_emit_endpoint_change(struct drm_fabric_endpoint *ep, u32 generation)
+{
+	drm_fabric_endpoint_event_send(DRM_FABRIC_CMD_ENDPOINT_CHANGE_NTF, ep,
+				       generation);
+}
+
 static void drm_fabric_fabric_event_send(enum drm_fabric_cmd cmd,
 					 struct drm_fabric *fabric,
 					 u32 generation)
diff --git a/drivers/gpu/drm/fabric/drm_fabric_nl.c b/drivers/gpu/drm/fabric/drm_fabric_nl.c
index 032548405146..20b277f27ae9 100644
--- a/drivers/gpu/drm/fabric/drm_fabric_nl.c
+++ b/drivers/gpu/drm/fabric/drm_fabric_nl.c
@@ -11,6 +11,19 @@
 
 #include <uapi/drm/drm_fabric.h>
 
+/* Common nested types */
+const struct nla_policy drm_fabric_fabric_new_params_nl_policy[DRM_FABRIC_A_FABRIC_NEW_PARAMS_INSTANCE_ID + 1] = {
+	[DRM_FABRIC_A_FABRIC_NEW_PARAMS_TYPE] = NLA_POLICY_RANGE(NLA_U32, 1, 1),
+	[DRM_FABRIC_A_FABRIC_NEW_PARAMS_NAME] = { .type = NLA_NUL_STRING, .len = 31, },
+	[DRM_FABRIC_A_FABRIC_NEW_PARAMS_INSTANCE_ID] = { .type = NLA_U64, },
+};
+
+const struct nla_policy drm_fabric_peer_nl_policy[DRM_FABRIC_A_PEER_ATTRS_PORT_INDEX + 1] = {
+	[DRM_FABRIC_A_PEER_ATTRS_PEER_ID] = { .type = NLA_U64, },
+	[DRM_FABRIC_A_PEER_ATTRS_TYPE] = NLA_POLICY_RANGE(NLA_U32, 1, 2),
+	[DRM_FABRIC_A_PEER_ATTRS_PORT_INDEX] = { .type = NLA_U32, },
+};
+
 /* DRM_FABRIC_CMD_FABRIC_GET - do */
 static const struct nla_policy drm_fabric_fabric_get_nl_policy[DRM_FABRIC_A_FABRIC_ID + 1] = {
 	[DRM_FABRIC_A_FABRIC_ID] = { .type = NLA_U32, },
@@ -50,6 +63,45 @@ static const struct nla_policy drm_fabric_port_stats_get_dump_nl_policy[DRM_FABR
 	[DRM_FABRIC_A_ENDPOINT_ID] = { .type = NLA_U32, },
 };
 
+/* DRM_FABRIC_CMD_FABRIC_NEW - do */
+static const struct nla_policy drm_fabric_fabric_new_nl_policy[DRM_FABRIC_A_FABRIC_NEW_PARAMS + 1] = {
+	[DRM_FABRIC_A_FABRIC_NEW_PARAMS] = NLA_POLICY_NESTED(drm_fabric_fabric_new_params_nl_policy),
+};
+
+/* DRM_FABRIC_CMD_FABRIC_DEL - do */
+static const struct nla_policy drm_fabric_fabric_del_nl_policy[DRM_FABRIC_A_FABRIC_ID + 1] = {
+	[DRM_FABRIC_A_FABRIC_ID] = { .type = NLA_U32, },
+};
+
+/* DRM_FABRIC_CMD_ENDPOINT_SET - do */
+static const struct nla_policy drm_fabric_endpoint_set_nl_policy[DRM_FABRIC_A_ADMIN_STATE + 1] = {
+	[DRM_FABRIC_A_ENDPOINT_ID] = { .type = NLA_U32, },
+	[DRM_FABRIC_A_DEV_NAME] = { .type = NLA_NUL_STRING, },
+	[DRM_FABRIC_A_BUS_NAME] = { .type = NLA_NUL_STRING, },
+	[DRM_FABRIC_A_FABRIC_ID] = { .type = NLA_U32, },
+	[DRM_FABRIC_A_ADMIN_STATE] = NLA_POLICY_RANGE(NLA_U32, 1, 2),
+};
+
+/* DRM_FABRIC_CMD_PORT_SET - do */
+static const struct nla_policy drm_fabric_port_set_nl_policy[DRM_FABRIC_A_ADMIN_STATE + 1] = {
+	[DRM_FABRIC_A_ENDPOINT_ID] = { .type = NLA_U32, },
+	[DRM_FABRIC_A_PORT_INDEX] = { .type = NLA_U32, },
+	[DRM_FABRIC_A_ADMIN_STATE] = NLA_POLICY_RANGE(NLA_U32, 1, 2),
+};
+
+/* DRM_FABRIC_CMD_PORT_PEER_NEW - do */
+static const struct nla_policy drm_fabric_port_peer_new_nl_policy[DRM_FABRIC_A_PEER + 1] = {
+	[DRM_FABRIC_A_ENDPOINT_ID] = { .type = NLA_U32, },
+	[DRM_FABRIC_A_PORT_INDEX] = { .type = NLA_U32, },
+	[DRM_FABRIC_A_PEER] = NLA_POLICY_NESTED(drm_fabric_peer_nl_policy),
+};
+
+/* DRM_FABRIC_CMD_PORT_PEER_DEL - do */
+static const struct nla_policy drm_fabric_port_peer_del_nl_policy[DRM_FABRIC_A_PORT_INDEX + 1] = {
+	[DRM_FABRIC_A_ENDPOINT_ID] = { .type = NLA_U32, },
+	[DRM_FABRIC_A_PORT_INDEX] = { .type = NLA_U32, },
+};
+
 /* Ops table for drm_fabric */
 static const struct genl_split_ops drm_fabric_nl_ops[] = {
 	{
@@ -106,6 +158,60 @@ static const struct genl_split_ops drm_fabric_nl_ops[] = {
 		.maxattr	= DRM_FABRIC_A_ENDPOINT_ID,
 		.flags		= GENL_CMD_CAP_DUMP,
 	},
+	{
+		.cmd		= DRM_FABRIC_CMD_FABRIC_NEW,
+		.pre_doit	= drm_fabric_nl_pre_doit,
+		.doit		= drm_fabric_nl_fabric_new_doit,
+		.post_doit	= drm_fabric_nl_post_doit,
+		.policy		= drm_fabric_fabric_new_nl_policy,
+		.maxattr	= DRM_FABRIC_A_FABRIC_NEW_PARAMS,
+		.flags		= GENL_ADMIN_PERM | GENL_CMD_CAP_DO,
+	},
+	{
+		.cmd		= DRM_FABRIC_CMD_FABRIC_DEL,
+		.pre_doit	= drm_fabric_nl_pre_doit,
+		.doit		= drm_fabric_nl_fabric_del_doit,
+		.post_doit	= drm_fabric_nl_post_doit,
+		.policy		= drm_fabric_fabric_del_nl_policy,
+		.maxattr	= DRM_FABRIC_A_FABRIC_ID,
+		.flags		= GENL_ADMIN_PERM | GENL_CMD_CAP_DO,
+	},
+	{
+		.cmd		= DRM_FABRIC_CMD_ENDPOINT_SET,
+		.pre_doit	= drm_fabric_nl_endpoint_pre_doit,
+		.doit		= drm_fabric_nl_endpoint_set_doit,
+		.post_doit	= drm_fabric_nl_endpoint_post_doit,
+		.policy		= drm_fabric_endpoint_set_nl_policy,
+		.maxattr	= DRM_FABRIC_A_ADMIN_STATE,
+		.flags		= GENL_ADMIN_PERM | GENL_CMD_CAP_DO,
+	},
+	{
+		.cmd		= DRM_FABRIC_CMD_PORT_SET,
+		.pre_doit	= drm_fabric_nl_port_pre_doit,
+		.doit		= drm_fabric_nl_port_set_doit,
+		.post_doit	= drm_fabric_nl_port_post_doit,
+		.policy		= drm_fabric_port_set_nl_policy,
+		.maxattr	= DRM_FABRIC_A_ADMIN_STATE,
+		.flags		= GENL_ADMIN_PERM | GENL_CMD_CAP_DO,
+	},
+	{
+		.cmd		= DRM_FABRIC_CMD_PORT_PEER_NEW,
+		.pre_doit	= drm_fabric_nl_port_pre_doit,
+		.doit		= drm_fabric_nl_port_peer_new_doit,
+		.post_doit	= drm_fabric_nl_port_post_doit,
+		.policy		= drm_fabric_port_peer_new_nl_policy,
+		.maxattr	= DRM_FABRIC_A_PEER,
+		.flags		= GENL_ADMIN_PERM | GENL_CMD_CAP_DO,
+	},
+	{
+		.cmd		= DRM_FABRIC_CMD_PORT_PEER_DEL,
+		.pre_doit	= drm_fabric_nl_port_pre_doit,
+		.doit		= drm_fabric_nl_port_peer_del_doit,
+		.post_doit	= drm_fabric_nl_port_post_doit,
+		.policy		= drm_fabric_port_peer_del_nl_policy,
+		.maxattr	= DRM_FABRIC_A_PORT_INDEX,
+		.flags		= GENL_ADMIN_PERM | GENL_CMD_CAP_DO,
+	},
 };
 
 static const struct genl_multicast_group drm_fabric_nl_mcgrps[] = {
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [RFC PATCH 10/12] drm/fabric: make the synthetic provider writable
  2026-08-24  8:09 [RFC PATCH 0/12] drm/fabric: vendor-neutral topology infrastructure for scale-up accelerator interconnects Konstantin Sinyuk
                   ` (8 preceding siblings ...)
  2026-08-24  8:09 ` [RFC PATCH 09/12] drm/fabric: implement mutation netlink operations Konstantin Sinyuk
@ 2026-08-24  8:09 ` Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 11/12] drm/fabric: add mutation KUnit tests Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 12/12] drm/fabric: add mutation netlink selftests Konstantin Sinyuk
  11 siblings, 0 replies; 13+ messages in thread
From: Konstantin Sinyuk @ 2026-08-24  8:09 UTC (permalink / raw)
  To: dri-devel
  Cc: Maarten Lankhorst, Francois Dugast, David Airlie, Simona Vetter,
	Maxime Ripard, Thomas Zimmermann, Jonathan Corbet, Shuah Khan,
	Donald Hunter, Jakub Kicinski, David S. Miller, Eric Dumazet,
	Paolo Abeni, Simon Horman, Ilia Levi, Rodrigo Vivi, linux-doc,
	linux-kselftest, netdev, linux-kernel

Add provisioning callbacks to fabricsim for endpoint attachment,
administrative-state changes and peer updates.

Reserve each endpoint's last port for userspace-managed peer provisioning;
the remaining ports stay provider-managed, so one topology covers both
peer authorities.

Add per-callback fault injection for error propagation and failure
atomicity. The new debugfs controls remain test-only.

Signed-off-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Assisted-by: GitHub-Copilot:claude-opus-4.8
---
 Documentation/gpu/drm-fabric.rst        |   4 +
 drivers/gpu/drm/fabric/drm_fabric_sim.c | 138 +++++++++++++++++-------
 2 files changed, 106 insertions(+), 36 deletions(-)

diff --git a/Documentation/gpu/drm-fabric.rst b/Documentation/gpu/drm-fabric.rst
index 4ee7cb7ef6ac..1fd48027eee3 100644
--- a/Documentation/gpu/drm-fabric.rst
+++ b/Documentation/gpu/drm-fabric.rst
@@ -454,6 +454,10 @@ ports. The switch shape links every endpoint to an opaque switch peer
 (``peer-type = switch``) whose id does not resolve to an endpoint, exercising the
 directed half-edge model without a first-class switch object.
 
+It implements the provisioning callbacks, so it is also the reference provider
+for the mutation commands. It reserves each endpoint's last port for userspace
+peer management and wires topology on the ports below it.
+
 Its debugfs knobs stimulate synthetic counter activity, operational-state changes
 and runtime endpoint add/remove. These files are unstable test controls and are
 not part of the uAPI; the stable, reviewed interface is the YAML-described
diff --git a/drivers/gpu/drm/fabric/drm_fabric_sim.c b/drivers/gpu/drm/fabric/drm_fabric_sim.c
index 7d489c6894bf..e441943c02c3 100644
--- a/drivers/gpu/drm/fabric/drm_fabric_sim.c
+++ b/drivers/gpu/drm/fabric/drm_fabric_sim.c
@@ -90,6 +90,7 @@ static struct dentry *fabricsim_debugfs_root;
 
 /* Test-only fault injection (debugfs). Sticky until cleared. */
 static bool fabricsim_fail_register;
+static bool fabricsim_fail_mutation;
 static u32 fabricsim_fail_errno = ENOMEM;
 
 /*
@@ -142,8 +143,45 @@ static int fabricsim_port_stats_get(struct drm_fabric_port *port,
 	return 0;
 }
 
+/* The mutation hooks only fail on request; the core owns the model. */
+static int fabricsim_endpoint_set(struct drm_fabric_endpoint *ep,
+				  const struct drm_fabric_endpoint_change *change,
+				  struct drm_fabric *fabric)
+{
+	if (fabricsim_fail_mutation)
+		return fabricsim_injected_errno();
+	return 0;
+}
+
+static int fabricsim_port_set(struct drm_fabric_port *port,
+			      enum drm_fabric_admin_state admin)
+{
+	if (fabricsim_fail_mutation)
+		return fabricsim_injected_errno();
+	return 0;
+}
+
+static int fabricsim_port_peer_new(struct drm_fabric_port *port,
+				   const struct drm_fabric_peer *peer)
+{
+	if (fabricsim_fail_mutation)
+		return fabricsim_injected_errno();
+	return 0;
+}
+
+static int fabricsim_port_peer_del(struct drm_fabric_port *port)
+{
+	if (fabricsim_fail_mutation)
+		return fabricsim_injected_errno();
+	return 0;
+}
+
 static const struct drm_fabric_ops fabricsim_ops = {
 	.port_stats_get		= fabricsim_port_stats_get,
+	.endpoint_set		= fabricsim_endpoint_set,
+	.port_set		= fabricsim_port_set,
+	.port_peer_new		= fabricsim_port_peer_new,
+	.port_peer_del		= fabricsim_port_peer_del,
 };
 
 #define FABRICSIM_TICK_MS 100
@@ -343,12 +381,9 @@ static void fabricsim_link_linear(void)
 		struct drm_fabric_endpoint *ep_b = fabricsim_slots[i + 1]->ep;
 		struct drm_fabric_port *pa, *pb;
 
-		/*
-		 * Interior nodes consume two ports; stop rather than walk off
-		 * an endpoint's port array if it was sized too small.
-		 */
-		if (pa_idx >= fabricsim_slots[i]->num_ports ||
-		    pb_idx >= fabricsim_slots[i + 1]->num_ports)
+		/* -1 skips the reserved last port. */
+		if (pa_idx >= fabricsim_slots[i]->num_ports - 1 ||
+		    pb_idx >= fabricsim_slots[i + 1]->num_ports - 1)
 			break;
 
 		pa = fabricsim_slots[i]->ports[pa_idx].port;
@@ -379,7 +414,8 @@ static void fabricsim_link_mesh(void)
 			if (i == j)
 				continue;
 
-			if (port_idx >= fabricsim_slots[i]->num_ports)
+			/* -1 skips the reserved last port. */
+			if (port_idx >= fabricsim_slots[i]->num_ports - 1)
 				break;
 
 			/*
@@ -421,7 +457,8 @@ static void fabricsim_link_switch(void)
 		struct drm_fabric_port *leaf_port =
 			fabricsim_slots[i]->ports[0].port;
 
-		if (!leaf_port)
+		/* Port 0 is the uplink; skip an endpoint with only the reserved port. */
+		if (!leaf_port || fabricsim_slots[i]->num_ports < 2)
 			continue;
 
 		/* One directed half-edge from the leaf to an opaque switch. */
@@ -467,11 +504,12 @@ static void fabricsim_ep_debugfs_create(struct fabricsim_ep_priv *ep_priv)
 }
 
 /*
- * Create one endpoint at @slot with @nports ports, registered as a member of
- * the synthetic fabric.  Returns the new ep_priv or an ERR_PTR.  Caller holds
+ * Create an endpoint at @slot with @nports ports. @orphan registers it
+ * without a fabric for a later ENDPOINT_SET attach. Caller holds
  * fabricsim_lock.
  */
-static struct fabricsim_ep_priv *fabricsim_make_ep(int slot, int nports)
+static struct fabricsim_ep_priv *fabricsim_make_ep(int slot, int nports,
+						   bool orphan)
 {
 	struct drm_fabric_endpoint_desc edesc = {};
 	struct drm_fabric_port_desc pdescs[16];
@@ -509,6 +547,13 @@ static struct fabricsim_ep_priv *fabricsim_make_ep(int slot, int nports)
 		pdescs[j].index = j;
 		pdescs[j].max_lane_count = 4;
 		pdescs[j].max_lane_signaling_rate_mbps = 200000; /* 200 Gbps/lane */
+		/*
+		 * Reserve the last port for userspace peer tests; a single-port
+		 * endpoint therefore has no provider-managed port.
+		 */
+		pdescs[j].peer_mode = (j == nports - 1) ?
+			DRM_FABRIC_PEER_MODE_USERSPACE :
+			DRM_FABRIC_PEER_MODE_PROVIDER;
 	}
 
 	snprintf(ep_name, sizeof(ep_name), "sim-ep%d", slot);
@@ -540,7 +585,8 @@ static struct fabricsim_ep_priv *fabricsim_make_ep(int slot, int nports)
 		timer_setup(&pp->activity_timer, fabricsim_activity_tick, 0);
 	}
 
-	ep_priv->ep = drm_fabric_endpoint_register(fabricsim_fabric, &edesc);
+	ep_priv->ep = drm_fabric_endpoint_register(orphan ? NULL : fabricsim_fabric,
+						   &edesc);
 	if (IS_ERR(ep_priv->ep)) {
 		ret = PTR_ERR(ep_priv->ep);
 		goto err_ports;
@@ -597,7 +643,7 @@ static void fabricsim_destroy_ep(struct fabricsim_ep_priv *ep_priv)
 	kfree(ep_priv);
 }
 
-static int fabricsim_add_endpoint(int nports)
+static int fabricsim_add_endpoint(int nports, bool orphan)
 {
 	struct fabricsim_ep_priv *ep_priv;
 	int slot, ret;
@@ -616,7 +662,7 @@ static int fabricsim_add_endpoint(int nports)
 		return -ENOSPC;
 	}
 
-	ep_priv = fabricsim_make_ep(slot, nports);
+	ep_priv = fabricsim_make_ep(slot, nports, orphan);
 	if (IS_ERR(ep_priv)) {
 		ret = PTR_ERR(ep_priv);
 		mutex_unlock(&fabricsim_lock);
@@ -667,7 +713,7 @@ static int fabricsim_bulk_add(int n)
 		return -EINVAL;
 
 	while (added < n) {
-		ret = fabricsim_add_endpoint(1);
+		ret = fabricsim_add_endpoint(1, false);
 		if (ret < 0)
 			return added ? added : ret;
 		added++;
@@ -713,15 +759,28 @@ static int fabricsim_parse_int(const char __user *buf, size_t count, int dflt)
 	return val;
 }
 
-static ssize_t fabricsim_add_ep_write(struct file *file, const char __user *buf,
-				      size_t count, loff_t *ppos)
+static ssize_t fabricsim_add_ep_common(const char __user *buf, size_t count,
+				       bool orphan)
 {
 	int nports = fabricsim_parse_int(buf, count, ports_per_ep);
-	int ret = fabricsim_add_endpoint(nports);
+	int ret = fabricsim_add_endpoint(nports, orphan);
 
 	return ret < 0 ? ret : count;
 }
 
+static ssize_t fabricsim_add_ep_write(struct file *file, const char __user *buf,
+				      size_t count, loff_t *ppos)
+{
+	return fabricsim_add_ep_common(buf, count, false);
+}
+
+static ssize_t fabricsim_add_orphan_write(struct file *file,
+					  const char __user *buf,
+					  size_t count, loff_t *ppos)
+{
+	return fabricsim_add_ep_common(buf, count, true);
+}
+
 static ssize_t fabricsim_del_ep_write(struct file *file, const char __user *buf,
 				      size_t count, loff_t *ppos)
 {
@@ -736,6 +795,11 @@ static const struct file_operations fabricsim_add_ep_fops = {
 	.write	= fabricsim_add_ep_write,
 };
 
+static const struct file_operations fabricsim_add_orphan_fops = {
+	.owner	= THIS_MODULE,
+	.write	= fabricsim_add_orphan_write,
+};
+
 static const struct file_operations fabricsim_del_ep_fops = {
 	.owner	= THIS_MODULE,
 	.write	= fabricsim_del_ep_write,
@@ -810,6 +874,8 @@ static const struct file_operations fabricsim_fail_errno_fops = {
  */
 static int __init fabricsim_setup_params(void)
 {
+	int wired;
+
 	/*
 	 * Reject an unrecognised topology rather than falling back to mesh, so
 	 * a typo cannot fake a shape.
@@ -830,22 +896,17 @@ static int __init fabricsim_setup_params(void)
 	if (ports_per_ep > 16)
 		ports_per_ep = 16;
 
-	/*
-	 * A mesh gives every endpoint (N-1) peers, so the busiest endpoint needs
-	 * at least (N-1) ports. The switch shape only needs one port per leaf
-	 * (a single half-edge to the opaque switch), so it is not bumped here.
-	 */
-	if (strcmp(topology, "mesh") == 0 && ports_per_ep < num_endpoints - 1)
-		ports_per_ep = num_endpoints - 1;
+	/* Peers wired per endpoint: mesh N-1, linear interior 2, switch 1. */
+	if (strcmp(topology, "mesh") == 0)
+		wired = num_endpoints - 1;
+	else if (strcmp(topology, "linear") == 0 && num_endpoints > 2)
+		wired = 2;
+	else
+		wired = 1;
 
-	/*
-	 * A linear chain gives every interior node two neighbours, so it needs
-	 * at least two ports; bump a too-small request rather than index past
-	 * the endpoint's port array.
-	 */
-	if (strcmp(topology, "linear") == 0 && num_endpoints > 2 &&
-	    ports_per_ep < 2)
-		ports_per_ep = 2;
+	/* make_ep() reserves the last port, so @wired alone drops an edge. */
+	if (ports_per_ep < wired + 1)
+		ports_per_ep = wired + 1;
 
 	fabricsim_init_eps = num_endpoints;
 
@@ -877,7 +938,7 @@ static int __init fabricsim_init(void)
 	mutex_lock(&fabricsim_lock);
 	for (i = 0; i < fabricsim_init_eps; i++) {
 		struct fabricsim_ep_priv *ep_priv =
-			fabricsim_make_ep(i, ports_per_ep);
+			fabricsim_make_ep(i, ports_per_ep, false);
 
 		if (IS_ERR(ep_priv)) {
 			ret = PTR_ERR(ep_priv);
@@ -910,6 +971,8 @@ static int __init fabricsim_init(void)
 	if (fabricsim_debugfs_root) {
 		debugfs_create_file("add_endpoint", 0200, fabricsim_debugfs_root,
 				    NULL, &fabricsim_add_ep_fops);
+		debugfs_create_file("add_orphan", 0200, fabricsim_debugfs_root,
+				    NULL, &fabricsim_add_orphan_fops);
 		debugfs_create_file("del_endpoint", 0200, fabricsim_debugfs_root,
 				    NULL, &fabricsim_del_ep_fops);
 
@@ -921,9 +984,12 @@ static int __init fabricsim_init(void)
 		debugfs_create_bool("fail_register", 0644,
 				    fabricsim_debugfs_root,
 				    &fabricsim_fail_register);
-		debugfs_create_file("fail_errno", 0644,
+		debugfs_create_bool("fail_mutation", 0644,
 				    fabricsim_debugfs_root,
-				    NULL, &fabricsim_fail_errno_fops);
+				    &fabricsim_fail_mutation);
+		debugfs_create_file("fail_errno", 0644,
+				    fabricsim_debugfs_root, NULL,
+				    &fabricsim_fail_errno_fops);
 	}
 
 	pr_info("fabricsim: registered %s topology with %d endpoints, %d ports/ep\n",
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [RFC PATCH 11/12] drm/fabric: add mutation KUnit tests
  2026-08-24  8:09 [RFC PATCH 0/12] drm/fabric: vendor-neutral topology infrastructure for scale-up accelerator interconnects Konstantin Sinyuk
                   ` (9 preceding siblings ...)
  2026-08-24  8:09 ` [RFC PATCH 10/12] drm/fabric: make the synthetic provider writable Konstantin Sinyuk
@ 2026-08-24  8:09 ` Konstantin Sinyuk
  2026-08-24  8:09 ` [RFC PATCH 12/12] drm/fabric: add mutation netlink selftests Konstantin Sinyuk
  11 siblings, 0 replies; 13+ messages in thread
From: Konstantin Sinyuk @ 2026-08-24  8:09 UTC (permalink / raw)
  To: dri-devel
  Cc: Maarten Lankhorst, Francois Dugast, David Airlie, Simona Vetter,
	Maxime Ripard, Thomas Zimmermann, Jonathan Corbet, Shuah Khan,
	Donald Hunter, Jakub Kicinski, David S. Miller, Eric Dumazet,
	Paolo Abeni, Simon Horman, Ilia Levi, Rodrigo Vivi, linux-doc,
	linux-kselftest, netdev, linux-kernel

Add KUnit coverage for the provisioning core: userspace-fabric lifetime
and its module reference, orphan attach and detach, administrative state,
peer-management authority, and failure paths that must not commit.

Threaded cases verify that mutation serializes against endpoint unregister
and competing fabric-ep-id registration, and that concurrent mutators never
overlap provider callbacks.

Signed-off-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Assisted-by: GitHub-Copilot:claude-opus-4.8
---
 drivers/gpu/drm/fabric/Kconfig           |    3 +-
 drivers/gpu/drm/fabric/drm_fabric_test.c | 1612 ++++++++++++++++++++--
 2 files changed, 1467 insertions(+), 148 deletions(-)

diff --git a/drivers/gpu/drm/fabric/Kconfig b/drivers/gpu/drm/fabric/Kconfig
index 2a70cac85b1d..7f427ac42a27 100644
--- a/drivers/gpu/drm/fabric/Kconfig
+++ b/drivers/gpu/drm/fabric/Kconfig
@@ -29,7 +29,8 @@ config DRM_FABRIC_KUNIT_TEST
 	depends on KUNIT=y || DRM_FABRIC=m
 	default KUNIT_ALL_TESTS
 	help
-	  Enable KUnit coverage for the drm_fabric object model.
+	  KUnit tests for the drm_fabric object model and topology-mutation
+	  paths.
 
 	  The tests are built into drm_fabric itself, so they need no exported
 	  symbols or test-only accessors in the production source.
diff --git a/drivers/gpu/drm/fabric/drm_fabric_test.c b/drivers/gpu/drm/fabric/drm_fabric_test.c
index 863b7cf69068..d2c88d183737 100644
--- a/drivers/gpu/drm/fabric/drm_fabric_test.c
+++ b/drivers/gpu/drm/fabric/drm_fabric_test.c
@@ -11,9 +11,15 @@
 #include <kunit/test.h>
 #include <kunit/device.h>
 
+#include <linux/atomic.h>
+#include <linux/completion.h>
+#include <linux/delay.h>
 #include <linux/device.h>
 #include <linux/err.h>
+#include <linux/jiffies.h>
+#include <linux/kthread.h>
 #include <linux/mutex.h>
+#include <linux/sched.h>
 #include <linux/string.h>
 
 #include <drm/drm_fabric.h>
@@ -35,6 +41,36 @@ static struct device *fabrictest_alloc_dev(struct kunit *test)
 	return dev;
 }
 
+static struct drm_fabric *fabrictest_find_fabric(u32 id)
+{
+	struct drm_fabric *fab;
+
+	mutex_lock(&drm_fabric_lock);
+	fab = drm_fabric_find_by_id(id);
+	mutex_unlock(&drm_fabric_lock);
+
+	return fab;
+}
+
+/*
+ * The FD-01 pin/unpin pair is only observable when drm_fabric is a loadable
+ * module; built-in, try_module_get() is a stub. Check this before trusting
+ * fabrictest_module_refcount().
+ */
+static bool fabrictest_module_refcount_observable(void)
+{
+	return IS_ENABLED(CONFIG_MODULE_UNLOAD) && IS_MODULE(CONFIG_DRM_FABRIC);
+}
+
+static int fabrictest_module_refcount(void)
+{
+#if defined(CONFIG_MODULE_UNLOAD) && IS_MODULE(CONFIG_DRM_FABRIC)
+	return module_refcount(THIS_MODULE);
+#else
+	return 0;
+#endif
+}
+
 static void fabrictest_unregister_fabric(void *fab)
 {
 	drm_fabric_unregister(fab);
@@ -759,44 +795,148 @@ static void drm_fabric_test_mesh_kn_topology(struct kunit *test)
 #undef KN_PORTS_PER_EP
 }
 
-static int fabrictest_stats_get(struct drm_fabric_port *port,
-				struct drm_fabric_port_stats *stats)
+/* Trivial provider that accepts every mutation so the core commits it. */
+static int fabrictest_mut_endpoint_set(struct drm_fabric_endpoint *ep,
+				       const struct drm_fabric_endpoint_change *change,
+				       struct drm_fabric *fabric)
 {
-	stats->read_bytes = 4096;
-	stats->write_bytes = 2048;
-	stats->link_down_count = 2;
-	stats->retrain_count = 3;
 	return 0;
 }
 
-static const struct drm_fabric_ops fabrictest_stats_ops = {
-	.port_stats_get = fabrictest_stats_get,
+static int fabrictest_mut_port_set(struct drm_fabric_port *port,
+				   enum drm_fabric_admin_state admin)
+{
+	return 0;
+}
+
+static int fabrictest_mut_port_peer_new(struct drm_fabric_port *port,
+					const struct drm_fabric_peer *peer)
+{
+	return 0;
+}
+
+static int fabrictest_mut_port_peer_del(struct drm_fabric_port *port)
+{
+	return 0;
+}
+
+static const struct drm_fabric_ops fabrictest_mut_ops = {
+	.endpoint_set	= fabrictest_mut_endpoint_set,
+	.port_set	= fabrictest_mut_port_set,
+	.port_peer_new	= fabrictest_mut_port_peer_new,
+	.port_peer_del	= fabrictest_mut_port_peer_del,
 };
 
-/* This does not exercise netlink dispatch or error propagation. */
-static void drm_fabric_test_port_stats_ops_registration(struct kunit *test)
+/*
+ * Provider that rejects every mutation: the core calls it before committing, so
+ * a failure must leave state, generation and notifications untouched.
+ */
+static int fabrictest_fail_port_set(struct drm_fabric_port *port,
+				    enum drm_fabric_admin_state admin)
 {
-	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	return -EIO;
+}
+
+static int fabrictest_fail_port_peer_new(struct drm_fabric_port *port,
+					 const struct drm_fabric_peer *peer)
+{
+	return -EIO;
+}
+
+static const struct drm_fabric_ops fabrictest_fail_ops = {
+	.port_set	= fabrictest_fail_port_set,
+	.port_peer_new	= fabrictest_fail_port_peer_new,
+};
+
+/*
+ * Internal mutators assert drm_fabric_mutation_lock is held, matching the
+ * netlink pre/post_doit contract; wrap each with the lock here.
+ */
+static int fabrictest_ep_set_locked(struct drm_fabric_endpoint *ep,
+				    const struct drm_fabric_endpoint_change *change)
+{
+	int ret;
+
+	mutex_lock(&drm_fabric_mutation_lock);
+	ret = drm_fabric_endpoint_set(ep, change);
+	mutex_unlock(&drm_fabric_mutation_lock);
+	return ret;
+}
+
+static int fabrictest_port_admin_locked(struct drm_fabric_port *port,
+					enum drm_fabric_admin_state admin)
+{
+	int ret;
+
+	mutex_lock(&drm_fabric_mutation_lock);
+	ret = drm_fabric_port_set_admin(port, admin);
+	mutex_unlock(&drm_fabric_mutation_lock);
+	return ret;
+}
+
+static int fabrictest_port_peer_new_locked(struct drm_fabric_port *port,
+					   const struct drm_fabric_peer *peer)
+{
+	int ret;
+
+	mutex_lock(&drm_fabric_mutation_lock);
+	ret = drm_fabric_port_peer_new(port, peer);
+	mutex_unlock(&drm_fabric_mutation_lock);
+	return ret;
+}
+
+static int fabrictest_port_peer_del_locked(struct drm_fabric_port *port)
+{
+	int ret;
+
+	mutex_lock(&drm_fabric_mutation_lock);
+	ret = drm_fabric_port_peer_del(port);
+	mutex_unlock(&drm_fabric_mutation_lock);
+	return ret;
+}
+
+static int fabrictest_user_fabric_new_locked(enum drm_fabric_type type,
+					     u64 instance_id, const char *name,
+					     u32 *fabric_id_out)
+{
+	int ret;
+
+	mutex_lock(&drm_fabric_mutation_lock);
+	ret = drm_fabric_user_fabric_new(type, instance_id, name, fabric_id_out);
+	mutex_unlock(&drm_fabric_mutation_lock);
+	return ret;
+}
+
+static int fabrictest_user_fabric_del_locked(u32 fabric_id)
+{
+	int ret;
+
+	mutex_lock(&drm_fabric_mutation_lock);
+	ret = drm_fabric_user_fabric_del(fabric_id);
+	mutex_unlock(&drm_fabric_mutation_lock);
+	return ret;
+}
+
+static void drm_fabric_test_failed_mutation_no_commit(struct kunit *test)
+{
+	struct device *dev = fabrictest_alloc_dev(test);
 	struct drm_fabric_desc fdesc = {
-		.type = DRM_FABRIC_TYPE_SYNTHETIC,
-		.name = "test-stats",
+		.type = DRM_FABRIC_TYPE_SYNTHETIC, .name = "test-failmut",
+	};
+	/* USERSPACE peer_mode so PORT_PEER_NEW reaches the provider below. */
+	struct drm_fabric_port_desc pdesc = {
+		.index = 0, .max_lane_count = 4,
+		.peer_mode = DRM_FABRIC_PEER_MODE_USERSPACE,
 	};
-	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
 	struct drm_fabric_endpoint_desc edesc = {
-		.fabric_ep_id = 0x5A,
-		.parent = fabrictest_dev,
-		.ops = &fabrictest_stats_ops,
-		.ports = &pdesc,
-		.num_ports = 1,
+		.fabric_ep_id = 0x66, .parent = dev, .ops = &fabrictest_fail_ops,
+		.ports = &pdesc, .num_ports = 1,
 	};
-	struct drm_fabric_endpoint_desc edesc_noops = {
-		.fabric_ep_id = 0x5B,
-		.parent = fabrictest_dev,
-		.ports = &pdesc,
-		.num_ports = 1,
+	struct drm_fabric_peer peer = {
+		.peer_id = 0x67, .peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
 	};
-	struct drm_fabric_port_stats stats = {};
-	struct drm_fabric_endpoint *ep, *ep_noops;
+	enum drm_fabric_admin_state admin0;
+	struct drm_fabric_endpoint *ep;
 	struct drm_fabric_port *port;
 	struct drm_fabric *fab;
 	u32 gen;
@@ -813,188 +953,194 @@ static void drm_fabric_test_port_stats_ops_registration(struct kunit *test)
 
 	port = fabrictest_port(ep, 0);
 	KUNIT_ASSERT_NOT_NULL(test, port);
+	admin0 = port->admin_state;
+	gen = drm_fabric_base_seq;
 
-	KUNIT_ASSERT_NOT_NULL(test, ep->ops);
-	KUNIT_ASSERT_NOT_NULL(test, ep->ops->port_stats_get);
-
-	/* A stats read is not a topology change: seq must not move. */
-	gen = fabrictest_seq_read();
-	KUNIT_EXPECT_EQ(test, ep->ops->port_stats_get(port, &stats), 0);
-	KUNIT_EXPECT_EQ(test, fabrictest_seq_read(), gen);
-	KUNIT_EXPECT_EQ(test, stats.read_bytes, 4096ULL);
-	KUNIT_EXPECT_EQ(test, stats.write_bytes, 2048ULL);
-	KUNIT_EXPECT_EQ(test, stats.link_down_count, 2ULL);
-	KUNIT_EXPECT_EQ(test, stats.retrain_count, 3ULL);
+	KUNIT_EXPECT_EQ(test,
+			fabrictest_port_admin_locked(port, DRM_FABRIC_ADMIN_STATE_UP), -EIO);
+	KUNIT_EXPECT_EQ(test, port->admin_state, admin0);
+	KUNIT_EXPECT_EQ(test, drm_fabric_base_seq, gen);
 
-	ep_noops = drm_fabric_endpoint_register(fab, &edesc_noops);
-	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_noops));
-	KUNIT_ASSERT_EQ(test, 0,
-			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_noops));
-	KUNIT_EXPECT_TRUE(test, !ep_noops->ops || !ep_noops->ops->port_stats_get);
+	KUNIT_EXPECT_EQ(test, fabrictest_port_peer_new_locked(port, &peer), -EIO);
+	KUNIT_EXPECT_FALSE(test, port->has_peer);
+	KUNIT_EXPECT_EQ(test, drm_fabric_base_seq, gen);
 }
 
-/*
- * Unregistering an endpoint that has a peer link must clear only that
- * endpoint's own port record; it must not touch the still-registered far
- * side's peer record. (Contrast with drm_fabric_port_unset_peer(), which
- * clears a peer explicitly and is covered separately.)
- */
-static void drm_fabric_test_local_unplug_keeps_edge(struct kunit *test)
+static void drm_fabric_test_orphan_attach_detach(struct kunit *test)
 {
 	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
 	struct drm_fabric_desc fdesc = {
 		.type = DRM_FABRIC_TYPE_SYNTHETIC,
-		.name = "test-unplug",
+		.name = "test-attach",
 	};
 	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
-	struct drm_fabric_endpoint_desc eadesc = {
-		.fabric_ep_id = 0xA0,
-		.name = "unplug-a",
-		.parent = fabrictest_dev,
-		.ports = &pdesc,
-		.num_ports = 1,
-	};
-	struct drm_fabric_endpoint_desc ebdesc = {
-		.fabric_ep_id = 0xB0,
-		.name = "unplug-b",
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0x55,
+		.name = "orphan-ep",
 		.parent = fabrictest_dev,
+		.ops = &fabrictest_mut_ops,
 		.ports = &pdesc,
 		.num_ports = 1,
 	};
-	struct drm_fabric_peer to_b = {
-		.peer_id = 0xB0, .peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
-	};
-	struct drm_fabric_peer to_a = {
-		.peer_id = 0xA0, .peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
-	};
+	struct drm_fabric_endpoint_change change;
 	struct drm_fabric *fab;
-	struct drm_fabric_endpoint *ep_a, *ep_b;
-	struct drm_fabric_port *pa, *pb;
+	struct drm_fabric_endpoint *ep;
+	u32 seq;
 
 	fab = drm_fabric_register(&fdesc);
 	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
 	KUNIT_ASSERT_EQ(test, 0,
 			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
 
-	ep_a = drm_fabric_endpoint_register(fab, &eadesc);
-	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_a));
-	KUNIT_ASSERT_EQ(test, 0,
-			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_a));
-
-	ep_b = drm_fabric_endpoint_register(fab, &ebdesc);
-	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_b));
+	ep = drm_fabric_endpoint_register(NULL, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
 	KUNIT_ASSERT_EQ(test, 0,
-			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_b));
-
-	pa = fabrictest_port(ep_a, 0);
-	pb = fabrictest_port(ep_b, 0);
-	KUNIT_ASSERT_NOT_NULL(test, pa);
-	KUNIT_ASSERT_NOT_NULL(test, pb);
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
 
-	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(pa, &to_b), 0);
-	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(pb, &to_a), 0);
-	KUNIT_EXPECT_TRUE(test, pa->has_peer);
+	KUNIT_EXPECT_NULL(test, ep->fabric);
+	KUNIT_EXPECT_EQ(test, ep->admin_state, DRM_FABRIC_ADMIN_STATE_DOWN);
 
 	/*
-	 * Remove B without retracting its peer first, modelling abrupt provider
-	 * teardown.
+	 * Membership and admin state are independent in the core; provider
+	 * policy may reject combinations such as admin-up on an orphan.
 	 */
-	kunit_release_action(test, fabrictest_unregister_endpoint, ep_b);
+	seq = drm_fabric_base_seq;
+	change = (struct drm_fabric_endpoint_change){
+		.valid = DRM_FABRIC_EP_CHANGE_ADMIN, .admin = DRM_FABRIC_ADMIN_STATE_UP,
+	};
+	KUNIT_EXPECT_EQ(test, fabrictest_ep_set_locked(ep, &change), 0);
+	KUNIT_EXPECT_EQ(test, ep->admin_state, DRM_FABRIC_ADMIN_STATE_UP);
+	KUNIT_EXPECT_NE(test, drm_fabric_base_seq, seq);
 
-	/* The surviving half-edge must be byte-unchanged: no field mutated. */
-	KUNIT_EXPECT_TRUE(test, pa->has_peer);
-	KUNIT_EXPECT_MEMEQ(test, &pa->peer, &to_b, sizeof(pa->peer));
+	/* Attaching changes membership only; admin_state is untouched. */
+	change = (struct drm_fabric_endpoint_change){
+		.valid = DRM_FABRIC_EP_CHANGE_FABRIC, .fabric_id = fab->id,
+	};
+	KUNIT_EXPECT_EQ(test, fabrictest_ep_set_locked(ep, &change), 0);
+	KUNIT_EXPECT_PTR_EQ(test, ep->fabric, fab);
+	KUNIT_EXPECT_EQ(test, ep->admin_state, DRM_FABRIC_ADMIN_STATE_UP);
+
+	/* Detach while admin is UP is accepted: membership clears, admin is kept. */
+	change = (struct drm_fabric_endpoint_change){
+		.valid = DRM_FABRIC_EP_CHANGE_FABRIC, .fabric_id = 0,
+	};
+	KUNIT_EXPECT_EQ(test, fabrictest_ep_set_locked(ep, &change), 0);
+	KUNIT_EXPECT_NULL(test, ep->fabric);
+	KUNIT_EXPECT_EQ(test, ep->admin_state, DRM_FABRIC_ADMIN_STATE_UP);
+
+	change = (struct drm_fabric_endpoint_change){
+		.valid = DRM_FABRIC_EP_CHANGE_ADMIN, .admin = DRM_FABRIC_ADMIN_STATE_DOWN,
+	};
+	KUNIT_EXPECT_EQ(test, fabrictest_ep_set_locked(ep, &change), 0);
+	KUNIT_EXPECT_EQ(test, ep->admin_state, DRM_FABRIC_ADMIN_STATE_DOWN);
+
+	change = (struct drm_fabric_endpoint_change){
+		.valid = DRM_FABRIC_EP_CHANGE_FABRIC, .fabric_id = 0,
+	};
+	KUNIT_EXPECT_EQ(test, fabrictest_ep_set_locked(ep, &change), 0);
+	KUNIT_EXPECT_NULL(test, ep->fabric);
 }
 
 /*
- * A's peer record names a port index, not an object; registering and then
- * unregistering an unrelated third endpoint must not perturb it.
+ * fabric_ep_id must be unique among a fabric's members (peer descriptors
+ * resolve against it); orphan ids do not resolve and may collide.
  */
-static void drm_fabric_test_remote_peer_retained(struct kunit *test)
+static void drm_fabric_test_ep_id_unique(struct kunit *test)
 {
-	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct device *dev = fabrictest_alloc_dev(test);
 	struct drm_fabric_desc fdesc = {
-		.type = DRM_FABRIC_TYPE_SYNTHETIC,
-		.name = "test-remote",
+		.type = DRM_FABRIC_TYPE_SYNTHETIC, .name = "test-epid",
 	};
 	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
-	struct drm_fabric_endpoint_desc eadesc = {
-		.fabric_ep_id = 0xA0,
-		.name = "remote-a",
-		.parent = fabrictest_dev,
-		.ports = &pdesc,
-		.num_ports = 1,
-	};
-	struct drm_fabric_endpoint_desc ecdesc = {
-		.fabric_ep_id = 0xC0,
-		.name = "remote-c",
-		.parent = fabrictest_dev,
-		.ports = &pdesc,
-		.num_ports = 1,
-	};
-	/* 0xBEEF has no local endpoint object. */
-	struct drm_fabric_peer remote = {
-		.peer_id = 0xBEEF, .peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+	struct drm_fabric_endpoint_desc edesc = {
+		.name = "epid", .parent = dev, .ops = &fabrictest_mut_ops,
+		.ports = &pdesc, .num_ports = 1,
 	};
+	struct drm_fabric_endpoint_change attach;
+	struct drm_fabric_endpoint *ep_a, *ep_dup, *orphan_a, *orphan_b, *orphan_c;
 	struct drm_fabric *fab;
-	struct drm_fabric_endpoint *ep_a, *ep_c;
-	struct drm_fabric_port *pa;
 
 	fab = drm_fabric_register(&fdesc);
 	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
 	KUNIT_ASSERT_EQ(test, 0,
 			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
 
-	ep_a = drm_fabric_endpoint_register(fab, &eadesc);
+	edesc.fabric_ep_id = 0x42;
+	ep_a = drm_fabric_endpoint_register(fab, &edesc);
 	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_a));
 	KUNIT_ASSERT_EQ(test, 0,
 			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_a));
 
-	pa = fabrictest_port(ep_a, 0);
-	KUNIT_ASSERT_NOT_NULL(test, pa);
+	/* A second member reusing that id is rejected. */
+	edesc.fabric_ep_id = 0x42;
+	ep_dup = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_EXPECT_TRUE(test, IS_ERR(ep_dup));
+	KUNIT_EXPECT_EQ(test, PTR_ERR(ep_dup), -EEXIST);
 
-	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(pa, &remote), 0);
-	KUNIT_EXPECT_TRUE(test, pa->has_peer);
+	edesc.fabric_ep_id = 0x43;
+	ep_dup = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_dup));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_dup));
 
-	ep_c = drm_fabric_endpoint_register(fab, &ecdesc);
-	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_c));
+	/* Orphans do not resolve peers, so two may share an id. */
+	edesc.fabric_ep_id = 0x42;
+	orphan_a = drm_fabric_endpoint_register(NULL, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(orphan_a));
 	KUNIT_ASSERT_EQ(test, 0,
-			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_c));
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, orphan_a));
 
-	kunit_release_action(test, fabrictest_unregister_endpoint, ep_c);
+	edesc.fabric_ep_id = 0x42;
+	orphan_b = drm_fabric_endpoint_register(NULL, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(orphan_b));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, orphan_b));
 
-	KUNIT_EXPECT_TRUE(test, pa->has_peer);
-	KUNIT_EXPECT_MEMEQ(test, &pa->peer, &remote, sizeof(pa->peer));
+	/* Attaching an orphan whose id collides with a member is rejected. */
+	attach = (struct drm_fabric_endpoint_change){
+		.valid = DRM_FABRIC_EP_CHANGE_FABRIC, .fabric_id = fab->id,
+	};
+	KUNIT_EXPECT_EQ(test, fabrictest_ep_set_locked(orphan_a, &attach), -EEXIST);
+	KUNIT_EXPECT_NULL(test, orphan_a->fabric);
+
+	edesc.fabric_ep_id = 0x44;
+	orphan_c = drm_fabric_endpoint_register(NULL, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(orphan_c));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, orphan_c));
+
+	KUNIT_EXPECT_EQ(test, fabrictest_ep_set_locked(orphan_c, &attach), 0);
+	KUNIT_EXPECT_PTR_EQ(test, orphan_c->fabric, fab);
 }
 
-/*
- * Removing an endpoint with multiple peered ports must bump the topology
- * generation exactly once, not once per port torn down.
- */
-static void drm_fabric_test_subtree_delete_single_bump(struct kunit *test)
+static void drm_fabric_test_port_admin_peer(struct kunit *test)
 {
 	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
 	struct drm_fabric_desc fdesc = {
 		.type = DRM_FABRIC_TYPE_SYNTHETIC,
-		.name = "test-subtree",
+		.name = "test-portadmin",
 	};
-	struct drm_fabric_port_desc pdescs[3] = {
-		{ .index = 0, .max_lane_count = 4 },
-		{ .index = 1, .max_lane_count = 4 },
-		{ .index = 2, .max_lane_count = 4 },
+	/* userspace-managed so this test can drive the PORT_PEER_NEW path. */
+	struct drm_fabric_port_desc pdesc = {
+		.index = 0, .max_lane_count = 4,
+		.peer_mode = DRM_FABRIC_PEER_MODE_USERSPACE,
 	};
 	struct drm_fabric_endpoint_desc edesc = {
-		.fabric_ep_id = 0xD0,
-		.name = "subtree-ep",
+		.fabric_ep_id = 0x77,
 		.parent = fabrictest_dev,
-		.ports = pdescs,
-		.num_ports = 3,
+		.ops = &fabrictest_mut_ops,
+		.ports = &pdesc,
+		.num_ports = 1,
 	};
 	struct drm_fabric_peer peer = {
-		.peer_id = 0xD1, .peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+		.peer_id = 0x88,
+		.peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+		.port_index = 2,
 	};
 	struct drm_fabric *fab;
 	struct drm_fabric_endpoint *ep;
+	struct drm_fabric_port *port;
+	u32 seq;
 
 	fab = drm_fabric_register(&fdesc);
 	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
@@ -1006,13 +1152,1173 @@ static void drm_fabric_test_subtree_delete_single_bump(struct kunit *test)
 	KUNIT_ASSERT_EQ(test, 0,
 			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
 
-	/* Two of the three ports carry a half-edge. */
-	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(fabrictest_port(ep, 0), &peer), 0);
-	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(fabrictest_port(ep, 1), &peer), 0);
+	port = fabrictest_port(ep, 0);
+	KUNIT_ASSERT_NOT_NULL(test, port);
 
-	fabrictest_seed_seq(test, 100);
-	kunit_release_action(test, fabrictest_unregister_endpoint, ep);
-	KUNIT_EXPECT_EQ(test, fabrictest_seq_read(), 101);
+	seq = drm_fabric_base_seq;
+	KUNIT_EXPECT_EQ(test, fabrictest_port_admin_locked(port, DRM_FABRIC_ADMIN_STATE_UP), 0);
+	KUNIT_EXPECT_EQ(test, port->admin_state, DRM_FABRIC_ADMIN_STATE_UP);
+	KUNIT_EXPECT_NE(test, drm_fabric_base_seq, seq);
+
+	drm_fabric_port_set_oper(port, DRM_FABRIC_PORT_STATE_ACTIVE);
+
+	seq = drm_fabric_base_seq;
+	KUNIT_EXPECT_EQ(test, fabrictest_port_admin_locked(port, DRM_FABRIC_ADMIN_STATE_DOWN), 0);
+	KUNIT_EXPECT_EQ(test, port->admin_state, DRM_FABRIC_ADMIN_STATE_DOWN);
+	KUNIT_EXPECT_NE(test, drm_fabric_base_seq, seq);
+	/* Admin and operational state are independent; admin-down preserves oper. */
+	KUNIT_EXPECT_EQ(test, port->oper_state, DRM_FABRIC_PORT_STATE_ACTIVE);
+
+	seq = drm_fabric_base_seq;
+	KUNIT_EXPECT_EQ(test, fabrictest_port_peer_new_locked(port, &peer), 0);
+	KUNIT_EXPECT_TRUE(test, port->has_peer);
+	KUNIT_EXPECT_EQ(test, port->peer.peer_id, 0x88ULL);
+	KUNIT_EXPECT_NE(test, drm_fabric_base_seq, seq);
+
+	KUNIT_EXPECT_EQ(test, fabrictest_port_peer_new_locked(port, &peer), -EEXIST);
+
+	seq = drm_fabric_base_seq;
+	KUNIT_EXPECT_EQ(test, fabrictest_port_peer_del_locked(port), 0);
+	KUNIT_EXPECT_FALSE(test, port->has_peer);
+	KUNIT_EXPECT_NE(test, drm_fabric_base_seq, seq);
+
+	KUNIT_EXPECT_EQ(test, fabrictest_port_peer_del_locked(port), -ENOENT);
+}
+
+/*
+ * PROVIDER ports take peers only from drm_fabric_port_set_peer() (kernel side);
+ * USERSPACE ports take peers only through the locked PEER_NEW/DEL mutators.
+ * Each rejects the other's path with -EOPNOTSUPP.
+ */
+static void drm_fabric_test_peer_mode(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-peermode",
+	};
+	struct drm_fabric_port_desc pdescs[2] = {
+		{ .index = 0, .max_lane_count = 4,
+		  .peer_mode = DRM_FABRIC_PEER_MODE_PROVIDER },
+		{ .index = 1, .max_lane_count = 4,
+		  .peer_mode = DRM_FABRIC_PEER_MODE_USERSPACE },
+	};
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0x91,
+		.parent = fabrictest_dev,
+		.ops = &fabrictest_mut_ops,
+		.ports = pdescs,
+		.num_ports = 2,
+	};
+	struct drm_fabric_peer peer = {
+		.peer_id = 0xA1,
+		.peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+		.port_index = 1,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep;
+	struct drm_fabric_port *pport, *uport;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	pport = fabrictest_port(ep, 0);
+	uport = fabrictest_port(ep, 1);
+	KUNIT_ASSERT_NOT_NULL(test, pport);
+	KUNIT_ASSERT_NOT_NULL(test, uport);
+
+	/* Provider-managed port: the provider programs it; the user path is refused. */
+	KUNIT_EXPECT_EQ(test, drm_fabric_port_set_peer(pport, &peer), 0);
+	KUNIT_EXPECT_TRUE(test, pport->has_peer);
+	KUNIT_EXPECT_EQ(test, fabrictest_port_peer_new_locked(pport, &peer), -EOPNOTSUPP);
+	KUNIT_EXPECT_EQ(test, fabrictest_port_peer_del_locked(pport), -EOPNOTSUPP);
+	/* The refused user calls leave the provider's peer intact. */
+	KUNIT_EXPECT_TRUE(test, pport->has_peer);
+	/* Same-source duplicate/absent errors are preserved. */
+	KUNIT_EXPECT_EQ(test, drm_fabric_port_set_peer(pport, &peer), -EEXIST);
+	KUNIT_EXPECT_EQ(test, drm_fabric_port_unset_peer(pport), 0);
+	KUNIT_EXPECT_EQ(test, drm_fabric_port_unset_peer(pport), -ENOENT);
+
+	/* Userspace-managed port: the user path programs it; the provider is refused. */
+	KUNIT_EXPECT_EQ(test, fabrictest_port_peer_new_locked(uport, &peer), 0);
+	KUNIT_EXPECT_TRUE(test, uport->has_peer);
+	KUNIT_EXPECT_EQ(test, drm_fabric_port_set_peer(uport, &peer), -EOPNOTSUPP);
+	KUNIT_EXPECT_EQ(test, drm_fabric_port_unset_peer(uport), -EOPNOTSUPP);
+	/* The refused provider calls leave the userspace peer intact. */
+	KUNIT_EXPECT_TRUE(test, uport->has_peer);
+	KUNIT_EXPECT_EQ(test, uport->peer.peer_id, 0xA1ULL);
+	KUNIT_EXPECT_EQ(test, fabrictest_port_peer_new_locked(uport, &peer), -EEXIST);
+	KUNIT_EXPECT_EQ(test, fabrictest_port_peer_del_locked(uport), 0);
+	KUNIT_EXPECT_EQ(test, fabrictest_port_peer_del_locked(uport), -ENOENT);
+}
+
+/*
+ * Model A reports operational state from the provisioning callback.
+ * drm_fabric_lock must be dropped across the callback to avoid recursion.
+ */
+struct fabrictest_model_a_ctx {
+	unsigned int	calls;
+	bool		mutation_lock_held;
+	bool		fabric_lock_held;
+};
+
+static int fabrictest_model_a_port_set(struct drm_fabric_port *port,
+				       enum drm_fabric_admin_state admin)
+{
+	struct fabrictest_model_a_ctx *ctx = port->endpoint->priv;
+
+	ctx->calls++;
+#ifdef CONFIG_LOCKDEP
+	ctx->mutation_lock_held = lockdep_is_held(&drm_fabric_mutation_lock);
+	ctx->fabric_lock_held = lockdep_is_held(&drm_fabric_lock);
+#endif
+
+	lockdep_assert_held(&drm_fabric_mutation_lock);
+	lockdep_assert_not_held(&drm_fabric_lock);
+
+	/* Takes drm_fabric_lock: a core that had not dropped it would deadlock here. */
+	drm_fabric_port_set_oper(port, DRM_FABRIC_PORT_STATE_ACTIVE);
+	return 0;
+}
+
+static const struct drm_fabric_ops fabrictest_model_a_ops = {
+	.port_set = fabrictest_model_a_port_set,
+};
+
+static void drm_fabric_test_model_a_oper_report(struct kunit *test)
+{
+	struct device *dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC, .name = "test-modela",
+	};
+	struct drm_fabric_port_desc pdesc = {
+		.index = 0, .max_lane_count = 4,
+		.peer_mode = DRM_FABRIC_PEER_MODE_USERSPACE,
+	};
+	struct fabrictest_model_a_ctx ctx = {};
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0x5a,
+		.parent = dev,
+		.ops = &fabrictest_model_a_ops,
+		.priv = &ctx,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep;
+	struct drm_fabric_port *port;
+	u32 seq;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	port = fabrictest_port(ep, 0);
+	KUNIT_ASSERT_NOT_NULL(test, port);
+
+	KUNIT_ASSERT_NE(test, port->admin_state, DRM_FABRIC_ADMIN_STATE_UP);
+	KUNIT_ASSERT_NE(test, port->oper_state, DRM_FABRIC_PORT_STATE_ACTIVE);
+
+	seq = drm_fabric_base_seq;
+
+	KUNIT_EXPECT_EQ(test,
+			fabrictest_port_admin_locked(port, DRM_FABRIC_ADMIN_STATE_UP), 0);
+
+	KUNIT_EXPECT_EQ(test, ctx.calls, 1u);
+	if (IS_ENABLED(CONFIG_LOCKDEP)) {
+		KUNIT_EXPECT_TRUE(test, ctx.mutation_lock_held);
+		KUNIT_EXPECT_FALSE(test, ctx.fabric_lock_held);
+	}
+
+	/*
+	 * The synchronous oper report committed inside the callback and the
+	 * administrative state committed after it, with no recursive deadlock.
+	 */
+	KUNIT_EXPECT_EQ(test, port->oper_state, DRM_FABRIC_PORT_STATE_ACTIVE);
+	KUNIT_EXPECT_EQ(test, port->admin_state, DRM_FABRIC_ADMIN_STATE_UP);
+
+	KUNIT_EXPECT_NE(test, drm_fabric_base_seq, seq);
+
+	/*
+	 * Repeating the same admin state is a no-op: no second provider call,
+	 * no second seq bump.
+	 */
+	seq = drm_fabric_base_seq;
+	KUNIT_EXPECT_EQ(test,
+			fabrictest_port_admin_locked(port, DRM_FABRIC_ADMIN_STATE_UP), 0);
+	KUNIT_EXPECT_EQ(test, ctx.calls, 1u);
+	KUNIT_EXPECT_EQ(test, drm_fabric_base_seq, seq);
+}
+
+struct fabrictest_unreg_race {
+	struct drm_fabric_endpoint *ep;
+	struct completion started;
+	struct completion finished;
+};
+
+static int fabrictest_unreg_thread(void *arg)
+{
+	struct fabrictest_unreg_race *r = arg;
+
+	complete(&r->started);
+	drm_fabric_endpoint_unregister(r->ep);
+	complete(&r->finished);
+
+	/* Stay alive until the test reaps us, so kthread_stop() is valid. */
+	while (!kthread_should_stop())
+		schedule_timeout_interruptible(msecs_to_jiffies(10));
+	return 0;
+}
+
+/*
+ * drm_fabric_endpoint_unregister() must take mutation_lock itself, so it cannot
+ * race a concurrent mutator: it blocks until the lock is free.
+ */
+static void drm_fabric_test_unregister_serializes_mutation(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-unreg",
+	};
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0x99,
+		.parent = fabrictest_dev,
+		.ops = &fabrictest_mut_ops,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct fabrictest_unreg_race r;
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep;
+	struct task_struct *task;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	/* The worker owns this endpoint's unregister, so no kunit teardown action. */
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+
+	r.ep = ep;
+	init_completion(&r.started);
+	init_completion(&r.finished);
+
+	mutex_lock(&drm_fabric_mutation_lock);
+
+	task = kthread_run(fabrictest_unreg_thread, &r, "fabrtest-unreg");
+	if (IS_ERR(task)) {
+		/* A fatal assertion would skip cleanup; unwind before failing. */
+		mutex_unlock(&drm_fabric_mutation_lock);
+		drm_fabric_endpoint_unregister(ep);
+		KUNIT_FAIL(test, "kthread_run failed: %pe", task);
+		return;
+	}
+
+	KUNIT_EXPECT_GT(test,
+			wait_for_completion_timeout(&r.started, msecs_to_jiffies(1000)),
+			0);
+	msleep(50);
+
+	/* Racer entered unregister() but is still stuck waiting for the lock. */
+	KUNIT_EXPECT_FALSE(test, try_wait_for_completion(&r.finished));
+
+	/* Release => unregister proceeds and must finish promptly. */
+	mutex_unlock(&drm_fabric_mutation_lock);
+	KUNIT_EXPECT_GT(test,
+			wait_for_completion_timeout(&r.finished, msecs_to_jiffies(5000)),
+			0);
+
+	kthread_stop(task);
+}
+
+static void fabrictest_stop_thread(void *t)
+{
+	kthread_stop(t);
+}
+
+/*
+ * Attach and endpoint registration compete for one fabric_ep_id.
+ * mutation_lock makes registration wait, then fail with -EEXIST.
+ */
+struct fabrictest_l4 {
+	struct completion cb_entered;
+	struct completion cb_release;
+	struct completion attach_done;
+	struct completion reg_done;
+	int attach_ret;
+	struct drm_fabric_endpoint *reg_ep;
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *orphan;
+	struct device *dev;
+	u64 ep_id;
+};
+
+/*
+ * Stalls inside the provider callback (mutation_lock held) so a second thread
+ * can be started and observed to block on the same fabric_ep_id.
+ */
+static int fabrictest_l4_endpoint_set(struct drm_fabric_endpoint *ep,
+				      const struct drm_fabric_endpoint_change *change,
+				      struct drm_fabric *fabric)
+{
+	struct fabrictest_l4 *l4 = ep->priv;
+
+	complete(&l4->cb_entered);
+	/* Bounded so a test abort can never wedge teardown on this thread. */
+	wait_for_completion_timeout(&l4->cb_release, msecs_to_jiffies(10000));
+	return 0;
+}
+
+static const struct drm_fabric_ops fabrictest_l4_ops = {
+	.endpoint_set = fabrictest_l4_endpoint_set,
+};
+
+static int fabrictest_l4_attach_thread(void *arg)
+{
+	struct fabrictest_l4 *l4 = arg;
+	struct drm_fabric_endpoint_change attach = {
+		.valid = DRM_FABRIC_EP_CHANGE_FABRIC,
+		.fabric_id = l4->fab->id,
+	};
+
+	l4->attach_ret = fabrictest_ep_set_locked(l4->orphan, &attach);
+	complete(&l4->attach_done);
+
+	while (!kthread_should_stop())
+		schedule_timeout_interruptible(msecs_to_jiffies(10));
+	return 0;
+}
+
+static int fabrictest_l4_register_thread(void *arg)
+{
+	struct fabrictest_l4 *l4 = arg;
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = l4->ep_id,
+		.name = "l4-b",
+		.parent = l4->dev,
+		.ops = &fabrictest_l4_ops,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+
+	l4->reg_ep = drm_fabric_endpoint_register(l4->fab, &edesc);
+	complete(&l4->reg_done);
+
+	while (!kthread_should_stop())
+		schedule_timeout_interruptible(msecs_to_jiffies(10));
+	return 0;
+}
+
+static void drm_fabric_test_attach_register_collision(struct kunit *test)
+{
+	struct device *dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC, .name = "test-l4",
+	};
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
+	struct drm_fabric_endpoint_desc edesc;
+	struct fabrictest_l4 *l4;
+	struct task_struct *t1, *t2;
+
+	l4 = kunit_kzalloc(test, sizeof(*l4), GFP_KERNEL);
+	KUNIT_ASSERT_NOT_NULL(test, l4);
+	init_completion(&l4->cb_entered);
+	init_completion(&l4->cb_release);
+	init_completion(&l4->attach_done);
+	init_completion(&l4->reg_done);
+	l4->dev = dev;
+	l4->ep_id = 0x4242;
+
+	l4->fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(l4->fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, l4->fab));
+
+	/* Orphan A with fabric_ep_id X and a blocking endpoint_set callback. */
+	edesc = (struct drm_fabric_endpoint_desc){
+		.fabric_ep_id = l4->ep_id,
+		.name = "l4-a",
+		.parent = dev,
+		.ops = &fabrictest_l4_ops,
+		.priv = l4,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	l4->orphan = drm_fabric_endpoint_register(NULL, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(l4->orphan));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test,
+						  fabrictest_unregister_endpoint,
+						  l4->orphan));
+
+	/*
+	 * T1 attaches A -> F and blocks inside the provider callback while it
+	 * holds drm_fabric_mutation_lock.
+	 */
+	t1 = kthread_run(fabrictest_l4_attach_thread, l4, "fabrtest-l4-a");
+	KUNIT_ASSERT_FALSE(test, IS_ERR(t1));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_stop_thread, t1));
+	KUNIT_ASSERT_GT(test,
+			wait_for_completion_timeout(&l4->cb_entered, msecs_to_jiffies(5000)),
+			0);
+
+	/* T2 races to register B with the same id directly into F. */
+	t2 = kthread_run(fabrictest_l4_register_thread, l4, "fabrtest-l4-b");
+	KUNIT_ASSERT_FALSE(test, IS_ERR(t2));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_stop_thread, t2));
+
+	/* Register thread is queued behind the stalled attach, not finished. */
+	msleep(50);
+	KUNIT_EXPECT_FALSE(test, try_wait_for_completion(&l4->reg_done));
+
+	/* Release A's callback: the attach commits and claims X. */
+	complete(&l4->cb_release);
+	KUNIT_EXPECT_GT(test,
+			wait_for_completion_timeout(&l4->attach_done, msecs_to_jiffies(5000)),
+			0);
+	KUNIT_EXPECT_EQ(test, l4->attach_ret, 0);
+	KUNIT_EXPECT_PTR_EQ(test, l4->orphan->fabric, l4->fab);
+
+	/* B then proceeds and must fail: X is now owned by A. */
+	KUNIT_EXPECT_GT(test,
+			wait_for_completion_timeout(&l4->reg_done, msecs_to_jiffies(5000)),
+			0);
+	if (!IS_ERR(l4->reg_ep)) {
+		KUNIT_ASSERT_EQ(test, 0,
+				kunit_add_action_or_reset(test,
+							  fabrictest_unregister_endpoint,
+							  l4->reg_ep));
+		KUNIT_FAIL(test, "racing registration unexpectedly succeeded");
+		return;
+	}
+
+	KUNIT_EXPECT_EQ(test, PTR_ERR(l4->reg_ep), -EEXIST);
+}
+
+static int fabrictest_stats_get(struct drm_fabric_port *port,
+				struct drm_fabric_port_stats *stats)
+{
+	/* The statistics callback may sleep and runs without either fabric lock. */
+	lockdep_assert_not_held(&drm_fabric_lock);
+	lockdep_assert_not_held(&drm_fabric_mutation_lock);
+
+	stats->read_bytes = 4096;
+	stats->write_bytes = 2048;
+	stats->link_down_count = 2;
+	stats->retrain_count = 3;
+	return 0;
+}
+
+static const struct drm_fabric_ops fabrictest_stats_ops = {
+	.port_stats_get = fabrictest_stats_get,
+};
+
+/* This does not exercise netlink dispatch or error propagation. */
+static void drm_fabric_test_port_stats_ops_registration(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-stats",
+	};
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0x5A,
+		.parent = fabrictest_dev,
+		.ops = &fabrictest_stats_ops,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric_endpoint_desc edesc_noops = {
+		.fabric_ep_id = 0x5B,
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric_port_stats stats = {};
+	struct drm_fabric_endpoint *ep, *ep_noops;
+	struct drm_fabric_port *port;
+	struct drm_fabric *fab;
+	u32 gen;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	port = fabrictest_port(ep, 0);
+	KUNIT_ASSERT_NOT_NULL(test, port);
+
+	KUNIT_ASSERT_NOT_NULL(test, ep->ops);
+	KUNIT_ASSERT_NOT_NULL(test, ep->ops->port_stats_get);
+
+	/* A stats read is not a topology change: seq must not move. */
+	gen = fabrictest_seq_read();
+	KUNIT_EXPECT_EQ(test, ep->ops->port_stats_get(port, &stats), 0);
+	KUNIT_EXPECT_EQ(test, fabrictest_seq_read(), gen);
+	KUNIT_EXPECT_EQ(test, stats.read_bytes, 4096ULL);
+	KUNIT_EXPECT_EQ(test, stats.write_bytes, 2048ULL);
+	KUNIT_EXPECT_EQ(test, stats.link_down_count, 2ULL);
+	KUNIT_EXPECT_EQ(test, stats.retrain_count, 3ULL);
+
+	ep_noops = drm_fabric_endpoint_register(fab, &edesc_noops);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_noops));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_noops));
+	KUNIT_EXPECT_TRUE(test, !ep_noops->ops || !ep_noops->ops->port_stats_get);
+}
+
+static void drm_fabric_test_user_fabric_new_del(struct kunit *test)
+{
+	bool refcount_observable = fabrictest_module_refcount_observable();
+	int baseline = refcount_observable ? fabrictest_module_refcount() : 0;
+	struct drm_fabric *fab;
+	u32 fid = 0;
+	int ret;
+
+	ret = fabrictest_user_fabric_new_locked(DRM_FABRIC_TYPE_SYNTHETIC, 0x1234, "vpod0", &fid);
+	KUNIT_EXPECT_EQ(test, ret, 0);
+	KUNIT_EXPECT_NE(test, fid, 0);
+	if (ret || !fid)
+		return;
+
+	fab = fabrictest_find_fabric(fid);
+	KUNIT_EXPECT_NOT_NULL(test, fab);
+	if (fab)
+		KUNIT_EXPECT_EQ(test, fab->type, DRM_FABRIC_TYPE_SYNTHETIC);
+
+	/* FD-01: publishing a userspace fabric must pin the module. */
+	if (refcount_observable)
+		KUNIT_EXPECT_EQ(test, fabrictest_module_refcount(), baseline + 1);
+
+	KUNIT_EXPECT_EQ(test, fabrictest_user_fabric_del_locked(fid), 0);
+
+	fab = fabrictest_find_fabric(fid);
+	KUNIT_EXPECT_NULL(test, fab);
+
+	/* FD-01: removing it must release that pin again. */
+	if (refcount_observable)
+		KUNIT_EXPECT_EQ(test, fabrictest_module_refcount(), baseline);
+}
+
+/*
+ * Keyed by id, not pointer: after deletion this returns -ENOENT instead of
+ * touching freed memory.
+ */
+static void fabrictest_user_fabric_del(void *p)
+{
+	fabrictest_user_fabric_del_locked(*(u32 *)p);
+}
+
+/*
+ * FD-01: a rejected FABRIC_NEW must not publish a fabric or leak a module
+ * reference. Invalid type is refused before try_module_get(); a duplicate
+ * (type, instance_id) is refused after it, so only that path tests module_put().
+ */
+static void drm_fabric_test_user_fabric_new_reject_no_module_ref(struct kunit *test)
+{
+	bool refcount_observable = fabrictest_module_refcount_observable();
+	int baseline = refcount_observable ? fabrictest_module_refcount() : 0;
+	u32 *fid = kunit_kzalloc(test, sizeof(*fid), GFP_KERNEL);
+	u32 dup_fid = 0;
+	int ret;
+
+	KUNIT_ASSERT_NOT_NULL(test, fid);
+
+	ret = fabrictest_user_fabric_new_locked((enum drm_fabric_type)0, 0xa1a1,
+						"test-new-invalid", NULL);
+	KUNIT_EXPECT_EQ(test, ret, -EINVAL);
+	if (refcount_observable)
+		KUNIT_EXPECT_EQ(test, fabrictest_module_refcount(), baseline);
+
+	ret = fabrictest_user_fabric_new_locked(DRM_FABRIC_TYPE_SYNTHETIC, 0xa2a2,
+						"test-new-dup", fid);
+	KUNIT_ASSERT_EQ(test, ret, 0);
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_user_fabric_del, fid));
+
+	ret = fabrictest_user_fabric_new_locked(DRM_FABRIC_TYPE_SYNTHETIC, 0xa2a2,
+						"test-new-dup2", &dup_fid);
+	KUNIT_EXPECT_EQ(test, ret, -EEXIST);
+	KUNIT_EXPECT_EQ(test, dup_fid, 0);
+	KUNIT_EXPECT_NOT_NULL(test, fabrictest_find_fabric(*fid));
+
+	/* Only the first, successful registration should still be pinning us. */
+	if (refcount_observable)
+		KUNIT_EXPECT_EQ(test, fabrictest_module_refcount(), baseline + 1);
+}
+
+/*
+ * Sweep of the endpoint_set()/user_fabric_del() error paths: no-op change,
+ * nonexistent fabric, already-attached, and provider-vs-user ownership.
+ */
+static void drm_fabric_test_reject_paths(struct kunit *test)
+{
+	bool refcount_observable = fabrictest_module_refcount_observable();
+	int baseline = refcount_observable ? fabrictest_module_refcount() : 0;
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	/* Distinct instance_ids: this case needs two live fabrics, not a
+	 * uniqueness collision (which (type, instance_id) equality would now
+	 * trigger -- including for instance_id 0).
+	 */
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-reject",
+		.instance_id = 0x2001,
+	};
+	struct drm_fabric_desc fdesc2 = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-reject2",
+		.instance_id = 0x2002,
+	};
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0x99,
+		.name = "reject-ep",
+		.parent = fabrictest_dev,
+		.ops = &fabrictest_mut_ops,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric_endpoint_change change;
+	struct drm_fabric *fab, *fab2;
+	struct drm_fabric_endpoint *ep;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	fab2 = drm_fabric_register(&fdesc2);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab2));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab2));
+
+	ep = drm_fabric_endpoint_register(NULL, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	/* An empty change request is a core no-op; netlink maps no-attrs to -EINVAL. */
+	change = (struct drm_fabric_endpoint_change){ .valid = 0 };
+	KUNIT_EXPECT_EQ(test, fabrictest_ep_set_locked(ep, &change), 0);
+
+	change = (struct drm_fabric_endpoint_change){
+		.valid = DRM_FABRIC_EP_CHANGE_FABRIC, .fabric_id = 0x7fffffff,
+	};
+	KUNIT_EXPECT_EQ(test, fabrictest_ep_set_locked(ep, &change), -ENOENT);
+
+	change = (struct drm_fabric_endpoint_change){
+		.valid = DRM_FABRIC_EP_CHANGE_FABRIC, .fabric_id = fab->id,
+	};
+	KUNIT_EXPECT_EQ(test, fabrictest_ep_set_locked(ep, &change), 0);
+	KUNIT_EXPECT_PTR_EQ(test, ep->fabric, fab);
+
+	change = (struct drm_fabric_endpoint_change){
+		.valid = DRM_FABRIC_EP_CHANGE_FABRIC, .fabric_id = fab2->id,
+	};
+	KUNIT_EXPECT_EQ(test, fabrictest_ep_set_locked(ep, &change), -EBUSY);
+	KUNIT_EXPECT_PTR_EQ(test, ep->fabric, fab);
+
+	/* Full FABRIC_DEL ownership/emptiness matrix. */
+
+	/* Unknown id: not found, before any ownership or emptiness check. */
+	KUNIT_EXPECT_EQ(test, fabrictest_user_fabric_del_locked(0x7fffffff), -ENOENT);
+
+	/*
+	 * Provider-owned fabrics are refused with -EPERM whether empty (fab2) or
+	 * non-empty (fab holds @ep): a provider keeps sole ownership of its
+	 * fabric's lifetime, and -EPERM is checked before the -EBUSY emptiness
+	 * test.
+	 */
+	KUNIT_EXPECT_EQ(test, fabrictest_user_fabric_del_locked(fab->id), -EPERM);
+	KUNIT_EXPECT_EQ(test, fabrictest_user_fabric_del_locked(fab2->id), -EPERM);
+
+	/*
+	 * FD-01: provider-owned fabrics never took a module reference, and a
+	 * rejected delete must not touch either the object or a reference.
+	 */
+	KUNIT_EXPECT_PTR_EQ(test, fabrictest_find_fabric(fab->id), fab);
+	KUNIT_EXPECT_PTR_EQ(test, fabrictest_find_fabric(fab2->id), fab2);
+	if (refcount_observable)
+		KUNIT_EXPECT_EQ(test, fabrictest_module_refcount(), baseline);
+
+	/*
+	 * Userspace-owned fabrics: non-empty is -EBUSY, empty is deletable.
+	 * Reuse @ep (moved out of @fab) to make the userspace fabric non-empty.
+	 */
+	{
+		struct drm_fabric_endpoint_change detach = {
+			.valid = DRM_FABRIC_EP_CHANGE_FABRIC, .fabric_id = 0,
+		};
+		struct drm_fabric_endpoint_change attach = {
+			.valid = DRM_FABRIC_EP_CHANGE_FABRIC,
+		};
+		u32 *uid = kunit_kzalloc(test, sizeof(*uid), GFP_KERNEL);
+		int ret;
+
+		KUNIT_ASSERT_NOT_NULL(test, uid);
+		ret = fabrictest_user_fabric_new_locked(DRM_FABRIC_TYPE_SYNTHETIC,
+							0x2003, "test-user-del",
+							uid);
+		KUNIT_ASSERT_EQ(test, ret, 0);
+		KUNIT_ASSERT_EQ(test, 0,
+				kunit_add_action_or_reset(test,
+							  fabrictest_user_fabric_del,
+							  uid));
+		if (refcount_observable)
+			KUNIT_EXPECT_EQ(test, fabrictest_module_refcount(), baseline + 1);
+
+		KUNIT_EXPECT_EQ(test, fabrictest_ep_set_locked(ep, &detach), 0);
+		attach.fabric_id = *uid;
+		KUNIT_EXPECT_EQ(test, fabrictest_ep_set_locked(ep, &attach), 0);
+
+		KUNIT_EXPECT_EQ(test, fabrictest_user_fabric_del_locked(*uid), -EBUSY);
+
+		/*
+		 * FD-01: the -EBUSY rejection must leave the fabric resolvable
+		 * and its module reference held, exactly as before the attempt.
+		 */
+		KUNIT_EXPECT_NOT_NULL(test, fabrictest_find_fabric(*uid));
+		if (refcount_observable)
+			KUNIT_EXPECT_EQ(test, fabrictest_module_refcount(), baseline + 1);
+
+		KUNIT_EXPECT_EQ(test, fabrictest_ep_set_locked(ep, &detach), 0);
+		KUNIT_EXPECT_EQ(test, fabrictest_user_fabric_del_locked(*uid), 0);
+
+		/* FD-01: a successful delete drops both the object and the pin. */
+		KUNIT_EXPECT_NULL(test, fabrictest_find_fabric(*uid));
+		if (refcount_observable)
+			KUNIT_EXPECT_EQ(test, fabrictest_module_refcount(), baseline);
+	}
+}
+
+/*
+ * Unregistering an endpoint that has a peer link must clear only that
+ * endpoint's own port record; it must not touch the still-registered far
+ * side's peer record. (Contrast with drm_fabric_port_unset_peer(), which
+ * clears a peer explicitly and is covered separately.)
+ */
+static void drm_fabric_test_local_unplug_keeps_edge(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-unplug",
+	};
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
+	struct drm_fabric_endpoint_desc eadesc = {
+		.fabric_ep_id = 0xA0,
+		.name = "unplug-a",
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric_endpoint_desc ebdesc = {
+		.fabric_ep_id = 0xB0,
+		.name = "unplug-b",
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric_peer to_b = {
+		.peer_id = 0xB0, .peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+	};
+	struct drm_fabric_peer to_a = {
+		.peer_id = 0xA0, .peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep_a, *ep_b;
+	struct drm_fabric_port *pa, *pb;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep_a = drm_fabric_endpoint_register(fab, &eadesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_a));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_a));
+
+	ep_b = drm_fabric_endpoint_register(fab, &ebdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_b));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_b));
+
+	pa = fabrictest_port(ep_a, 0);
+	pb = fabrictest_port(ep_b, 0);
+	KUNIT_ASSERT_NOT_NULL(test, pa);
+	KUNIT_ASSERT_NOT_NULL(test, pb);
+
+	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(pa, &to_b), 0);
+	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(pb, &to_a), 0);
+	KUNIT_EXPECT_TRUE(test, pa->has_peer);
+
+	/*
+	 * Remove B without retracting its peer first, modelling abrupt provider
+	 * teardown.
+	 */
+	kunit_release_action(test, fabrictest_unregister_endpoint, ep_b);
+
+	/* The surviving half-edge must be byte-unchanged: no field mutated. */
+	KUNIT_EXPECT_TRUE(test, pa->has_peer);
+	KUNIT_EXPECT_MEMEQ(test, &pa->peer, &to_b, sizeof(pa->peer));
+}
+
+/*
+ * A's peer record names a port index, not an object; registering and then
+ * unregistering an unrelated third endpoint must not perturb it.
+ */
+static void drm_fabric_test_remote_peer_retained(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-remote",
+	};
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
+	struct drm_fabric_endpoint_desc eadesc = {
+		.fabric_ep_id = 0xA0,
+		.name = "remote-a",
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	struct drm_fabric_endpoint_desc ecdesc = {
+		.fabric_ep_id = 0xC0,
+		.name = "remote-c",
+		.parent = fabrictest_dev,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+	/* 0xBEEF has no local endpoint object. */
+	struct drm_fabric_peer remote = {
+		.peer_id = 0xBEEF, .peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep_a, *ep_c;
+	struct drm_fabric_port *pa;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep_a = drm_fabric_endpoint_register(fab, &eadesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_a));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_a));
+
+	pa = fabrictest_port(ep_a, 0);
+	KUNIT_ASSERT_NOT_NULL(test, pa);
+
+	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(pa, &remote), 0);
+	KUNIT_EXPECT_TRUE(test, pa->has_peer);
+
+	ep_c = drm_fabric_endpoint_register(fab, &ecdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep_c));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep_c));
+
+	kunit_release_action(test, fabrictest_unregister_endpoint, ep_c);
+
+	KUNIT_EXPECT_TRUE(test, pa->has_peer);
+	KUNIT_EXPECT_MEMEQ(test, &pa->peer, &remote, sizeof(pa->peer));
+}
+
+/*
+ * Removing an endpoint with multiple peered ports must bump the topology
+ * generation exactly once, not once per port torn down.
+ */
+static void drm_fabric_test_subtree_delete_single_bump(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-subtree",
+	};
+	struct drm_fabric_port_desc pdescs[3] = {
+		{ .index = 0, .max_lane_count = 4 },
+		{ .index = 1, .max_lane_count = 4 },
+		{ .index = 2, .max_lane_count = 4 },
+	};
+	struct drm_fabric_endpoint_desc edesc = {
+		.fabric_ep_id = 0xD0,
+		.name = "subtree-ep",
+		.parent = fabrictest_dev,
+		.ports = pdescs,
+		.num_ports = 3,
+	};
+	struct drm_fabric_peer peer = {
+		.peer_id = 0xD1, .peer_type = DRM_FABRIC_PEER_TYPE_ACCEL,
+	};
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep;
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	/* Two of the three ports carry a half-edge. */
+	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(fabrictest_port(ep, 0), &peer), 0);
+	KUNIT_ASSERT_EQ(test, drm_fabric_port_set_peer(fabrictest_port(ep, 1), &peer), 0);
+
+	fabrictest_seed_seq(test, 100);
+	kunit_release_action(test, fabrictest_unregister_endpoint, ep);
+	KUNIT_EXPECT_EQ(test, fabrictest_seq_read(), 101);
+}
+
+#define FABRICTEST_CONC_THREADS	4
+#define FABRICTEST_CONC_ITERS	200
+
+struct fabrictest_conc_ctx {
+	/* Sampled *inside* the provider hook (under the core lock). */
+	atomic_t	in_flight;
+	atomic_t	max_in_flight;
+	atomic_t	calls;
+	/* Sampled *around* the core mutation call (incl. lock wait). */
+	atomic_t	contenders;
+	atomic_t	max_contenders;
+	atomic_t	started;
+	atomic_t	done;
+	int		nthreads;
+};
+
+/* Lock-free running maximum; cmpxchg retries until the value only grows. */
+static void fabrictest_bump_max(atomic_t *max, int cur)
+{
+	int old = atomic_read(max);
+
+	while (cur > old)
+		old = atomic_cmpxchg(max, old, cur);
+}
+
+/* Record concurrent callback entry, then sleep to widen the overlap window. */
+static void fabrictest_conc_enter(struct fabrictest_conc_ctx *ctx)
+{
+	fabrictest_bump_max(&ctx->max_in_flight,
+			    atomic_inc_return(&ctx->in_flight));
+	atomic_inc(&ctx->calls);
+	usleep_range(20, 60);
+	atomic_dec(&ctx->in_flight);
+}
+
+static int fabrictest_mock_port_set(struct drm_fabric_port *port,
+				    enum drm_fabric_admin_state admin)
+{
+	fabrictest_conc_enter(port->endpoint->priv);
+	return 0;
+}
+
+static int fabrictest_mock_endpoint_set(struct drm_fabric_endpoint *ep,
+					const struct drm_fabric_endpoint_change *change,
+					struct drm_fabric *fabric)
+{
+	fabrictest_conc_enter(ep->priv);
+	return 0;
+}
+
+static const struct drm_fabric_ops fabrictest_conc_ops = {
+	.port_set = fabrictest_mock_port_set,
+	.endpoint_set = fabrictest_mock_endpoint_set,
+};
+
+struct fabrictest_worker {
+	struct drm_fabric_endpoint	*ep;
+	struct drm_fabric_port		*port;
+	struct fabrictest_conc_ctx	*ctx;
+	int				kind;	/* 0: PORT_SET, 1: ENDPOINT_SET */
+	int				iters;
+};
+
+/*
+ * All worker state lives in one kunit-managed allocation so the kthreads never
+ * dereference the test function's stack.  Combined with the per-thread stop
+ * action below, an assert-abort during spawn can still reap every worker before
+ * its backing memory (and the endpoint it touches) is torn down.
+ */
+struct fabrictest_conc_harness {
+	struct fabrictest_conc_ctx	ctx;
+	struct fabrictest_worker	workers[FABRICTEST_CONC_THREADS];
+	struct task_struct		*threads[FABRICTEST_CONC_THREADS];
+};
+
+static int fabrictest_mutator(void *arg)
+{
+	struct fabrictest_worker *w = arg;
+	struct fabrictest_conc_ctx *ctx = w->ctx;
+	unsigned long deadline;
+	int i;
+
+	/*
+	 * Barrier: don't start hammering until every worker is up, so the
+	 * contention window is as wide as possible.
+	 */
+	atomic_inc(&ctx->started);
+	deadline = jiffies + msecs_to_jiffies(1000);
+	while (atomic_read(&ctx->started) < ctx->nthreads &&
+	       time_before(jiffies, deadline))
+		cond_resched();
+
+	for (i = 0; i < w->iters; i++) {
+		enum drm_fabric_admin_state admin =
+			(i & 1) ? DRM_FABRIC_ADMIN_STATE_UP : DRM_FABRIC_ADMIN_STATE_DOWN;
+
+		/*
+		 * Count threads in/awaiting the mutator (the locked wrapper
+		 * blocks on drm_fabric_mutation_lock if another worker holds it),
+		 * so the test can prove real contention happened rather than
+		 * passing vacuously.
+		 */
+		fabrictest_bump_max(&ctx->max_contenders,
+				    atomic_inc_return(&ctx->contenders));
+		if (w->kind == 0) {
+			fabrictest_port_admin_locked(w->port, admin);
+		} else {
+			struct drm_fabric_endpoint_change change = {
+				.valid = DRM_FABRIC_EP_CHANGE_ADMIN,
+				.admin = admin,
+			};
+
+			fabrictest_ep_set_locked(w->ep, &change);
+		}
+		atomic_dec(&ctx->contenders);
+		cond_resched();
+	}
+
+	atomic_inc(&ctx->done);
+
+	/* Idle until the test reaps us so the threadfn never exits early. */
+	while (!kthread_should_stop())
+		schedule_timeout_interruptible(msecs_to_jiffies(2));
+
+	return 0;
+}
+
+/*
+ * FABRICTEST_CONC_THREADS racers alternate port-admin and endpoint-admin
+ * mutators; mutation_lock must serialize them into the provider hook.
+ */
+static void drm_fabric_test_concurrent_mutation(struct kunit *test)
+{
+	struct device *fabrictest_dev = fabrictest_alloc_dev(test);
+	struct drm_fabric_desc fdesc = {
+		.type = DRM_FABRIC_TYPE_SYNTHETIC,
+		.name = "test-conc",
+	};
+	struct drm_fabric_port_desc pdesc = { .index = 0, .max_lane_count = 4 };
+	struct fabrictest_conc_harness *h;
+	struct drm_fabric_endpoint_desc edesc;
+	struct drm_fabric *fab;
+	struct drm_fabric_endpoint *ep;
+	struct drm_fabric_port *port;
+	unsigned long deadline;
+	int i;
+
+	h = kunit_kzalloc(test, sizeof(*h), GFP_KERNEL);
+	KUNIT_ASSERT_NOT_NULL(test, h);
+	h->ctx.nthreads = FABRICTEST_CONC_THREADS;
+
+	edesc = (struct drm_fabric_endpoint_desc){
+		.fabric_ep_id = 0xC0,
+		.name = "conc-ep",
+		.parent = fabrictest_dev,
+		.ops = &fabrictest_conc_ops,
+		.priv = &h->ctx,
+		.ports = &pdesc,
+		.num_ports = 1,
+	};
+
+	fab = drm_fabric_register(&fdesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(fab));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_fabric, fab));
+
+	ep = drm_fabric_endpoint_register(fab, &edesc);
+	KUNIT_ASSERT_FALSE(test, IS_ERR(ep));
+	KUNIT_ASSERT_EQ(test, 0,
+			kunit_add_action_or_reset(test, fabrictest_unregister_endpoint, ep));
+
+	port = fabrictest_port(ep, 0);
+	KUNIT_ASSERT_NOT_NULL(test, port);
+
+	for (i = 0; i < FABRICTEST_CONC_THREADS; i++) {
+		h->workers[i] = (struct fabrictest_worker){
+			.ep = ep,
+			.port = port,
+			.ctx = &h->ctx,
+			.kind = i & 1,
+			.iters = FABRICTEST_CONC_ITERS,
+		};
+		h->threads[i] = kthread_run(fabrictest_mutator, &h->workers[i],
+					    "fabrtest-conc/%d", i);
+		KUNIT_ASSERT_FALSE(test, IS_ERR(h->threads[i]));
+		/* Reap this worker if a later assertion aborts the test. */
+		KUNIT_ASSERT_EQ(test, 0,
+				kunit_add_action_or_reset(test,
+							  fabrictest_stop_thread,
+							  h->threads[i]));
+	}
+
+	deadline = jiffies + msecs_to_jiffies(10000);
+	while (atomic_read(&h->ctx.done) < FABRICTEST_CONC_THREADS &&
+	       time_before(jiffies, deadline))
+		schedule_timeout_interruptible(msecs_to_jiffies(20));
+
+	/* Correctness: no deadlock / lost wakeup, every worker completed. */
+	KUNIT_EXPECT_EQ(test, atomic_read(&h->ctx.done), FABRICTEST_CONC_THREADS);
+	KUNIT_EXPECT_GT(test, atomic_read(&h->ctx.calls), 0);
+
+	/* Correctness: the object model is consistent after the storm. */
+	KUNIT_EXPECT_PTR_EQ(test, ep->fabric, fab);
+	KUNIT_EXPECT_LE(test, (int)port->admin_state, (int)DRM_FABRIC_ADMIN_STATE_UP);
+	KUNIT_EXPECT_LE(test, (int)ep->admin_state, (int)DRM_FABRIC_ADMIN_STATE_UP);
+
+	/*
+	 * Prove real contention occurred while the provider callback stayed
+	 * serialized.
+	 */
+	KUNIT_EXPECT_GE_MSG(test, atomic_read(&h->ctx.max_contenders), 2,
+			    "workers never contended; concurrency not exercised");
+
+	/*
+	 * Provider callbacks must not overlap. max_contenders >= 2 makes this
+	 * assertion non-vacuous.
+	 */
+	KUNIT_EXPECT_EQ_MSG(test, atomic_read(&h->ctx.max_in_flight), 1,
+			    "provider hooks overlapped; mutations did not serialise");
 }
 
 static void drm_fabric_test_switch_topology(struct kunit *test)
@@ -1299,10 +2605,22 @@ static struct kunit_case drm_fabric_test_cases[] = {
 	KUNIT_CASE(drm_fabric_test_port_oper_state_rejects_invalid),
 	KUNIT_CASE(drm_fabric_test_register_rejects_invalid_type),
 	KUNIT_CASE(drm_fabric_test_mesh_kn_topology),
+	KUNIT_CASE(drm_fabric_test_orphan_attach_detach),
+	KUNIT_CASE(drm_fabric_test_ep_id_unique),
+	KUNIT_CASE(drm_fabric_test_failed_mutation_no_commit),
+	KUNIT_CASE(drm_fabric_test_port_admin_peer),
+	KUNIT_CASE(drm_fabric_test_peer_mode),
+	KUNIT_CASE(drm_fabric_test_model_a_oper_report),
+	KUNIT_CASE_SLOW(drm_fabric_test_unregister_serializes_mutation),
+	KUNIT_CASE_SLOW(drm_fabric_test_attach_register_collision),
 	KUNIT_CASE(drm_fabric_test_port_stats_ops_registration),
+	KUNIT_CASE(drm_fabric_test_user_fabric_new_del),
+	KUNIT_CASE(drm_fabric_test_user_fabric_new_reject_no_module_ref),
+	KUNIT_CASE(drm_fabric_test_reject_paths),
 	KUNIT_CASE(drm_fabric_test_local_unplug_keeps_edge),
 	KUNIT_CASE(drm_fabric_test_remote_peer_retained),
 	KUNIT_CASE(drm_fabric_test_subtree_delete_single_bump),
+	KUNIT_CASE_SLOW(drm_fabric_test_concurrent_mutation),
 	KUNIT_CASE(drm_fabric_test_switch_topology),
 	KUNIT_CASE_PARAM(drm_fabric_test_topology_param,
 			 fabrictest_topo_gen_params),
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [RFC PATCH 12/12] drm/fabric: add mutation netlink selftests
  2026-08-24  8:09 [RFC PATCH 0/12] drm/fabric: vendor-neutral topology infrastructure for scale-up accelerator interconnects Konstantin Sinyuk
                   ` (10 preceding siblings ...)
  2026-08-24  8:09 ` [RFC PATCH 11/12] drm/fabric: add mutation KUnit tests Konstantin Sinyuk
@ 2026-08-24  8:09 ` Konstantin Sinyuk
  11 siblings, 0 replies; 13+ messages in thread
From: Konstantin Sinyuk @ 2026-08-24  8:09 UTC (permalink / raw)
  To: dri-devel
  Cc: Maarten Lankhorst, Francois Dugast, David Airlie, Simona Vetter,
	Maxime Ripard, Thomas Zimmermann, Jonathan Corbet, Shuah Khan,
	Donald Hunter, Jakub Kicinski, David S. Miller, Eric Dumazet,
	Paolo Abeni, Simon Horman, Ilia Levi, Rodrigo Vivi, linux-doc,
	linux-kselftest, netdev, linux-kernel

Add three programs covering CAP_NET_ADMIN enforcement, non-init_net
rejection and end-to-end provisioning: orphan attach, administrative-state
changes, peer installation, link failure and recovery.

Extend the query, hotplug, fault and policy tests for the mutation
operations. The policy probe now builds nested requests with NLA_F_NESTED,
so an out-of-range nested member reaches the range check and is refused
with -ERANGE instead of as a malformed nest.

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

Signed-off-by: Konstantin Sinyuk <ksinyuk@kernel.org>
Assisted-by: GitHub-Copilot:claude-opus-4.8
---
 Documentation/gpu/drm-fabric.rst              |   3 +-
 .../selftests/drivers/gpu/drm_fabric/Makefile |   3 +
 .../drivers/gpu/drm_fabric/README.rst         |  20 +-
 .../drivers/gpu/drm_fabric/cap_netadmin.py    | 314 +++++++++++++++++
 .../selftests/drivers/gpu/drm_fabric/config   |   5 +
 .../drivers/gpu/drm_fabric/fabric_abi.py      | 156 ++++++++-
 .../drivers/gpu/drm_fabric/fault_abi.py       | 211 +++++++++++-
 .../drivers/gpu/drm_fabric/hotplug_abi.py     | 145 +++++++-
 .../drivers/gpu/drm_fabric/lib_drm_fabric.py  |  17 +
 .../drivers/gpu/drm_fabric/netns_abi.py       | 294 ++++++++++++++++
 .../drivers/gpu/drm_fabric/nl_policy_probe.py | 280 ++++++++++++---
 .../drm_fabric/provisioning_scenarios_abi.py  | 324 ++++++++++++++++++
 .../drivers/gpu/drm_fabric/switch_abi.py      |  31 +-
 13 files changed, 1721 insertions(+), 82 deletions(-)
 create mode 100755 tools/testing/selftests/drivers/gpu/drm_fabric/cap_netadmin.py
 create mode 100755 tools/testing/selftests/drivers/gpu/drm_fabric/netns_abi.py
 create mode 100755 tools/testing/selftests/drivers/gpu/drm_fabric/provisioning_scenarios_abi.py

diff --git a/Documentation/gpu/drm-fabric.rst b/Documentation/gpu/drm-fabric.rst
index 1fd48027eee3..e400c44d32b8 100644
--- a/Documentation/gpu/drm-fabric.rst
+++ b/Documentation/gpu/drm-fabric.rst
@@ -474,5 +474,6 @@ 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``
+switch half-edge, ``CAP_NET_ADMIN`` gating and provisioning rejects, and
+provider fault-injection failure atomicity. See that directory's ``README.rst``
 for build and execution commands.
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/Makefile b/tools/testing/selftests/drivers/gpu/drm_fabric/Makefile
index 54d756979d97..6cdf14c44a5a 100644
--- a/tools/testing/selftests/drivers/gpu/drm_fabric/Makefile
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/Makefile
@@ -18,7 +18,10 @@ TEST_PROGS := \
 	hotplug_abi.py \
 	dump_scale_abi.py \
 	switch_abi.py \
+	cap_netadmin.py \
+	netns_abi.py \
 	fault_abi.py \
+	provisioning_scenarios_abi.py \
 	harness_reset_abi.py
 
 TEST_FILES := lib_drm_fabric.py
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/README.rst b/tools/testing/selftests/drivers/gpu/drm_fabric/README.rst
index 6c24581db1f2..33d9618b31d5 100644
--- a/tools/testing/selftests/drivers/gpu/drm_fabric/README.rst
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/README.rst
@@ -4,7 +4,7 @@
 drm_fabric selftests
 ====================
 
-These selftests exercise the ``drm-fabric`` query uAPI against
+These selftests exercise the ``drm-fabric`` query and mutation uAPI against
 ``drm_fabric_sim`` using the in-tree YNL library. KUnit covers the core object
 model.
 
@@ -45,7 +45,7 @@ Suites
   ends the dump.
 
 ``hotplug_abi.py``
-  Endpoint hotplug: CREATE/DELETE notifications.
+  Endpoint hotplug: CREATE/DELETE NTFs and mutation round-trips.
 
 ``dump_scale_abi.py``
   Dump resume under many endpoints (``bulk_add``).
@@ -54,10 +54,19 @@ Suites
   Opaque switch peers whose identifiers do not resolve to an endpoint
   (``topology=switch``).
 
+``cap_netadmin.py``
+  ``CAP_NET_ADMIN`` enforcement for mutation commands.
+
+``netns_abi.py``
+  Rejects commands outside ``init_net``, including with ``CAP_NET_ADMIN``.
+
 ``fault_abi.py``
-  Provider fault injection: errno propagation and no leaked endpoint
+  Provider failures: errno propagation, rollback and no notification
   (``fail_*``).
 
+``provisioning_scenarios_abi.py``
+  Endpoint, port and peer provisioning scenarios.
+
 ``harness_reset_abi.py``
   Recovery after a SIGKILL-terminated predecessor.
 
@@ -71,6 +80,7 @@ A SKIP means a required precondition was unavailable.
 
 Environment
   ``check-spec-regen.sh`` needs PyYAML and writable temporary storage.
+  ``netns_abi.py`` needs ``CONFIG_NET_NS``.
 
 Per case
   A case skips when a required control, parameter or family capability is
@@ -81,7 +91,7 @@ Whole suite
 
 Timing
   The two ``dump_intr_abi.py`` boundary cases may skip if the concurrent
-  topology change misses the required dump boundary.
+  mutation misses the required dump boundary.
 
 KUnit
 -----
@@ -133,4 +143,4 @@ Build out-of-tree, boot with ``vng`` and run the same target in the guest:
        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
+``qemu-system-x86``, ``virtme-ng`` (``pip install --user virtme-ng``).
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/cap_netadmin.py b/tools/testing/selftests/drivers/gpu/drm_fabric/cap_netadmin.py
new file mode 100755
index 000000000000..f3a80c719b7b
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/cap_netadmin.py
@@ -0,0 +1,314 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+"""
+CAP_NET_ADMIN enforcement on the mutation commands: an unprivileged child
+(forked, uid dropped before the socket opens) is refused with -EPERM; also
+covers the -EINVAL/-ENOENT/-EEXIST rejection paths.
+
+Requires drm_fabric + drm_fabric_sim loaded; run as root.
+"""
+
+import errno
+import json
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import lib_drm_fabric as L
+
+UNPRIV_UID = int(os.environ.get("UNPRIV_UID", "65534"))
+
+
+def run_unpriv(method, vals):
+    """Run a single `do` under an unprivileged uid in a child process.
+    Returns (ok, err): ok True on success, err the positive errno on
+    NlError. Result crosses via a JSON line over a pipe.
+    """
+    r, w = os.pipe()
+    pid = os.fork()
+    if pid == 0:  # child
+        os.close(r)
+        result = {"kind": "exc", "val": "setup"}
+        try:
+            try:
+                os.setgroups([])
+            except OSError:
+                pass
+            os.setresgid(UNPRIV_UID, UNPRIV_UID, UNPRIV_UID)
+            os.setresuid(UNPRIV_UID, UNPRIV_UID, UNPRIV_UID)
+            _, NlError = L.import_ynl()
+            fam = L.DrmFabric()
+            try:
+                fam.do(method, vals)
+                result = {"kind": "ok", "val": None}
+            except NlError as exc:
+                result = {"kind": "err", "val": exc.error}
+        except Exception as exc:  # noqa: BLE001
+            result = {"kind": "exc", "val": str(exc)}
+        os.write(w, json.dumps(result).encode())
+        os.close(w)
+        os._exit(0)
+
+    os.close(w)
+    buf = b""
+    while True:
+        chunk = os.read(r, 4096)
+        if not chunk:
+            break
+        buf += chunk
+    os.close(r)
+    os.waitpid(pid, 0)
+    result = json.loads(buf.decode())
+    return (result["kind"] == "ok",
+            result["val"] if result["kind"] == "err" else None)
+
+
+class Cfg:
+    def __init__(self, fab, nl_error):
+        self.fab = fab
+        self.NlError = nl_error
+
+
+def test_cap_fabric_new_privileged(ksft, cfg):
+    fab, NlError = cfg.fab, cfg.NlError
+    new_fid = None
+    try:
+        rep = fab.do("fabric-new",
+                     {"fabric-new-params": {"type": "synthetic",
+                                            "name": "captest",
+                                            "instance-id": 0xCA9}})
+        new_fid = rep.get("fabric-id")
+        ksft.check(new_fid is not None, "cap-fabric-new-privileged",
+                   "reply=%s" % rep)
+    except NlError as exc:
+        ksft.not_ok("cap-fabric-new-privileged", "errno=%d" % exc.error)
+    if new_fid is not None:
+        try:
+            fab.do("fabric-del", {"fabric-id": new_fid})
+        except NlError:
+            pass
+
+
+def test_cap_fabric_new_unprivileged(ksft, cfg):
+    ok, err = run_unpriv("fabric-new",
+                         {"fabric-new-params": {"type": "synthetic",
+                                                "name": "nope",
+                                                "instance-id": 0x4E0}})
+    ksft.check(not ok and err == errno.EPERM, "cap-fabric-new-unprivileged-eperm",
+               "ok=%s errno=%s" % (ok, err))
+
+
+def test_cap_port_set_unprivileged(ksft, cfg):
+    ok, err = run_unpriv("port-set",
+                         {"endpoint-id": 0, "port-index": 0, "admin-state": "down"})
+    ksft.check(not ok and err == errno.EPERM, "cap-port-set-unprivileged-eperm",
+               "ok=%s errno=%s" % (ok, err))
+
+
+def test_cap_port_set_privileged(ksft, cfg):
+    fab, NlError = cfg.fab, cfg.NlError
+    ok_priv = True
+    detail = ""
+    try:
+        fab.do("port-set", {"endpoint-id": 0, "port-index": 0, "admin-state": "down"})
+    except NlError as exc:
+        ok_priv = False
+        detail = "errno=%d" % exc.error
+    try:
+        fab.do("port-set", {"endpoint-id": 0, "port-index": 0, "admin-state": "up"})
+    except NlError:
+        pass
+    ksft.check(ok_priv, "cap-port-set-privileged-ok", detail)
+
+
+def test_cap_fabric_get_unprivileged(ksft, cfg):
+    ok, err = run_unpriv("fabric-get", {"fabric-id": 1})
+    ksft.check(ok, "cap-fabric-get-unprivileged-ok", "errno=%s" % err)
+
+
+def test_reject_fabric_del_unknown(ksft, cfg):
+    fab, NlError = cfg.fab, cfg.NlError
+    try:
+        fab.do("fabric-del", {"fabric-id": 4294967295})
+        ksft.not_ok("reject-fabric-del-unknown-enoent", "accepted")
+    except NlError as exc:
+        ksft.check(exc.error == errno.ENOENT, "reject-fabric-del-unknown-enoent",
+                   "errno=%d" % exc.error)
+
+
+def test_reject_fabric_new_no_type(ksft, cfg):
+    fab, NlError = cfg.fab, cfg.NlError
+    try:
+        fab.do("fabric-new", {"fabric-new-params": {"name": "no-type"}})
+        ksft.not_ok("reject-fabric-new-no-type-einval", "accepted")
+    except NlError as exc:
+        ksft.check(exc.error == errno.EINVAL, "reject-fabric-new-no-type-einval",
+                   "errno=%d" % exc.error)
+
+
+# A zero fabric type has no ynl symbolic name; the raw probe is in
+# nl_policy_probe.py.
+
+USER_PORT = 3
+
+
+def test_reject_port_peer_new_provider_managed(ksft, cfg):
+    """PORT_PEER_NEW on a provider-managed port is refused with -EOPNOTSUPP."""
+    fab, NlError = cfg.fab, cfg.NlError
+    try:
+        fab.do("port-peer-new",
+               {"endpoint-id": 0, "port-index": 0,
+                "peer": {"peer-id": 258, "type": "accel", "port-index": 0}})
+        ksft.not_ok("reject-port-peer-new-provider-managed-eopnotsupp", "accepted")
+    except NlError as exc:
+        ksft.check(exc.error == errno.EOPNOTSUPP,
+                   "reject-port-peer-new-provider-managed-eopnotsupp",
+                   "errno=%d" % exc.error)
+
+
+def test_userspace_peer_roundtrip(ksft, cfg):
+    """A userspace-managed port takes PORT_PEER_NEW, rejects a duplicate with
+    -EEXIST, and clears with PORT_PEER_DEL."""
+    fab, NlError = cfg.fab, cfg.NlError
+    peer = {"peer-id": 258, "type": "accel", "port-index": 0}
+    try:
+        fab.do("port-peer-new",
+               {"endpoint-id": 0, "port-index": USER_PORT, "peer": peer})
+    except NlError as exc:
+        ksft.not_ok("userspace-peer-new-ok", "errno=%d" % exc.error)
+        return
+    ksft.ok("userspace-peer-new-ok")
+    try:
+        fab.do("port-peer-new",
+               {"endpoint-id": 0, "port-index": USER_PORT, "peer": peer})
+        ksft.not_ok("userspace-peer-new-dup-eexist", "accepted duplicate")
+    except NlError as exc:
+        ksft.check(exc.error == errno.EEXIST, "userspace-peer-new-dup-eexist",
+                   "errno=%d" % exc.error)
+    # Clear it again so the reject suite leaves the port unlinked.
+    try:
+        fab.do("port-peer-del", {"endpoint-id": 0, "port-index": USER_PORT})
+        ksft.ok("userspace-peer-del-ok")
+    except NlError as exc:
+        ksft.not_ok("userspace-peer-del-ok", "errno=%d" % exc.error)
+
+
+def _reject_incomplete_peer(ksft, cfg, peer, name):
+    """A port-peer-new with an incomplete peer must be refused with -EINVAL.
+    Targets the userspace-managed port, so the rejection is unambiguously
+    peer-attribute validation, not the provider/userspace mode check.
+    """
+    fab, NlError = cfg.fab, cfg.NlError
+    try:
+        fab.do("port-peer-new",
+               {"endpoint-id": 0, "port-index": USER_PORT, "peer": peer})
+        ksft.not_ok(name, "accepted incomplete peer")
+    except NlError as exc:
+        ksft.check(exc.error == errno.EINVAL, name, "errno=%d" % exc.error)
+
+
+def test_reject_port_peer_new_no_type(ksft, cfg):
+    """A peer without a type is rejected (no valid peer type is 0)."""
+    _reject_incomplete_peer(ksft, cfg, {"peer-id": 258, "port-index": 0},
+                            "reject-port-peer-new-no-type-einval")
+
+
+def test_reject_port_peer_new_no_port_index(ksft, cfg):
+    """A peer without a port-index is rejected (0 would be a valid index)."""
+    _reject_incomplete_peer(ksft, cfg, {"peer-id": 258, "type": "accel"},
+                            "reject-port-peer-new-no-port-index-einval")
+
+
+def test_reject_port_peer_new_no_peer_id(ksft, cfg):
+    """A peer without a peer-id is rejected."""
+    _reject_incomplete_peer(ksft, cfg, {"type": "accel", "port-index": 0},
+                            "reject-port-peer-new-no-peer-id-einval")
+
+
+def test_reject_fabric_del_provider(ksft, cfg):
+    """FABRIC_DEL refuses a provider-owned fabric with -EPERM."""
+    fab, NlError = cfg.fab, cfg.NlError
+    prov = [f["fabric"] for f in fab.dump("fabric-get", {})
+            if f["fabric"].get("name") == "fabricsim"]
+    if not prov:
+        ksft.skip("reject-fabric-del-provider-eperm", "no fabricsim fabric")
+        return
+    try:
+        fab.do("fabric-del", {"fabric-id": prov[0]["fabric-id"]})
+        ksft.not_ok("reject-fabric-del-provider-eperm", "accepted")
+    except NlError as exc:
+        ksft.check(exc.error == errno.EPERM, "reject-fabric-del-provider-eperm",
+                   "errno=%d" % exc.error)
+
+
+def test_reject_port_peer_del_unlinked(ksft, cfg):
+    """PORT_PEER_DEL on the unlinked userspace-managed port is -ENOENT."""
+    fab, NlError = cfg.fab, cfg.NlError
+    try:
+        fab.do("port-peer-del", {"endpoint-id": 0, "port-index": USER_PORT})
+        ksft.not_ok("reject-port-peer-del-unlinked-enoent", "accepted")
+    except NlError as exc:
+        ksft.check(exc.error == errno.ENOENT, "reject-port-peer-del-unlinked-enoent",
+                   "errno=%d" % exc.error)
+
+
+def test_reject_port_peer_del_provider_managed(ksft, cfg):
+    """PORT_PEER_DEL on a provider-managed port is refused with -EOPNOTSUPP."""
+    fab, NlError = cfg.fab, cfg.NlError
+    try:
+        fab.do("port-peer-del", {"endpoint-id": 0, "port-index": 0})
+        ksft.not_ok("reject-port-peer-del-provider-managed-eopnotsupp", "accepted")
+    except NlError as exc:
+        ksft.check(exc.error == errno.EOPNOTSUPP,
+                   "reject-port-peer-del-provider-managed-eopnotsupp",
+                   "errno=%d" % exc.error)
+
+
+def test_reject_endpoint_set_empty(ksft, cfg):
+    fab, NlError = cfg.fab, cfg.NlError
+    try:
+        fab.do("endpoint-set", {"endpoint-id": 0})
+        ksft.not_ok("reject-endpoint-set-empty-einval", "accepted")
+    except NlError as exc:
+        ksft.check(exc.error == errno.EINVAL, "reject-endpoint-set-empty-einval",
+                   "errno=%d" % exc.error)
+
+
+CASES = (
+    test_cap_fabric_new_privileged,
+    test_cap_fabric_new_unprivileged,
+    test_cap_port_set_unprivileged,
+    test_cap_port_set_privileged,
+    test_cap_fabric_get_unprivileged,
+    test_reject_fabric_del_unknown,
+    test_reject_fabric_del_provider,
+    test_reject_fabric_new_no_type,
+    test_reject_port_peer_new_provider_managed,
+    test_userspace_peer_roundtrip,
+    test_reject_port_peer_new_no_type,
+    test_reject_port_peer_new_no_port_index,
+    test_reject_port_peer_new_no_peer_id,
+    test_reject_port_peer_del_unlinked,
+    test_reject_port_peer_del_provider_managed,
+    test_reject_endpoint_set_empty,
+)
+
+MUTATION_CASES = tuple(
+    case for case in CASES
+    if case is not test_cap_fabric_get_unprivileged
+)
+
+
+def main():
+    ksft = L.Ksft()
+    _, NlError = L.import_ynl()
+
+    with L.fabricsim(ksft) as fab:
+        L.run_cases(ksft, Cfg(fab, NlError),
+                    L.select_cases(fab, CASES, MUTATION_CASES))
+    ksft.finish()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/config b/tools/testing/selftests/drivers/gpu/drm_fabric/config
index 6eaab8a7d771..f7b38c11a5a9 100644
--- a/tools/testing/selftests/drivers/gpu/drm_fabric/config
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/config
@@ -2,6 +2,11 @@
 # Kernel config fragment required to run the drm_fabric kselftests.
 # Merge with: scripts/kconfig/merge_config.sh or make kselftest-merge.
 CONFIG_NET=y
+# netns_abi.py drives the init_net restriction from a non-initial namespace.
+# USER_NS lets it model container root; without it the suite still runs, using
+# a network namespace alone.
+CONFIG_NET_NS=y
+CONFIG_USER_NS=y
 CONFIG_DRM=y
 CONFIG_DEBUG_FS=y
 CONFIG_DRM_FABRIC=m
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/fabric_abi.py b/tools/testing/selftests/drivers/gpu/drm_fabric/fabric_abi.py
index 94cc1078a365..92acf47d45aa 100755
--- a/tools/testing/selftests/drivers/gpu/drm_fabric/fabric_abi.py
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/fabric_abi.py
@@ -9,6 +9,7 @@ are immune to CLI text changes.
 Usage: fabric_abi.py [--no-load]   (--no-load: modules already loaded)
 """
 
+import errno
 import os
 import sys
 
@@ -246,6 +247,33 @@ def test_port_change_ntf(ksft, cfg):
     L.dbg_write("ep1/port1/oper_state", "active")
 
 
+def test_endpoint_change_ntf(ksft, cfg):
+    # ENDPOINT_CHANGE_NTF is emitted by an attribute change (endpoint-set), not
+    # by unregister -- removing a provider emits ENDPOINT_DELETE_NTF instead.
+    # Toggle a live endpoint's admin state to provoke the change event, then
+    # restore the original state so later cases are unaffected.
+    fab, NlError = cfg.fab, cfg.NlError
+    ep = fab.do("endpoint-get", {"endpoint-id": 0})["endpoint"]
+    cur = ep.get("admin-state")
+    target = "down" if cur == "up" else "up"
+    ev = L.DrmFabric()
+    ev.ntf_subscribe(L.MCAST_MONITOR)
+    L.settle(EVT_SETTLE)
+    try:
+        fab.do("endpoint-set", {"endpoint-id": 0, "admin-state": target})
+    except NlError as exc:
+        ksft.not_ok("endpoint-change-ntf-notification",
+                    "endpoint-set errno=%d" % exc.error)
+        return
+    got = L.wait_ntf(ev, "endpoint-change-ntf", timeout=EVT_DURATION,
+                     match=lambda n: n["msg"]["endpoint"].get("endpoint-id") == 0)
+    try:
+        fab.do("endpoint-set", {"endpoint-id": 0, "admin-state": cur})
+    except NlError:
+        pass
+    ksft.check(got is not None, "endpoint-change-ntf-notification")
+
+
 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
@@ -334,6 +362,119 @@ def test_link_down_exact_count(ksft, cfg):
     L.dbg_write("ep3/port1/inject", "recover_to_active")
 
 
+def test_stats_survive_mutation(ksft, cfg):
+    if not cfg.dfs:
+        ksft.skip("stats-counters-survive-mutation", "debugfs not available")
+        return
+    fab, NlError = cfg.fab, cfg.NlError
+    L.dbg_write("ep2/port0/inject", "link_down")
+    L.dbg_write("ep2/port0/inject", "link_down")
+    pre = fab.do("port-stats-get",
+                 {"endpoint-id": 2, "port-index": 0})["port-stats"]
+    cpre = pre.get("link-down-count", 0)
+    survived = True
+    try:
+        fab.do("port-set", {"endpoint-id": 2, "port-index": 0,
+                            "admin-state": "down"})
+        fab.do("port-set", {"endpoint-id": 2, "port-index": 0,
+                            "admin-state": "up"})
+        # detach then re-attach the endpoint (mutation on membership). An
+        # endpoint must be admin-down to leave its fabric (decoupled lifecycle
+        # invariant), so bring it down first and restore admin-up after.
+        fab.do("endpoint-set", {"endpoint-id": 2, "admin-state": "down"})
+        fab.do("endpoint-set", {"endpoint-id": 2, "fabric-id": 0})
+        fab.do("endpoint-set", {"endpoint-id": 2, "fabric-id": cfg.fid})
+        fab.do("endpoint-set", {"endpoint-id": 2, "admin-state": "up"})
+    except NlError as exc:
+        # The sim does not ordinarily reject this mutation-only sequence (no
+        # fault injection is armed here), so an unexpected failure here is a
+        # real ABI regression, not an environmental limitation.
+        survived = None
+        ksft.not_ok("stats-counters-survive-mutation",
+                    "mutation errno=%d" % L.nl_errno(exc))
+    if survived is not None:
+        post = fab.do("port-stats-get",
+                      {"endpoint-id": 2, "port-index": 0})["port-stats"]
+        ksft.check(post.get("link-down-count", 0) == cpre,
+                   "stats-counters-survive-mutation",
+                   "pre=%d post=%s" % (cpre, post.get("link-down-count")))
+    # Restore everything the sequence above can have changed, not just the
+    # port: a failure part-way through leaves the endpoint detached or
+    # admin-down, and skipping with that state still in place would silently
+    # change the topology every later case enumerates. Re-attaching requires
+    # admin-down first, so drive the full sequence back.
+    for cmd, req in (("endpoint-set", {"endpoint-id": 2, "admin-state": "down"}),
+                     ("endpoint-set", {"endpoint-id": 2, "fabric-id": cfg.fid}),
+                     ("endpoint-set", {"endpoint-id": 2, "admin-state": "up"}),
+                     ("port-set", {"endpoint-id": 2, "port-index": 0,
+                                   "admin-state": "up"})):
+        try:
+            fab.do(cmd, req)
+        except NlError:
+            pass
+    try:
+        L.dbg_write("ep2/port0/inject", "recover_to_active")
+    except OSError:
+        pass
+    # Assert the restore actually took: a silent failure here is exactly what
+    # would make a later, unrelated case fail instead of this one.
+    back = fab.do("endpoint-get", {"endpoint-id": 2})["endpoint"]
+    ksft.check(back.get("fabric-id") == cfg.fid and
+               back.get("admin-state") == "up",
+               "stats-mutation-endpoint-restored",
+               "fabric-id=%s admin-state=%s"
+               % (back.get("fabric-id"), back.get("admin-state")))
+
+
+def test_fabric_new_duplicate(ksft, cfg):
+    fab, NlError = cfg.fab, cfg.NlError
+    params = {"type": "synthetic", "name": "iid-uniq", "instance-id": 0x9999}
+
+    def fabric_cleanup(fabric_id):
+        def drop():
+            """Delete the fabric unless explicit cleanup already did."""
+            try:
+                fab.do("fabric-del", {"fabric-id": fabric_id})
+            except NlError as exc:
+                if L.nl_errno(exc) != errno.ENOENT:
+                    raise
+
+        return drop
+
+    try:
+        fabric_id = fab.do("fabric-new",
+                           {"fabric-new-params": params})["fabric-id"]
+    except NlError as exc:
+        ksft.not_ok("fabric-new-duplicate-instance-id-eexist",
+                    "setup fabric-new errno=%d" % L.nl_errno(exc))
+        return
+
+    L.on_teardown(fabric_cleanup(fabric_id))
+
+    dup = dict(params, name="iid-dup")
+    try:
+        duplicate = fab.do("fabric-new", {"fabric-new-params": dup})
+    except NlError as exc:
+        ksft.check(L.nl_errno(exc) == errno.EEXIST,
+                   "fabric-new-duplicate-instance-id-eexist",
+                   "errno=%d" % L.nl_errno(exc))
+    else:
+        # Arm cleanup before reporting: an accepted duplicate is a second
+        # live fabric that drop() above cannot reach.
+        dup_id = duplicate.get("fabric-id")
+        if dup_id is not None:
+            L.on_teardown(fabric_cleanup(dup_id))
+        ksft.not_ok("fabric-new-duplicate-instance-id-eexist",
+                    "duplicate instance-id accepted")
+
+    try:
+        fab.do("fabric-del", {"fabric-id": fabric_id})
+        ksft.ok("fabric-new-duplicate-cleanup-del")
+    except NlError as exc:
+        ksft.not_ok("fabric-new-duplicate-cleanup-del",
+                    "errno=%d" % L.nl_errno(exc))
+
+
 # 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.
@@ -351,10 +492,21 @@ CASES = (
     test_counters_stop,
     test_port_state_cycle,
     test_port_change_ntf,
+    test_endpoint_change_ntf,
     test_linear_topology,
     test_reload_mesh,
     test_port_change_ntf_full,
     test_link_down_exact_count,
+    test_stats_survive_mutation,
+    test_fabric_new_duplicate,
+)
+
+# Cases that exercise the topology-mutation uAPI.  On a query-only build the
+# family has no mutation ops, so these are filtered out.
+MUTATION_CASES = (
+    test_endpoint_change_ntf,
+    test_stats_survive_mutation,
+    test_fabric_new_duplicate,
 )
 
 
@@ -388,12 +540,12 @@ def main():
     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.
+    # fabric-id 0 is the reserved orphan sentinel; discover the live 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)
+    L.run_cases(ksft, cfg, L.select_cases(fab, CASES, MUTATION_CASES))
     ksft.finish()
 
 
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/fault_abi.py b/tools/testing/selftests/drivers/gpu/drm_fabric/fault_abi.py
index 8ea2d1de93d7..fce15ef3ca5c 100755
--- a/tools/testing/selftests/drivers/gpu/drm_fabric/fault_abi.py
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/fault_abi.py
@@ -2,10 +2,10 @@
 # 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.
+Provider fault injection via fabricsim's fail_* debugfs toggles (cf.
+netdevsim's should_fail): a failed mutation must surface the provider's
+exact errno through genetlink, leave core state untouched, emit no change
+notification, and succeed once the fault is cleared.
 
 Requires drm_fabric + drm_fabric_sim with fabricsim debugfs; run as root.
 """
@@ -17,6 +17,12 @@ import sys
 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
 import lib_drm_fabric as L
 
+# Budget for proving a notification did *not* arrive. Short by design: the
+# failing command has already returned before the wait starts, so a success
+# notification would have been queued by then.
+EVT_NEG_DURATION = float(os.environ.get("EVT_NEG_DURATION", "0.5"))
+EVT_SETTLE = float(os.environ.get("EVT_SETTLE", "0.2"))
+
 
 def eps_by_name(fab):
     return {e["endpoint"]["name"]: e["endpoint"]
@@ -51,10 +57,114 @@ def set_fault(name, on):
     L.dbg_write(name, "Y" if on else "N")
 
 
+def set_fail_errno(code):
+    L.dbg_write("fail_errno", int(code))
+
+
+def has_fail_errno():
+    return os.path.exists(os.path.join(L.DEBUGFS, "fail_errno"))
+
+
 class Cfg:
-    def __init__(self, fab, fid):
+    def __init__(self, fab, nl_error, fid, orphan):
         self.fab = fab
+        self.NlError = nl_error
         self.fid = fid
+        self.orphan = orphan
+        self.oid = orphan["endpoint-id"]
+        self.oslot = slot_of(orphan["name"])
+
+
+def test_endpoint_set_fault(ksft, cfg):
+    """A failed ENDPOINT_SET returns -ENOMEM and rolls back; clearing succeeds."""
+    fab, NlError = cfg.fab, cfg.NlError
+    orphan, oid, fid = cfg.orphan, cfg.oid, cfg.fid
+
+    ksft.check(orphan.get("fabric-id", 0) == 0, "fault-orphan-precondition",
+               "fabric-id=%s" % orphan.get("fabric-id"))
+
+    # A failed mutation must not emit a success notification.
+    ev = L.DrmFabric()
+    ev.ntf_subscribe(L.MCAST_MONITOR)
+    L.settle(EVT_SETTLE)
+
+    set_fault("fail_mutation", True)
+    try:
+        got = None
+        try:
+            fab.do("endpoint-set", {"endpoint-id": oid, "fabric-id": fid})
+        except NlError as exc:
+            got = exc.error
+        ksft.check(got == errno.ENOMEM, "fault-endpoint-set-returns-enomem",
+                   "errno=%s" % got)
+
+        ec = L.wait_ntf(ev, "endpoint-change-ntf", timeout=EVT_NEG_DURATION,
+                        match=lambda n: n["msg"]["endpoint"].get("endpoint-id") == oid)
+        ksft.check(ec is None, "fault-endpoint-set-emits-no-ntf",
+                   "unexpected endpoint-change for %s: %s" % (oid, ec))
+
+        now = eps_by_name(fab).get(orphan["name"], {})
+        ksft.check(now.get("fabric-id", 0) == 0, "fault-endpoint-set-failure-atomicity",
+                   "fabric-id=%s (expected still-orphan)" % now.get("fabric-id"))
+    finally:
+        set_fault("fail_mutation", False)
+    ok = True
+    try:
+        fab.do("endpoint-set", {"endpoint-id": oid, "fabric-id": fid})
+    except NlError as exc:
+        ok = False
+        ksft.not_ok("fault-cleared-endpoint-set-ok", "errno=%d" % exc.error)
+    if ok:
+        attached = eps_by_name(fab).get(orphan["name"], {})
+        ksft.check(attached.get("fabric-id") == fid,
+                   "fault-cleared-endpoint-set-ok",
+                   "fabric-id=%s" % attached.get("fabric-id"))
+        ec2 = L.wait_ntf(ev, "endpoint-change-ntf", timeout=EVT_NEG_DURATION,
+                        match=lambda n: n["msg"]["endpoint"].get("endpoint-id") == oid)
+        ksft.check(ec2 is not None, "fault-cleared-endpoint-set-emits-ntf",
+                   "expected endpoint-change for %s, got none" % oid)
+
+    try:
+        fab.do("endpoint-set", {"endpoint-id": oid, "fabric-id": 0})
+    except NlError:
+        pass
+    del_via(fab, orphan["name"], cfg.oslot)
+
+
+def test_port_peer_new_fault(ksft, cfg):
+    """A failed PORT_PEER_NEW returns -ENOMEM and leaves no peer behind."""
+    fab, NlError = cfg.fab, cfg.NlError
+    ep_a = add_via(fab, "add_endpoint", nports=1)
+    if ep_a is None:
+        ksft.not_ok("fault-port-peer-new-returns-enomem", "add ep failed")
+        ksft.not_ok("fault-port-peer-new-failure-atomicity", "add ep failed")
+        return
+    a_id = ep_a["endpoint-id"]
+    ev = L.DrmFabric()
+    ev.ntf_subscribe(L.MCAST_MONITOR)
+    L.settle(EVT_SETTLE)
+    set_fault("fail_mutation", True)
+    try:
+        got = None
+        try:
+            fab.do("port-peer-new",
+                   {"endpoint-id": a_id, "port-index": 0,
+                    "peer": {"peer-id": 0xBEEF, "type": "accel",
+                             "port-index": 0}})
+        except NlError as exc:
+            got = exc.error
+        ksft.check(got == errno.ENOMEM, "fault-port-peer-new-returns-enomem",
+                   "errno=%s" % got)
+        pc = L.wait_ntf(ev, "port-change-ntf", timeout=EVT_NEG_DURATION,
+                        match=lambda n: n["msg"]["port"].get("endpoint-id") == a_id)
+        ksft.check(pc is None, "fault-port-peer-new-emits-no-ntf",
+                   "unexpected port-change for %s: %s" % (a_id, pc))
+    finally:
+        set_fault("fail_mutation", False)
+    pa = fab.do("port-get", {"endpoint-id": a_id, "port-index": 0})["port"]
+    ksft.check("peer" not in pa, "fault-port-peer-new-failure-atomicity",
+               "unexpected peer=%s" % pa.get("peer"))
+    del_via(fab, ep_a["name"], slot_of(ep_a["name"]))
 
 
 def test_register_fault(ksft, cfg):
@@ -84,20 +194,107 @@ def test_register_fault(ksft, cfg):
         del_via(fab, created["name"], slot_of(created["name"]))
 
 
+def test_errno_round_trip(ksft, cfg):
+    """A selectable provider errno propagates verbatim (not flattened to ENOMEM)."""
+    fab, NlError = cfg.fab, cfg.NlError
+    if not has_fail_errno():
+        ksft.skip("fault-errno-round-trip", "fail_errno knob absent (old module)")
+        return
+    ep = add_via(fab, "add_endpoint", nports=1)
+    if ep is None:
+        ksft.not_ok("fault-errno-round-trip", "add ep failed")
+        return
+    a_id = ep["endpoint-id"]
+    # EBUSY is not the -ENOMEM the other cases use nor a code genl raises itself,
+    # so seeing it come back means the provider's errno was preserved verbatim.
+    set_fail_errno(errno.EBUSY)
+    set_fault("fail_mutation", True)
+    try:
+        got = None
+        try:
+            fab.do("port-peer-new",
+                   {"endpoint-id": a_id, "port-index": 0,
+                    "peer": {"peer-id": 0xBEEF, "type": "accel",
+                             "port-index": 0}})
+        except NlError as exc:
+            got = exc.error
+    finally:
+        set_fault("fail_mutation", False)
+        set_fail_errno(errno.ENOMEM)        # restore the default for later cases
+    ksft.check(got == errno.EBUSY, "fault-errno-round-trip",
+               "expected EBUSY(%d), got %s" % (errno.EBUSY, got))
+    del_via(fab, ep["name"], slot_of(ep["name"]))
+
+
+def test_port_peer_del_fault(ksft, cfg):
+    """A failed PORT_PEER_DEL surfaces the errno and keeps the peer (failure atomicity)."""
+    fab, NlError = cfg.fab, cfg.NlError
+    ep = add_via(fab, "add_endpoint", nports=1)
+    if ep is None:
+        ksft.not_ok("fault-port-peer-del-returns-errno", "add ep failed")
+        ksft.not_ok("fault-port-peer-del-retained", "add ep failed")
+        return
+    a_id = ep["endpoint-id"]
+    fab.do("port-peer-new",
+           {"endpoint-id": a_id, "port-index": 0,
+            "peer": {"peer-id": 0xBEEF, "type": "accel", "port-index": 0}})
+    # Subscribe after the successful add, so any event seen below belongs to the
+    # failing delete rather than the setup.
+    ev = L.DrmFabric()
+    ev.ntf_subscribe(L.MCAST_MONITOR)
+    L.settle(EVT_SETTLE)
+    set_fault("fail_mutation", True)
+    try:
+        got = None
+        try:
+            fab.do("port-peer-del", {"endpoint-id": a_id, "port-index": 0})
+        except NlError as exc:
+            got = exc.error
+        ksft.check(got == errno.ENOMEM, "fault-port-peer-del-returns-errno",
+                   "errno=%s" % got)
+        pd = L.wait_ntf(ev, "port-change-ntf", timeout=EVT_NEG_DURATION,
+                        match=lambda n: n["msg"]["port"].get("endpoint-id") == a_id)
+        ksft.check(pd is None, "fault-port-peer-del-emits-no-ntf",
+                   "unexpected port-change for %s: %s" % (a_id, pd))
+        pa = fab.do("port-get", {"endpoint-id": a_id, "port-index": 0})["port"]
+        ksft.check("peer" in pa, "fault-port-peer-del-retained",
+                   "peer unexpectedly removed after failed delete")
+    finally:
+        set_fault("fail_mutation", False)
+    try:
+        fab.do("port-peer-del", {"endpoint-id": a_id, "port-index": 0})
+    except NlError as exc:
+        ksft.not_ok("fault-port-peer-del-cleared-ok", "errno=%d" % exc.error)
+    else:
+        pa = fab.do("port-get", {"endpoint-id": a_id, "port-index": 0})["port"]
+        ksft.check("peer" not in pa, "fault-port-peer-del-cleared-ok",
+                   "peer still present after clear")
+    del_via(fab, ep["name"], slot_of(ep["name"]))
+
+
 CASES = (
+    test_endpoint_set_fault,
+    test_port_peer_new_fault,
     test_register_fault,
+    test_errno_round_trip,
+    test_port_peer_del_fault,
 )
 
 
 def main():
     ksft = L.Ksft()
+    _, NlError = L.import_ynl()
 
-    with L.fabricsim(ksft, need_debugfs=True, need_control="fail_register") as fab:
+    with L.fabricsim(ksft, need_debugfs=True, need_control="fail_mutation") 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)
+        orphan = add_via(fab, "add_orphan", nports=1)
+        if orphan is None:
+            ksft.skip_all("could not create orphan endpoint")
+
+        L.run_cases(ksft, Cfg(fab, NlError, fid, orphan), CASES)
     ksft.finish()
 
 
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/hotplug_abi.py b/tools/testing/selftests/drivers/gpu/drm_fabric/hotplug_abi.py
index 19a3405fade9..df70403a7e30 100755
--- a/tools/testing/selftests/drivers/gpu/drm_fabric/hotplug_abi.py
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/hotplug_abi.py
@@ -2,10 +2,11 @@
 # 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.
+Endpoint hotplug via fabricsim's debugfs lifecycle controls (cf. netdevsim's
+new_port/del_port): CREATE/DELETE events and peer-unplug edge retention are
+observed over the real ABI; ENDPOINT_SET/PORT_SET mutation is also issued over
+the real (privileged) genetlink ABI -- only the hotplug stimulus itself uses
+the test-only debugfs controls.
 
 Usage: hotplug_abi.py [--no-load]
 """
@@ -17,6 +18,10 @@ 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"))
+# Window for asserting an event is ABSENT: a peer unplug emits (or suppresses)
+# its notification synchronously during the del, so a short window proves
+# non-arrival without burning the full positive EVT_DURATION.
+EVT_NEG_DURATION = float(os.environ.get("EVT_NEG_DURATION", "0.5"))
 # 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"))
@@ -59,6 +64,7 @@ class Cfg:
         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",
@@ -66,9 +72,13 @@ def _gen(fab):
 
 
 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.
+    """Provider grows and shrinks the topology within its fabric: a
+    read-only ABI view of the xGMI-shaped lifecycle where the provider owns
+    membership/adjacency and userspace only observes. Asserts (a) initial
+    adjacency visible, (b) hotplug CREATE/DELETE events each advance
+    topology-generation, (c) a late arrival carries no peer (provider
+    links explicitly, doesn't auto-wire), (d) pure reads never advance the
+    generation. Non-destructive: only the two added endpoints are removed.
     """
     fab = cfg.fab
 
@@ -143,9 +153,11 @@ def test_provider_topology_lifecycle(ksft, cfg):
                "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."""
+    """Hotplug one endpoint and unplug it, asserting the CREATE/DELETE
+    events and membership. Self-contained: adds and deletes the same
+    endpoint.
+    """
     fab = cfg.fab
     ev = L.DrmFabric()
     ev.ntf_subscribe(L.MCAST_MONITOR)
@@ -181,9 +193,124 @@ def test_hotplug_lifecycle(ksft, cfg):
             del_ep(fab, slot_of(name), name)
 
 
+def test_peer_unplug(ksft, cfg):
+    """Link two members, delete one, assert the survivor's peer is intact."""
+    fab, NlError = cfg.fab, cfg.NlError
+    ep_a = add_ep(fab, "add_endpoint", nports=1)
+    ep_b = add_ep(fab, "add_endpoint", nports=1)
+    if not (ep_a and ep_b):
+        ksft.not_ok("peer-unplug-link-established", "could not add two endpoints")
+        ksft.not_ok("peer-unplug-survivor-peer-retained", "setup failed")
+        ksft.not_ok("peer-unplug-no-port-change-ntf", "setup failed")
+        # Tear down the half-built setup: an endpoint left behind here joins
+        # the fabric every later case enumerates, turning one failed setup
+        # into unrelated failures further down the suite.
+        for ep in (ep_a, ep_b):
+            if ep:
+                del_ep(fab, slot_of(ep["name"]), ep["name"])
+        return
+
+    a_id, b_id = ep_a["endpoint-id"], ep_b["endpoint-id"]
+    a_fepid, b_fepid = ep_a["fabric-ep-id"], ep_b["fabric-ep-id"]
+    linked = True
+    try:
+        fab.do("port-peer-new", {"endpoint-id": a_id, "port-index": 0,
+                                 "peer": {"peer-id": b_fepid,
+                                          "type": "accel",
+                                          "port-index": 0}})
+        fab.do("port-peer-new", {"endpoint-id": b_id, "port-index": 0,
+                                 "peer": {"peer-id": a_fepid,
+                                          "type": "accel",
+                                          "port-index": 0}})
+    except NlError as exc:
+        linked = False
+        ksft.not_ok("peer-unplug-link-setup", "errno=%d" % exc.error)
+
+    if linked:
+        pa = fab.do("port-get", {"endpoint-id": a_id, "port-index": 0})["port"]
+        ksft.check("peer" in pa, "peer-unplug-link-established")
+
+        ev = L.DrmFabric()
+        ev.ntf_subscribe(L.MCAST_MONITOR)
+        L.settle(EVT_SETTLE)
+        del_ep(fab, slot_of(ep_b["name"]), ep_b["name"])
+        pc = L.wait_ntf(
+            ev, "port-change-ntf", timeout=EVT_NEG_DURATION,
+            match=lambda n: n["msg"]["port"].get("endpoint-id") == a_id)
+        pa2 = fab.do("port-get",
+                     {"endpoint-id": a_id, "port-index": 0})["port"]
+        ksft.check("peer" in pa2, "peer-unplug-survivor-peer-retained",
+                   "peer=%s" % pa2.get("peer"))
+        ksft.check(pc is None, "peer-unplug-no-port-change-ntf",
+                   "unexpected port-change for a=%s" % (pc,))
+    else:
+        del_ep(fab, slot_of(ep_b["name"]), ep_b["name"])
+    del_ep(fab, slot_of(ep_a["name"]), ep_a["name"])
+
+
+def test_orphan_lifecycle(ksft, cfg):
+    """Orphan attach -> admin up/down -> detach, plus a PORT_SET round-trip."""
+    fab, NlError = cfg.fab, cfg.NlError
+    orphan = add_ep(fab, "add_orphan", nports=1)
+    if not orphan:
+        ksft.not_ok("endpoint-set-orphan-created", "add_orphan failed")
+        return
+
+    o_id = orphan["endpoint-id"]
+    ksft.check(orphan.get("fabric-id", 0) == 0, "endpoint-set-orphan-created",
+               "fabric-id=%s" % orphan.get("fabric-id"))
+
+    def ep_now():
+        return fab.do("endpoint-get", {"endpoint-id": o_id})["endpoint"]
+
+    try:
+        fab.do("endpoint-set", {"endpoint-id": o_id, "fabric-id": cfg.fid})
+        e = ep_now()
+        ksft.check(e.get("fabric-id") == cfg.fid and
+                   e.get("admin-state") == "down",
+                   "endpoint-set-attach-keeps-admin-down",
+                   "fabric=%s admin=%s" % (e.get("fabric-id"),
+                                           e.get("admin-state")))
+
+        fab.do("endpoint-set", {"endpoint-id": o_id, "admin-state": "up"})
+        ksft.check(ep_now().get("admin-state") == "up",
+                   "endpoint-set-admin-up")
+
+        fab.do("endpoint-set", {"endpoint-id": o_id, "admin-state": "down"})
+        fab.do("endpoint-set", {"endpoint-id": o_id, "fabric-id": 0})
+        ksft.check(ep_now().get("fabric-id", 0) == 0,
+                   "endpoint-set-detach-to-orphan")
+    except NlError as exc:
+        ksft.not_ok("endpoint-set-attach-keeps-admin-down",
+                    "errno=%d" % exc.error)
+        ksft.not_ok("endpoint-set-admin-up", "setup failed")
+        ksft.not_ok("endpoint-set-detach-to-orphan", "setup failed")
+
+    try:
+        fab.do("port-set", {"endpoint-id": o_id, "port-index": 0,
+                            "admin-state": "down"})
+        d = fab.do("port-get",
+                   {"endpoint-id": o_id, "port-index": 0})["port"]
+        fab.do("port-set", {"endpoint-id": o_id, "port-index": 0,
+                            "admin-state": "up"})
+        u = fab.do("port-get",
+                   {"endpoint-id": o_id, "port-index": 0})["port"]
+        ksft.check(d.get("admin-state") == "down" and
+                   u.get("admin-state") == "up",
+                   "port-set-admin-round-trip",
+                   "down=%s up=%s" % (d.get("admin-state"),
+                                      u.get("admin-state")))
+    except NlError as exc:
+        ksft.not_ok("port-set-admin-round-trip", "errno=%d" % exc.error)
+
+    del_ep(fab, slot_of(orphan["name"]), orphan["name"])
+
+
 CASES = (
     test_provider_topology_lifecycle,
     test_hotplug_lifecycle,
+    test_peer_unplug,
+    test_orphan_lifecycle,
 )
 
 
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
index 30fb0edb02b9..9e5dfd6a6ba9 100644
--- a/tools/testing/selftests/drivers/gpu/drm_fabric/lib_drm_fabric.py
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/lib_drm_fabric.py
@@ -267,6 +267,16 @@ def family_has_op(fab, name):
     return name in getattr(fab, "ops", {})
 
 
+def select_cases(fab, cases, mutation_cases, probe="fabric-new"):
+    """Return @cases, dropping @mutation_cases when @probe (a representative
+    mutation op) is absent from the family.
+    """
+    if family_has_op(fab, probe):
+        return tuple(cases)
+    drop = set(mutation_cases)
+    return tuple(c for c in cases if c not in drop)
+
+
 # System helpers (kselftest runs as root)
 
 def is_root():
@@ -462,6 +472,13 @@ def fabricsim(ksft, topology=None, need_debugfs=False, need_control=None,
         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"))
+    else:
+        # Running against providers somebody else loaded (--no-load, or a
+        # previous suite that restored the sim but kept the core). There is no
+        # module to unwind, but the suite can still add endpoints and peers,
+        # and without a teardown that state would leak into the next suite and
+        # survive the timeout killer's SIGTERM. Restore the default shape.
+        on_teardown(sim_restore_default)
 
     if not module_loaded("drm_fabric_sim"):
         ksft.skip_all("drm_fabric_sim not loaded")
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/netns_abi.py b/tools/testing/selftests/drivers/gpu/drm_fabric/netns_abi.py
new file mode 100755
index 000000000000..ea165e870130
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/netns_abi.py
@@ -0,0 +1,294 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+"""
+Confinement is by init_net, not CAP_NET_ADMIN-in-userns: a child that
+unshares into its own user+net namespace (or net-only, without
+CONFIG_USER_NS) and regains root must still be refused, and specifically
+refused *while holding CAP_NET_ADMIN* -- the complement of
+cap_netadmin.py's unprivileged-in-init_net case. Verifies the child truly
+left init_net and the family still resolves before trusting any -EPERM.
+
+Requires drm_fabric + drm_fabric_sim; run as root. Skips without user
+namespace support.
+"""
+
+import ctypes
+import errno
+import json
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import lib_drm_fabric as L
+
+CLONE_NEWUSER = 0x10000000
+CLONE_NEWNET = 0x40000000
+CAP_NET_ADMIN = 12
+
+
+def _cap_effective():
+    """CapEff bitmask of the calling thread, or None if unreadable."""
+    try:
+        with open("/proc/self/status", encoding="ascii") as f:
+            for line in f:
+                if line.startswith("CapEff:"):
+                    return int(line.split()[1], 16)
+    except OSError:
+        pass
+    return None
+
+
+def _map_self(uid, gid):
+    """Map @uid/@gid to 0 in the new user namespace. Ids must be read before
+    unsharing: an unmapped namespace makes getuid() answer the overflow
+    uid, and the kernel only accepts a self-map with the caller's real
+    parent-side id.
+    """
+    try:
+        # setgroups must be denied before gid_map is writable.
+        with open("/proc/self/setgroups", "w", encoding="ascii") as f:
+            f.write("deny")
+        with open("/proc/self/uid_map", "w", encoding="ascii") as f:
+            f.write("0 %d 1" % uid)
+        with open("/proc/self/gid_map", "w", encoding="ascii") as f:
+            f.write("0 %d 1" % gid)
+    except OSError as exc:
+        return "id map: %s" % exc
+    return None
+
+
+def _enter_namespaces():
+    """Enter a non-initial network namespace; returns (mode, failure).
+    Prefers "user+net" (models container root); falls back to "net" alone
+    when CONFIG_USER_NS is absent, which is if anything the sharper case
+    since the caller then keeps the initial CAP_NET_ADMIN, isolating the
+    namespace check.
+    """
+    libc = ctypes.CDLL(None, use_errno=True)
+    uid, gid = os.getuid(), os.getgid()
+
+    if libc.unshare(CLONE_NEWUSER | CLONE_NEWNET) == 0:
+        fail = _map_self(uid, gid)
+        return (None, fail) if fail else ("user+net", None)
+    first = os.strerror(ctypes.get_errno())
+
+    if libc.unshare(CLONE_NEWNET) == 0:
+        return "net", None
+    return None, ("user+net: %s; net: %s"
+                  % (first, os.strerror(ctypes.get_errno())))
+
+
+def _try(fab, NlError, fn):
+    """Return 'ok' or the positive errno the ABI answered with."""
+    try:
+        fn(fab)
+        return "ok"
+    except NlError as exc:
+        return L.nl_errno(exc)
+
+
+def _child_probe(w):
+    """Everything measured inside the new namespaces, reported as one JSON blob."""
+    out = {"stage": "start"}
+    try:
+        mode, fail = _enter_namespaces()
+        if fail:
+            out = {"stage": "unshare", "detail": fail}
+            raise SystemExit
+
+        out = {
+            "stage": "entered",
+            "mode": mode,
+            "ns_inode": os.stat("/proc/self/ns/net").st_ino,
+            "cap_eff": _cap_effective(),
+        }
+
+        _, NlError = L.import_ynl()
+        try:
+            fab = L.DrmFabric()
+        except Exception as exc:  # noqa: BLE001
+            out["family"] = "error: %s" % exc
+            raise SystemExit
+        out["family"] = "ok"
+
+        out["fabric_get"] = _try(fab, NlError,
+                                 lambda f: f.do("fabric-get", {"fabric-id": 1}))
+        out["fabric_get_dump"] = _try(fab, NlError,
+                                      lambda f: list(f.dump("fabric-get", {})))
+        out["fabric_new"] = _try(
+            fab, NlError,
+            lambda f: f.do("fabric-new", {"fabric-new-params": {
+                "type": "synthetic", "name": "netns", "instance-id": 0x4E5}}))
+    except SystemExit:
+        pass
+    except Exception as exc:  # noqa: BLE001
+        out["stage"] = "exception"
+        out["detail"] = str(exc)
+    os.write(w, json.dumps(out).encode())
+
+
+_PROBE = None
+
+
+def probe():
+    """Run the namespaced child once and cache what it reported."""
+    global _PROBE
+    if _PROBE is not None:
+        return _PROBE
+
+    r, w = os.pipe()
+    pid = os.fork()
+    if pid == 0:  # child
+        os.close(r)
+        try:
+            _child_probe(w)
+        finally:
+            os.close(w)
+            os._exit(0)
+
+    os.close(w)
+    buf = b""
+    while True:
+        chunk = os.read(r, 4096)
+        if not chunk:
+            break
+        buf += chunk
+    os.close(r)
+    os.waitpid(pid, 0)
+
+    try:
+        _PROBE = json.loads(buf.decode())
+    except ValueError:
+        _PROBE = {"stage": "no-report"}
+    return _PROBE
+
+
+class Cfg:
+    def __init__(self, fab, nl_error):
+        self.fab = fab
+        self.NlError = nl_error
+        self.init_ns = os.stat("/proc/self/ns/net").st_ino
+
+
+def _entered(ksft, cfg, name):
+    """Common gate: report SKIP or FAIL when the child never got far enough."""
+    p = probe()
+    if p.get("stage") == "unshare":
+        ksft.skip(name, "cannot create user+net namespace: %s"
+                  % p.get("detail", "?"))
+        return None
+    if p.get("stage") != "entered":
+        ksft.not_ok(name, "child did not reach the namespace: %s" % p)
+        return None
+    return p
+
+
+def test_child_left_init_net(ksft, cfg):
+    """Control: the child must really be in a different network namespace.
+    Without it, a kernel lacking CONFIG_NET_NS could leave the child in
+    init_net and every -EPERM below would be vacuous.
+    """
+    p = _entered(ksft, cfg, "netns-child-left-init-net")
+    if p is None:
+        return
+    ksft.check(p["ns_inode"] != cfg.init_ns, "netns-child-left-init-net",
+               "mode=%s child ns=%s parent ns=%s"
+               % (p.get("mode"), p["ns_inode"], cfg.init_ns))
+
+
+def test_child_holds_cap_net_admin(ksft, cfg):
+    """Control: the child must hold CAP_NET_ADMIN, else the -EPERM
+    assertions below would just be an ordinary unprivileged rejection,
+    proving nothing about namespace confinement.
+    """
+    p = _entered(ksft, cfg, "netns-child-holds-cap-net-admin")
+    if p is None:
+        return
+    cap = p.get("cap_eff")
+    ksft.check(cap is not None and bool(cap & (1 << CAP_NET_ADMIN)),
+               "netns-child-holds-cap-net-admin",
+               "mode=%s CapEff=%s"
+               % (p.get("mode"), "?" if cap is None else "0x%x" % cap))
+
+
+def test_family_visible_in_child_netns(ksft, cfg):
+    """Control: the family is netnsok and resolves in the new namespace,
+    else the errnos below would be genetlink failing to find it, not the
+    family refusing the caller.
+    """
+    p = _entered(ksft, cfg, "netns-family-resolves")
+    if p is None:
+        return
+    ksft.check(p.get("family") == "ok", "netns-family-resolves",
+               "family=%s" % p.get("family"))
+
+
+def _expect_eperm(ksft, cfg, key, name):
+    p = _entered(ksft, cfg, name)
+    if p is None:
+        return
+    if p.get("family") != "ok":
+        ksft.not_ok(name, "family did not resolve; errno is not meaningful")
+        return
+    got = p.get(key)
+    ksft.check(got == errno.EPERM, name,
+               "mode=%s result=%s (expected EPERM)" % (p.get("mode"), got))
+
+
+def test_fabric_get_refused(ksft, cfg):
+    """A read is refused too: confinement is not limited to mutation."""
+    _expect_eperm(ksft, cfg, "fabric_get", "netns-fabric-get-eperm")
+
+
+def test_fabric_get_dump_refused(ksft, cfg):
+    """Dumps take the same check as doit handlers."""
+    _expect_eperm(ksft, cfg, "fabric_get_dump", "netns-fabric-get-dump-eperm")
+
+
+def test_fabric_new_refused(ksft, cfg):
+    """Provisioning is refused despite the child holding CAP_NET_ADMIN."""
+    _expect_eperm(ksft, cfg, "fabric_new", "netns-fabric-new-eperm")
+
+
+def test_init_net_topology_unchanged(ksft, cfg):
+    """The refused child must not have created anything in init_net."""
+    fab, NlError = cfg.fab, cfg.NlError
+    try:
+        names = [f["fabric"].get("name") for f in fab.dump("fabric-get", {})]
+    except NlError as exc:
+        ksft.not_ok("netns-init-net-unchanged", "errno=%d" % L.nl_errno(exc))
+        return
+    ksft.check("netns" not in names, "netns-init-net-unchanged",
+               "fabrics=%s" % names)
+
+
+CASES = (
+    test_child_left_init_net,
+    test_child_holds_cap_net_admin,
+    test_family_visible_in_child_netns,
+    test_fabric_get_refused,
+    test_fabric_get_dump_refused,
+    test_fabric_new_refused,
+    test_init_net_topology_unchanged,
+)
+
+# Only the provisioning case needs a mutation-capable build; confinement of
+# reads and dumps is a query-only contract asserted on either build.
+MUTATION_CASES = (
+    test_fabric_new_refused,
+)
+
+
+def main():
+    ksft = L.Ksft()
+    _, NlError = L.import_ynl()
+
+    with L.fabricsim(ksft) as fab:
+        L.run_cases(ksft, Cfg(fab, NlError),
+                    L.select_cases(fab, CASES, MUTATION_CASES))
+    ksft.finish()
+
+
+if __name__ == "__main__":
+    main()
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
index 0d4d60d45e5a..16afa185e87b 100755
--- a/tools/testing/selftests/drivers/gpu/drm_fabric/nl_policy_probe.py
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/nl_policy_probe.py
@@ -7,9 +7,6 @@ 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
@@ -116,34 +113,77 @@ def _parse_all_enums(text):
     return out
 
 
+# Symbols the uAPI defines on every build. Their absence means the header did
+# not parse or is not drm_fabric's, which is distinct from a query-only build
+# and must not be confused with one.
+_REQUIRED_SYMS = ("DRM_FABRIC_CMD_FABRIC_GET", "DRM_FABRIC_CMD_PORT_GET",
+                  "DRM_FABRIC_A_FABRIC_ID", "DRM_FABRIC_A_ENDPOINT_ID",
+                  "DRM_FABRIC_A_PORT_INDEX", "DRM_FABRIC_A_PEER",
+                  "DRM_FABRIC_A_PEER_ATTRS_PEER_ID",
+                  "DRM_FABRIC_A_PEER_ATTRS_TYPE", "__DRM_FABRIC_A_MAX")
+
+# Commands that exist only once topology provisioning is present. Whether the
+# header defines them describes the build, which is what lets a command missing
+# from the live family be reported as a failure instead of a skip.
+_MUTATION_CMDS = ("DRM_FABRIC_CMD_FABRIC_NEW", "DRM_FABRIC_CMD_FABRIC_DEL",
+                  "DRM_FABRIC_CMD_ENDPOINT_SET", "DRM_FABRIC_CMD_PORT_SET",
+                  "DRM_FABRIC_CMD_PORT_PEER_NEW",
+                  "DRM_FABRIC_CMD_PORT_PEER_DEL")
+_MUTATION_SYMS = _MUTATION_CMDS + ("DRM_FABRIC_A_ADMIN_STATE",
+                                  "DRM_FABRIC_A_FABRIC_NEW_PARAMS",
+                                  "DRM_FABRIC_A_FABRIC_NEW_PARAMS_TYPE")
+
+
 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"
+    """Resolve ids from the uAPI header, or report why we cannot; returns
+    (ids, header path) or (None, reason). Deliberately no built-in fallback
+    table: a stale entry wouldn't fail loudly, it would probe the wrong
+    attribute and still report success.
+    """
     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
+    if not hdr:
+        return None, ("drm_fabric uAPI header not found; set "
+                      "UAPI_HEADER=/path/to/include/uapi/drm/drm_fabric.h")
+    syms = _parse_all_enums(open(hdr).read())
+    missing = [s for s in _REQUIRED_SYMS if s not in syms]
+    if missing:
+        return None, "%s does not define %s" % (hdr, ", ".join(missing))
+    return syms, hdr
+
+
+_IDS, _ID_SRC = _load_ids()
 
 
-_SYMS, _ID_SRC = _load_ids()
+def _id(name):
+    """Value of @name, or None when this build's header does not define it."""
+    return _IDS.get(name) if _IDS else None
 
-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"]
+CMD_FABRIC_GET = _id("DRM_FABRIC_CMD_FABRIC_GET")
+CMD_PORT_GET = _id("DRM_FABRIC_CMD_PORT_GET")
+CMD_PORT_SET = _id("DRM_FABRIC_CMD_PORT_SET")
+CMD_PORT_PEER_NEW = _id("DRM_FABRIC_CMD_PORT_PEER_NEW")
+CMD_FABRIC_NEW = _id("DRM_FABRIC_CMD_FABRIC_NEW")
 
-# 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
+A_FABRIC_ID = _id("DRM_FABRIC_A_FABRIC_ID")
+A_ENDPOINT_ID = _id("DRM_FABRIC_A_ENDPOINT_ID")
+A_PORT_INDEX = _id("DRM_FABRIC_A_PORT_INDEX")
+A_ADMIN_STATE = _id("DRM_FABRIC_A_ADMIN_STATE")
+A_PEER = _id("DRM_FABRIC_A_PEER")
+A_FABRIC_NEW_PARAMS = _id("DRM_FABRIC_A_FABRIC_NEW_PARAMS")
+
+A_PEER_PEER_ID = _id("DRM_FABRIC_A_PEER_ATTRS_PEER_ID")
+A_PEER_TYPE = _id("DRM_FABRIC_A_PEER_ATTRS_TYPE")
+A_FABRIC_NEW_PARAMS_TYPE = _id("DRM_FABRIC_A_FABRIC_NEW_PARAMS_TYPE")
+
+# One past the top-level attribute set's upper bound, so every command
+# strict-rejects it: no per-command maxattr can exceed the set it indexes.
+# __DRM_FABRIC_A_MAX is that value by construction, so this tracks the set as
+# it grows instead of quietly aliasing a real attribute once it does.
+A_UNKNOWN = _id("__DRM_FABRIC_A_MAX")
+
+# What the build supports, as opposed to what the running family advertises.
+BUILD_HAS_MUTATION = bool(_IDS) and all(s in _IDS for s in _MUTATION_SYMS)
 
 
 # NLA builders
@@ -158,6 +198,17 @@ def nla(attr_type, payload):
     return struct.pack("=HH", length, attr_type) + payload + pad
 
 
+def nla_nest(attr_type, payload):
+    """Build a nest the way a real client does.
+
+    Strict validation rejects an attribute the policy declares as a nest
+    unless NLA_F_NESTED is set, before it ever recurses into the nested
+    policy. Without the flag a probe aimed at a nested member only ever
+    reaches the outer parse.
+    """
+    return nla(attr_type | NLA_F_NESTED, payload)
+
+
 def nla_u32(attr_type, val):
     return nla(attr_type, struct.pack("=I", val & 0xFFFFFFFF))
 
@@ -173,6 +224,16 @@ def build_msg(family_id, cmd, seq, payload, flags=NLM_F_REQUEST | NLM_F_ACK):
     return nlh + body
 
 
+# One counter for every request the suite sends, so each reply can be matched
+# to the request that caused it and no two requests ever share a sequence.
+_SEQ = [100]
+
+
+def _next_seq():
+    _SEQ[0] += 1
+    return _SEQ[0]
+
+
 # Socket helpers
 
 def open_sock():
@@ -234,17 +295,32 @@ def drain(sock, first_timeout=0.5, more_timeout=0.3):
     return msgs
 
 
+def _getfamily(sock, name):
+    """Send one CTRL_CMD_GETFAMILY and return the datagram that answers it.
+    Only a reply matching our own sequence is accepted: an earlier request's
+    queued ACK or late reply would otherwise look like a family that
+    advertises nothing, silently disabling every introspection check.
+    """
+    seq = _next_seq()
+    sock.send(build_msg(GENL_ID_CTRL, CTRL_CMD_GETFAMILY, seq,
+                        nla(CTRL_ATTR_FAMILY_NAME, name + b"\x00"),
+                        flags=NLM_F_REQUEST))
+    while True:
+        try:
+            data = sock.recv(65536)
+        except socket.timeout:
+            return None
+        (_, mtype, _, mseq, _) = struct.unpack_from("=IHHII", data, 0)
+        if mseq != seq:
+            continue
+        if mtype == NLMSG_ERROR:
+            return None
+        return data
+
+
 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:
+    data = _getfamily(sock, name)
+    if data is None:
         return None
     attrs = data[NLMSG_HDRLEN + GENL_HDRLEN:]
     for atype, payload in iter_attrs(attrs):
@@ -263,17 +339,8 @@ def get_family_info(sock, name):
     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:
+    data = _getfamily(sock, name)
+    if data is None:
         return None
 
     info = {"version": None, "ops": {}, "mcast": set()}
@@ -306,17 +373,13 @@ def get_family_info(sock, name):
 # 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))
+    sock.send(build_msg(fid, cmd, _next_seq(), payload))
     msgs = drain(sock)
     rejected = [-e for (t, e) in msgs
                if t == NLMSG_ERROR and e is not None and e != 0]
@@ -364,12 +427,22 @@ class Cfg:
     def __init__(self, sock, fid):
         self.sock = sock
         self.fid = fid
+        # Whether the running family advertises the mutation commands, from
+        # live introspection in main(): True, False, or None when the
+        # introspection itself failed. The three states are kept apart because
+        # "this build has no mutation commands" is a skip while "this build has
+        # them but the family does not offer them" is a failure.
+        self.live_mutation = None
 
 
 def test_malformed_requests(ksft, cfg):
     sock, fid = cfg.sock, cfg.fid
     # Malformed framing/attributes must fail validation with -EINVAL.
     EINVAL = {errno.EINVAL}
+    # Out-of-range enums are caught by the generated NLA_POLICY range checks,
+    # which report -ERANGE and nothing else. Accepting -EINVAL as well would
+    # let a malformed probe that never reaches the range check pass silently.
+    ERANGE = {errno.ERANGE}
 
     case_rejected(ksft, "wrong-type-short-u32", sock, fid, CMD_FABRIC_GET,
                   nla(A_FABRIC_ID, struct.pack("=H", 1)), EINVAL)
@@ -377,10 +450,79 @@ def test_malformed_requests(ksft, cfg):
     case_rejected(ksft, "unknown-attribute-id", sock, fid, CMD_FABRIC_GET,
                   nla_u32(A_FABRIC_ID, 1) + nla_u32(A_UNKNOWN, 0), EINVAL)
 
+    # Policy errors are unreachable when mutation commands are absent.
+    if cfg.live_mutation:
+        # Truncated nest: PEER header claims 64 bytes but carries 4. Rejected
+        # while walking the attributes, before any policy runs.
+        bad_nest = (struct.pack("=HH", 64, A_PEER | NLA_F_NESTED) +
+                    b"\x00\x00\x00\x00")
+        case_rejected(ksft, "truncated-nest", sock, fid, CMD_PORT_PEER_NEW,
+                      nla_u32(A_ENDPOINT_ID, 0) + nla_u32(A_PORT_INDEX, 0) + bad_nest,
+                      EINVAL)
+
+        # Out-of-range enum: admin-state past DRM_FABRIC_ADMIN_UP.
+        case_rejected(ksft, "enum-range-admin-state", sock, fid, CMD_PORT_SET,
+                      nla_u32(A_ENDPOINT_ID, 0) + nla_u32(A_PORT_INDEX, 0) +
+                      nla_u32(A_ADMIN_STATE, 0xFFFFFFFF), ERANGE)
+
+        # Out-of-range enum: peer-type past DRM_FABRIC_PEER_SWITCH, inside a
+        # nest, so this only reaches the nested policy as a well-formed nest.
+        peer = nla_u64(A_PEER_PEER_ID, 0x1) + nla_u32(A_PEER_TYPE, 99)
+        case_rejected(ksft, "enum-range-peer-type", sock, fid, CMD_PORT_PEER_NEW,
+                      nla_u32(A_ENDPOINT_ID, 0) + nla_u32(A_PORT_INDEX, 0) +
+                      nla_nest(A_PEER, peer), ERANGE)
+
+        # Zero fabric-type, which the enum starts above and so never names.
+        # The range check runs before the doit, so the refusal predates any
+        # fabric the request could have created, which the next case asserts.
+        before = fabric_count(sock, fid)
+        case_rejected(ksft, "enum-range-fabric-type", sock, fid, CMD_FABRIC_NEW,
+                      nla_nest(A_FABRIC_NEW_PARAMS,
+                               nla_u32(A_FABRIC_NEW_PARAMS_TYPE, 0)),
+                      ERANGE)
+        after = fabric_count(sock, fid)
+        ksft.check(before is not None and after == before,
+                   "enum-range-fabric-type-not-created",
+                   "fabrics before=%s after=%s" % (before, after))
+    else:
+        # The case set stays the same either way -- the probes are reported
+        # rather than silently omitted -- but only a query-only build earns a
+        # skip. If this build defines the mutation commands and the family does
+        # not offer them, the probes are unrunnable for a reason worth seeing.
+        if cfg.live_mutation is None:
+            report, why = ksft.not_ok, ("family introspection failed; cannot "
+                                        "tell which commands are advertised")
+        elif BUILD_HAS_MUTATION:
+            report, why = ksft.not_ok, ("uAPI header defines the mutation "
+                                        "commands but the family advertises "
+                                        "none")
+        else:
+            report, why = ksft.skip, ("query-only build: uAPI header defines "
+                                      "no mutation commands")
+        for nm in ("truncated-nest", "enum-range-admin-state",
+                   "enum-range-peer-type", "enum-range-fabric-type",
+                   "enum-range-fabric-type-not-created"):
+            report(nm, why)
+
     case_rejected(ksft, "missing-required-port-index", sock, fid, CMD_PORT_GET,
                   nla_u32(A_ENDPOINT_ID, 0), EINVAL)
 
 
+def fabric_count(sock, fid):
+    """Fabrics a dump reports, or None when the dump itself did not succeed.
+
+    None is distinct from zero on purpose: a dump that errored says nothing
+    about how many fabrics exist, and reporting it as zero would let a broken
+    dump satisfy a claim that nothing was created.
+    """
+    sock.send(build_msg(fid, CMD_FABRIC_GET, _next_seq(), b"",
+                        flags=NLM_F_REQUEST | NLM_F_DUMP))
+    msgs = drain(sock)
+    if not msgs or any(t == NLMSG_ERROR and e != 0 for (t, e) in msgs):
+        return None
+    return sum(1 for (t, _) in msgs if t not in (NLMSG_ERROR, NLMSG_DONE))
+
+
 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
@@ -388,8 +530,7 @@ def test_liveness(ksft, cfg):
     (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"",
+    sock.send(build_msg(fid, CMD_FABRIC_GET, _next_seq(), 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]
@@ -412,15 +553,16 @@ def test_liveness(ksft, cfg):
 
 def test_family_introspection(ksft, cfg):
     """Via CTRL_CMD_GETFAMILY: version, admin-perm gating, mcast surface."""
-    getter_ids = [_SYMS[n] for n in (
+    mutator_ids = [_id(n) for n in _MUTATION_CMDS if _id(n) is not None]
+    getter_ids = [_id(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]
+        if _id(n) is not None]
 
     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"):
+                   "genl-mutators-admin-perm", "genl-getters-not-admin-perm"):
             ksft.not_ok(nm, "CTRL_CMD_GETFAMILY introspection failed")
         return
 
@@ -437,8 +579,19 @@ def test_family_introspection(ksft, cfg):
                     "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.
+    # The mutator admin-perm gate only applies once the mutation commands exist
+    # at all; a query-only build registers no mutators to check. Gate on the
+    # build rather than on the live family, so a build that should advertise
+    # mutators but does not fails here instead of dropping the check.
+    if BUILD_HAS_MUTATION:
+        seen_mut = [c for c in mutator_ids if c in ops]
+        bad_mut = [c for c in seen_mut if not (ops[c] & GENL_ADMIN_PERM)]
+        if seen_mut and not bad_mut:
+            ksft.ok("genl-mutators-admin-perm (%d cmds)" % len(seen_mut))
+        else:
+            ksft.not_ok("genl-mutators-admin-perm",
+                        "seen=%s missing-perm=%s" % (seen_mut, bad_mut))
+
     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:
@@ -461,6 +614,11 @@ def main():
     if os.geteuid() != 0:
         tap.skip_all("root is required to load drm_fabric modules")
 
+    # Every probe below is built from uAPI ids, so without them there is
+    # nothing trustworthy to send.
+    if _IDS is None:
+        tap.skip_all(_ID_SRC)
+
     if _maybe_load_modules():
         L.on_teardown(_unload_providers)
 
@@ -476,7 +634,15 @@ def main():
 
     sys.stderr.write("# attribute/command ids from: %s\n" % _ID_SRC)
 
+    # Ask the live family which of the topology-mutation commands it actually
+    # offers. Left as None when the introspection fails, so the probes gated on
+    # it report that rather than treating an unanswered question as a no.
     cfg = Cfg(sock, fid)
+    info = get_family_info(sock, FAMILY_NAME)
+    if info is not None:
+        cfg.live_mutation = any(_id(n) in info["ops"] for n in _MUTATION_CMDS
+                                if _id(n) is not None)
+
     L.run_cases(tap, cfg, CASES)
     tap.finish()
 
diff --git a/tools/testing/selftests/drivers/gpu/drm_fabric/provisioning_scenarios_abi.py b/tools/testing/selftests/drivers/gpu/drm_fabric/provisioning_scenarios_abi.py
new file mode 100755
index 000000000000..21da97f63615
--- /dev/null
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/provisioning_scenarios_abi.py
@@ -0,0 +1,324 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 Intel Corporation
+"""
+End-to-end provisioning lifecycles, tying the implementation to the
+intended flows rather than the isolated mechanics covered elsewhere
+(cap_netadmin/fault/fabric_abi): orchestrated startup and link
+failure/recovery, each detailed on its own test.
+
+Mutation via the real ABI; operational/telemetry state via fabricsim
+debugfs. Needs drm_fabric + drm_fabric_sim (default mesh, 4 ports); root.
+
+Usage: provisioning_scenarios_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"))
+EVT_SETTLE = float(os.environ.get("EVT_SETTLE", "0.2"))
+
+USER_PORT = 3
+
+
+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_orphan(fab, nports):
+    """Register a provider orphan via debugfs; return the new endpoint dict."""
+    before = set(eps_by_name(fab))
+    L.dbg_write("add_orphan", nports)
+    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, ep_id, port_index):
+    return fab.do("port-get", {"endpoint-id": ep_id,
+                               "port-index": port_index}).get(
+                                   "topology-generation")
+
+
+def _port(fab, ep_id, port_index):
+    return fab.do("port-get", {"endpoint-id": ep_id,
+                               "port-index": port_index})["port"]
+
+
+def _ep(fab, ep_id):
+    return fab.do("endpoint-get", {"endpoint-id": ep_id})["endpoint"]
+
+
+def test_orchestrated_startup(ksft, cfg):
+    """Full orchestrated bring-up of a provider-supplied orphan: orphan ->
+    create fabric -> attach -> endpoint admin up -> port admin up ->
+    provider oper ACTIVE -> userspace installs a peer. Administrative
+    intent (userspace) and operational state (provider) move
+    independently.
+    """
+    fab, NlError = cfg.fab, cfg.NlError
+
+    orphan = add_orphan(fab, nports=4)
+    if not orphan:
+        for name in ("startup-orphan-visible", "startup-fabric-created",
+                     "startup-attach-membership",
+                     "startup-attach-endpoint-change-ntf",
+                     "startup-endpoint-admin-up",
+                     "startup-oper-independent-of-admin",
+                     "startup-port-admin-up",
+                     "startup-provider-reports-oper-active",
+                     "startup-oper-active-port-change-ntf",
+                     "startup-userspace-peer-installed"):
+            ksft.not_ok(name, "add_orphan failed")
+        return
+
+    o_id = orphan["endpoint-id"]
+    slot = slot_of(orphan["name"])
+    made_fabric = None
+
+    ksft.check(orphan.get("fabric-id", 0) == 0 and
+               orphan.get("admin-state") == "down", "startup-orphan-visible",
+               "fabric-id=%s admin=%s" % (orphan.get("fabric-id"),
+                                          orphan.get("admin-state")))
+    try:
+        rep = fab.do("fabric-new", {"fabric-new-params": {
+            "type": "synthetic", "name": "startup", "instance-id": 0x57A}})
+        made_fabric = rep.get("fabric-id")
+        ksft.check(made_fabric is not None, "startup-fabric-created",
+                   "reply=%s" % rep)
+
+        ev = L.DrmFabric()
+        ev.ntf_subscribe(L.MCAST_MONITOR)
+        L.settle(EVT_SETTLE)
+        fab.do("endpoint-set", {"endpoint-id": o_id, "fabric-id": made_fabric})
+        attach_ntf = L.wait_ntf(
+            ev, "endpoint-change-ntf", timeout=EVT_DURATION,
+            match=lambda n: n["msg"]["endpoint"].get("endpoint-id") == o_id)
+        e = _ep(fab, o_id)
+        ksft.check(e.get("fabric-id") == made_fabric and
+                   e.get("admin-state") == "down", "startup-attach-membership",
+                   "fabric=%s admin=%s" % (e.get("fabric-id"),
+                                           e.get("admin-state")))
+        ksft.check(attach_ntf is not None,
+                   "startup-attach-endpoint-change-ntf")
+
+        fab.do("endpoint-set", {"endpoint-id": o_id, "admin-state": "up"})
+        ksft.check(_ep(fab, o_id).get("admin-state") == "up",
+                   "startup-endpoint-admin-up")
+
+        # Bring a provider-managed port admin-up; operational state must not
+        # follow automatically -- the provider owns it.
+        pre = _port(fab, o_id, 0)
+        fab.do("port-set", {"endpoint-id": o_id, "port-index": 0,
+                            "admin-state": "up"})
+        p = _port(fab, o_id, 0)
+        ksft.check(p.get("admin-state") == "up", "startup-port-admin-up")
+        ksft.check(pre.get("oper-state") != "active" and
+                   p.get("oper-state") != "active",
+                   "startup-oper-independent-of-admin",
+                   "oper=%s" % p.get("oper-state"))
+
+        evp = L.DrmFabric()
+        evp.ntf_subscribe(L.MCAST_MONITOR)
+        L.settle(EVT_SETTLE)
+        L.dbg_write("ep%d/port0/oper_state" % slot, "active")
+        oper_ntf = L.wait_ntf(
+            evp, "port-change-ntf", timeout=EVT_DURATION,
+            match=lambda n: n["msg"]["port"].get("endpoint-id") == o_id)
+        L.wait_until(lambda: _port(fab, o_id, 0).get("oper-state") == "active")
+        ksft.check(_port(fab, o_id, 0).get("oper-state") == "active",
+                   "startup-provider-reports-oper-active")
+        ksft.check(oper_ntf is not None, "startup-oper-active-port-change-ntf")
+
+        peer = {"peer-id": 0x2A, "type": "accel", "port-index": 0}
+        fab.do("port-peer-new", {"endpoint-id": o_id, "port-index": USER_PORT,
+                                 "peer": peer})
+        pu = _port(fab, o_id, USER_PORT)
+        ksft.check(pu.get("peer") is not None and
+                   pu["peer"].get("peer-id") == 0x2A,
+                   "startup-userspace-peer-installed",
+                   "peer=%s" % pu.get("peer"))
+    finally:
+        for method, vals in (
+                ("port-peer-del", {"endpoint-id": o_id,
+                                   "port-index": USER_PORT}),
+                ("port-set", {"endpoint-id": o_id, "port-index": 0,
+                              "admin-state": "down"}),
+                ("endpoint-set", {"endpoint-id": o_id, "admin-state": "down"}),
+                ("endpoint-set", {"endpoint-id": o_id, "fabric-id": 0})):
+            try:
+                fab.do(method, vals)
+            except NlError:
+                pass
+        if made_fabric is not None:
+            try:
+                fab.do("fabric-del", {"fabric-id": made_fabric})
+            except NlError:
+                pass
+        del_ep(fab, slot, orphan["name"])
+
+
+def test_link_failure_and_recovery(ksft, cfg):
+    """A live link fails and recovers under provider control.
+
+    Uses an initial mesh member (provider-managed port 0 with an established
+    peer, userspace-managed port 3). Asserts administrative intent survives an
+    operational failure, telemetry advances without touching topology-
+    generation, operational transitions do advance it and emit port-change,
+    an identical admin request is a no-op, and a userspace peer is replaced
+    with strict delete-before-new ordering leaving no stale descriptor.
+    """
+    fab, NlError = cfg.fab, cfg.NlError
+    EP, PP, UP = 0, 0, USER_PORT
+
+    fab.do("port-set", {"endpoint-id": EP, "port-index": PP,
+                        "admin-state": "up"})
+    L.dbg_write("ep%d/port%d/oper_state" % (EP, PP), "active")
+    L.wait_until(lambda: _port(fab, EP, PP).get("oper-state") == "active")
+    try:
+        base = fab.do("port-stats-get", {"endpoint-id": EP,
+                                         "port-index": PP})["port-stats"]
+        c0 = base.get("link-down-count", 0)
+        g_active = _gen(fab, EP, PP)
+
+        # Failure: the provider reports the link down.
+        ev = L.DrmFabric()
+        ev.ntf_subscribe(L.MCAST_MONITOR)
+        L.settle(EVT_SETTLE)
+        L.dbg_write("ep%d/port%d/inject" % (EP, PP), "link_down")
+        down_ntf = L.wait_ntf(
+            ev, "port-change-ntf", timeout=EVT_DURATION,
+            match=lambda n: n["msg"]["port"].get("endpoint-id") == EP)
+        L.wait_until(lambda: _port(fab, EP, PP).get("oper-state") == "inactive")
+        p_down = _port(fab, EP, PP)
+        ksft.check(p_down.get("oper-state") == "inactive",
+                   "linkfail-oper-inactive", "oper=%s" % p_down.get(
+                       "oper-state"))
+        ksft.check(p_down.get("admin-state") == "up",
+                   "linkfail-admin-stays-up", "admin=%s" % p_down.get(
+                       "admin-state"))
+        ksft.check(down_ntf is not None, "linkfail-oper-change-port-change-ntf")
+        g_down = _gen(fab, EP, PP)
+        ksft.check(g_active is not None and g_down is not None and
+                   g_down > g_active, "linkfail-oper-change-advances-generation",
+                   "active=%s down=%s" % (g_active, g_down))
+
+        # Telemetry advances; a stats read must not advance topology-generation.
+        s = fab.do("port-stats-get", {"endpoint-id": EP,
+                                      "port-index": PP})["port-stats"]
+        ksft.check(s.get("link-down-count", 0) >= c0 + 1,
+                   "linkfail-link-down-count-increases",
+                   "c0=%d now=%s" % (c0, s.get("link-down-count")))
+        ksft.check(_gen(fab, EP, PP) == g_down,
+                   "linkfail-stats-read-no-generation-bump")
+
+        # Recovery.
+        ev2 = L.DrmFabric()
+        ev2.ntf_subscribe(L.MCAST_MONITOR)
+        L.settle(EVT_SETTLE)
+        L.dbg_write("ep%d/port%d/inject" % (EP, PP), "recover_to_active")
+        up_ntf = L.wait_ntf(
+            ev2, "port-change-ntf", timeout=EVT_DURATION,
+            match=lambda n: n["msg"]["port"].get("endpoint-id") == EP)
+        L.wait_until(lambda: _port(fab, EP, PP).get("oper-state") == "active")
+        ksft.check(_port(fab, EP, PP).get("oper-state") == "active",
+                   "linkfail-recovery-oper-active")
+        g_recovered = _gen(fab, EP, PP)
+        ksft.check(g_recovered > g_down,
+                   "linkfail-recovery-advances-generation",
+                   "down=%s recovered=%s" % (g_down, g_recovered))
+        ksft.check(up_ntf is not None, "linkfail-recovery-port-change-ntf")
+
+        # An identical admin request is a no-op: no generation change.
+        g_pre_noop = _gen(fab, EP, PP)
+        fab.do("port-set", {"endpoint-id": EP, "port-index": PP,
+                            "admin-state": "up"})
+        ksft.check(_gen(fab, EP, PP) == g_pre_noop,
+                   "linkfail-idempotent-admin-noop")
+
+        # Peer replacement on the userspace-managed port: X, then delete, then
+        # Y -- strict delete-before-new ordering, no stale descriptor.
+        peer_x = {"peer-id": 0x101, "type": "accel", "port-index": 0}
+        peer_y = {"peer-id": 0x202, "type": "accel", "port-index": 0}
+        fab.do("port-peer-new", {"endpoint-id": EP, "port-index": UP,
+                                 "peer": peer_x})
+        px = _port(fab, EP, UP).get("peer")
+        fab.do("port-peer-del", {"endpoint-id": EP, "port-index": UP})
+        pmid = _port(fab, EP, UP).get("peer")
+        fab.do("port-peer-new", {"endpoint-id": EP, "port-index": UP,
+                                 "peer": peer_y})
+        py = _port(fab, EP, UP).get("peer")
+        ksft.check(px is not None and px.get("peer-id") == 0x101,
+                   "linkfail-peer-install-x", "peer=%s" % px)
+        ksft.check(pmid is None, "linkfail-peer-del-clears", "peer=%s" % pmid)
+        ksft.check(py is not None and py.get("peer-id") == 0x202,
+                   "linkfail-peer-replace-y-no-stale", "peer=%s" % py)
+
+        # Final query matches the reported stream: oper active + peer Y.
+        pf0 = _port(fab, EP, PP)
+        pfu = _port(fab, EP, UP)
+        ksft.check(pf0.get("oper-state") == "active" and
+                   (pfu.get("peer") or {}).get("peer-id") == 0x202,
+                   "linkfail-final-query-matches",
+                   "oper=%s peer=%s" % (pf0.get("oper-state"),
+                                        pfu.get("peer")))
+    finally:
+        try:
+            fab.do("port-peer-del", {"endpoint-id": EP, "port-index": UP})
+        except NlError:
+            pass
+        L.dbg_write("ep%d/port%d/inject" % (EP, PP), "recover_to_active")
+
+
+CASES = (
+    test_orchestrated_startup,
+    test_link_failure_and_recovery,
+)
+
+
+def main():
+    ksft = L.Ksft()
+    _, NlError = L.import_ynl()
+
+    with L.fabricsim(ksft, need_debugfs=True, need_control="add_orphan") as fab:
+        fid = fabricsim_fid(fab)
+        if fid is None:
+            ksft.skip_all("fabricsim fabric not present")
+        if not L.family_has_op(fab, "fabric-new"):
+            ksft.skip_all("mutation ABI absent (query-only build)")
+
+        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/switch_abi.py b/tools/testing/selftests/drivers/gpu/drm_fabric/switch_abi.py
index 775be4ac2160..152d16cdc464 100755
--- a/tools/testing/selftests/drivers/gpu/drm_fabric/switch_abi.py
+++ b/tools/testing/selftests/drivers/gpu/drm_fabric/switch_abi.py
@@ -4,7 +4,9 @@
 """
 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.
+and peer-id non-resolution, not leaf-switch-leaf reachability. A final case
+reloads at a one-port-per-endpoint request, where the port reserved for
+userspace peers would otherwise consume the only half-edge.
 
 --no-load is ignored (needs a fresh insmod). Run as root.
 """
@@ -78,11 +80,38 @@ def test_switch_id_does_not_resolve(ksft, cfg):
                % sorted(leaked))
 
 
+def test_minimum_request_preserves_switch_wiring(ksft, cfg):
+    """A one-port-per-endpoint request must still leave the switch wired:
+    setup raises the count so the reserved userspace port does not consume
+    the only half-edge.
+
+    Runs last: it reloads the sim, invalidating the snapshot above.
+    """
+    fab = cfg.fab
+    L.rmmod("drm_fabric_sim")
+    if not L.insmod("drm-fabric-sim.ko", "topology=switch", "ports_per_ep=1"):
+        ksft.skip("switch-minimum-request-wired",
+                  "could not load sim with ports_per_ep=1")
+        return
+    if not L.wait_until(lambda: L.module_loaded("drm_fabric_sim")):
+        ksft.skip("switch-minimum-request-wired", "sim did not reappear")
+        return
+
+    eps = [e["endpoint"] for e in fab.dump("endpoint-get", {})]
+    sim_eps = [e for e in eps if e["name"].startswith("sim-ep")]
+    peers = switch_peers(fab, sim_eps)
+    ksft.check(bool(sim_eps) and len(peers) == len(sim_eps),
+               "switch-minimum-request-wired",
+               "ports_per_ep=1: switch-peers=%d leaves=%d"
+               % (len(peers), len(sim_eps)))
+
+
 CASES = (
     test_every_leaf_has_switch_peer,
     test_half_edge_fully_serialized,
     test_single_opaque_switch_id,
     test_switch_id_does_not_resolve,
+    test_minimum_request_preserves_switch_wiring,
 )
 
 
-- 
2.43.0


^ permalink raw reply related	[flat|nested] 13+ messages in thread

end of thread, other threads:[~2026-08-24  8:10 UTC | newest]

Thread overview: 13+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-08-24  8:09 [RFC PATCH 0/12] drm/fabric: vendor-neutral topology infrastructure for scale-up accelerator interconnects Konstantin Sinyuk
2026-08-24  8:09 ` [RFC PATCH 01/12] drm/fabric: add core object model and provider API Konstantin Sinyuk
2026-08-24  8:09 ` [RFC PATCH 02/12] drm/fabric: add query uAPI and generated headers Konstantin Sinyuk
2026-08-24  8:09 ` [RFC PATCH 03/12] drm/fabric: implement query netlink operations Konstantin Sinyuk
2026-08-24  8:09 ` [RFC PATCH 04/12] drm/fabric: add read-only synthetic provider Konstantin Sinyuk
2026-08-24  8:09 ` [RFC PATCH 05/12] drm/fabric: add object-model KUnit tests Konstantin Sinyuk
2026-08-24  8:09 ` [RFC PATCH 06/12] drm/fabric: add YNL query and policy selftests Konstantin Sinyuk
2026-08-24  8:09 ` [RFC PATCH 07/12] drm/fabric: add topology-provisioning core Konstantin Sinyuk
2026-08-24  8:09 ` [RFC PATCH 08/12] drm/fabric: add provisioning netlink uAPI Konstantin Sinyuk
2026-08-24  8:09 ` [RFC PATCH 09/12] drm/fabric: implement mutation netlink operations Konstantin Sinyuk
2026-08-24  8:09 ` [RFC PATCH 10/12] drm/fabric: make the synthetic provider writable Konstantin Sinyuk
2026-08-24  8:09 ` [RFC PATCH 11/12] drm/fabric: add mutation KUnit tests Konstantin Sinyuk
2026-08-24  8:09 ` [RFC PATCH 12/12] drm/fabric: add mutation netlink selftests Konstantin Sinyuk

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for NNTP newsgroup(s).