[PATCH 10/12] sched_ext: Add selftest for blocked donor admission

From: Andrea Righi

Date: Thu Jul 02 2026 - 14:10:59 EST


SCX_OPS_ENQ_BLOCKED allows BPF schedulers to receive blocked proxy
donors through ops.enqueue(), while scx_bpf_task_is_blocked() identifies
donor admission requests and scx_bpf_task_proxy_cpu() returns the CPU
where the mutex owner is blocked. Add test coverage for this interface,
including validation of the owner's CPU.

Exercise a three-task priority inversion on a single CPU using a
weighted vruntime BPF scheduler. A nice +19 owner holds a shared mutex,
a nice -20 donor blocks on it and a nice 0 CPU contender delays the
owner.

Run the workload with SCX_OPS_ENQ_BLOCKED first disabled and then
enabled. In the enabled run, count blocked donor enqueues, validate the
proxy CPU, and dispatch the donor there to facilitate proxy execution.
Report average mutex hold and wait times and their deltas without
enforcing performance results.

Proxy execution coverage requires CONFIG_SCHED_PROXY_EXEC=y, which the
selftest config selects. Expect blocked enqueue events when generic
proxy execution is enabled and none when proxy execution is disabled.

The mutex is provided via a loadable kernel module, built via
TEST_GEN_MODS_DIR, with the test responsible for loading, unloading, and
managing its lifecycle.

Signed-off-by: Andrea Righi <arighi@xxxxxxxxxx>
---
tools/testing/selftests/sched_ext/.gitignore | 4 +
tools/testing/selftests/sched_ext/Makefile | 2 +
tools/testing/selftests/sched_ext/config | 2 +
.../selftests/sched_ext/enq_blocked.bpf.c | 116 +++
.../testing/selftests/sched_ext/enq_blocked.c | 682 ++++++++++++++++++
.../testing/selftests/sched_ext/enq_blocked.h | 21 +
.../selftests/sched_ext/test_modules/Makefile | 13 +
.../test_modules/scx_enq_blocked_test.c | 134 ++++
8 files changed, 974 insertions(+)
create mode 100644 tools/testing/selftests/sched_ext/enq_blocked.bpf.c
create mode 100644 tools/testing/selftests/sched_ext/enq_blocked.c
create mode 100644 tools/testing/selftests/sched_ext/enq_blocked.h
create mode 100644 tools/testing/selftests/sched_ext/test_modules/Makefile
create mode 100644 tools/testing/selftests/sched_ext/test_modules/scx_enq_blocked_test.c

diff --git a/tools/testing/selftests/sched_ext/.gitignore b/tools/testing/selftests/sched_ext/.gitignore
index ae5491a114c09..54a1fd2af713d 100644
--- a/tools/testing/selftests/sched_ext/.gitignore
+++ b/tools/testing/selftests/sched_ext/.gitignore
@@ -4,3 +4,7 @@
!Makefile
!.gitignore
!config
+!test_modules/
+!test_modules/scx_enq_blocked_test.c
+!test_modules/Makefile
+test_modules/*.mod.c
diff --git a/tools/testing/selftests/sched_ext/Makefile b/tools/testing/selftests/sched_ext/Makefile
index 5d2dffca0e918..8eeece67d1de1 100644
--- a/tools/testing/selftests/sched_ext/Makefile
+++ b/tools/testing/selftests/sched_ext/Makefile
@@ -5,6 +5,7 @@ include ../../../scripts/Makefile.arch
include ../../../scripts/Makefile.include

TEST_GEN_PROGS := runner
+TEST_GEN_MODS_DIR := test_modules

# override lib.mk's default rules
OVERRIDE_TARGETS := 1
@@ -164,6 +165,7 @@ all_test_bpfprogs := $(foreach prog,$(wildcard *.bpf.c),$(INCLUDE_DIR)/$(patsubs
auto-test-targets := \
create_dsq \
dequeue \
+ enq_blocked \
enq_last_no_enq_fails \
ddsp_bogus_dsq_fail \
ddsp_vtimelocal_fail \
diff --git a/tools/testing/selftests/sched_ext/config b/tools/testing/selftests/sched_ext/config
index aa901b05c8ad6..affa3cf33470a 100644
--- a/tools/testing/selftests/sched_ext/config
+++ b/tools/testing/selftests/sched_ext/config
@@ -6,3 +6,5 @@ CONFIG_BPF=y
CONFIG_BPF_SYSCALL=y
CONFIG_DEBUG_INFO=y
CONFIG_DEBUG_INFO_BTF=y
+CONFIG_EXPERT=y
+CONFIG_SCHED_PROXY_EXEC=y
diff --git a/tools/testing/selftests/sched_ext/enq_blocked.bpf.c b/tools/testing/selftests/sched_ext/enq_blocked.bpf.c
new file mode 100644
index 0000000000000..c18be4a10f35e
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/enq_blocked.bpf.c
@@ -0,0 +1,116 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES
+ *
+ * Verify that SCX_OPS_ENQ_BLOCKED passes blocked proxy donors through
+ * ops.enqueue().
+ */
+
+#include <scx/common.bpf.h>
+
+#define SHARED_DSQ 0
+
+char _license[] SEC("license") = "GPL";
+
+s32 donor_pid;
+s32 expected_proxy_cpu;
+u64 nr_blocked_enqueues;
+u64 nr_bad_proxy_cpus;
+static u64 vtime_now;
+
+UEI_DEFINE(uei);
+
+s32 BPF_STRUCT_OPS(enq_blocked_select_cpu,
+ struct task_struct *p, s32 prev_cpu, u64 wake_flags)
+{
+ return prev_cpu;
+}
+
+void BPF_STRUCT_OPS(enq_blocked_enqueue, struct task_struct *p, u64 enq_flags)
+{
+ if (scx_bpf_task_is_blocked(p)) {
+ int cpu = scx_bpf_task_proxy_cpu(p);
+
+ if (p->pid == donor_pid) {
+ __sync_fetch_and_add(&nr_blocked_enqueues, 1);
+ if (cpu != expected_proxy_cpu)
+ __sync_fetch_and_add(&nr_bad_proxy_cpus, 1);
+ }
+
+ /*
+ * Try to migrate the donor to the owner's CPU if possible, to
+ * speed up the proxy exec switch and reduce handoff latency.
+ */
+ if (cpu < 0 || !bpf_cpumask_test_cpu(cpu, p->cpus_ptr))
+ cpu = scx_bpf_task_cpu(p);
+
+ scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL_ON | cpu, SCX_SLICE_DFL, enq_flags);
+ return;
+ }
+
+ /* Limit the amount of budget an idling task can accumulate. */
+ u64 vtime = p->scx.dsq_vtime;
+
+ if (time_before(vtime, vtime_now - SCX_SLICE_DFL))
+ vtime = vtime_now - SCX_SLICE_DFL;
+
+ scx_bpf_dsq_insert_vtime(p, SHARED_DSQ, SCX_SLICE_DFL, vtime,
+ enq_flags);
+ if (enq_flags & SCX_ENQ_LAST)
+ scx_bpf_kick_cpu(scx_bpf_task_cpu(p), SCX_KICK_IDLE);
+}
+
+void BPF_STRUCT_OPS(enq_blocked_dispatch, s32 cpu, struct task_struct *prev)
+{
+ scx_bpf_dsq_move_to_local(SHARED_DSQ, 0);
+}
+
+void BPF_STRUCT_OPS(enq_blocked_running, struct task_struct *p)
+{
+ if (time_before(vtime_now, p->scx.dsq_vtime))
+ vtime_now = p->scx.dsq_vtime;
+}
+
+void BPF_STRUCT_OPS(enq_blocked_stopping, struct task_struct *p, bool runnable)
+{
+ u64 delta = scale_by_task_weight_inverse(p,
+ SCX_SLICE_DFL - p->scx.slice);
+
+ scx_bpf_task_set_dsq_vtime(p, p->scx.dsq_vtime + delta);
+}
+
+void BPF_STRUCT_OPS(enq_blocked_enable, struct task_struct *p)
+{
+ scx_bpf_task_set_dsq_vtime(p, vtime_now);
+}
+
+s32 BPF_STRUCT_OPS_SLEEPABLE(enq_blocked_init)
+{
+ int ret;
+
+ ret = scx_bpf_create_dsq(SHARED_DSQ, -1);
+ if (ret) {
+ scx_bpf_error("failed to create DSQ %d (%d)", SHARED_DSQ, ret);
+ return ret;
+ }
+
+ return 0;
+}
+
+void BPF_STRUCT_OPS(enq_blocked_exit, struct scx_exit_info *ei)
+{
+ UEI_RECORD(uei, ei);
+}
+
+SEC(".struct_ops.link")
+struct sched_ext_ops enq_blocked_ops = {
+ .select_cpu = (void *)enq_blocked_select_cpu,
+ .enqueue = (void *)enq_blocked_enqueue,
+ .dispatch = (void *)enq_blocked_dispatch,
+ .running = (void *)enq_blocked_running,
+ .stopping = (void *)enq_blocked_stopping,
+ .enable = (void *)enq_blocked_enable,
+ .init = (void *)enq_blocked_init,
+ .exit = (void *)enq_blocked_exit,
+ .name = "enq_blocked",
+};
diff --git a/tools/testing/selftests/sched_ext/enq_blocked.c b/tools/testing/selftests/sched_ext/enq_blocked.c
new file mode 100644
index 0000000000000..cb1c164d0a127
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/enq_blocked.c
@@ -0,0 +1,682 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES
+ *
+ * Exercise a three-task priority inversion on a single CPU. A high-priority
+ * donor blocks on a mutex held by a low-priority owner while a medium-priority
+ * contender consumes CPU. A weighted-vruntime BPF scheduler runs the workload
+ * with SCX_OPS_ENQ_BLOCKED first disabled and then enabled. When enabled, the
+ * scheduler observes the blocked donor in ops.enqueue(), validates the proxy
+ * CPU, and dispatches the donor there to facilitate proxy execution.
+ *
+ * Report the average mutex hold and wait times for both runs and their deltas.
+ * The timing data is informational; only blocked-donor admission and proxy CPU
+ * selection are validated. CONFIG_SCHED_PROXY_EXEC=y is required to exercise
+ * the proxy-execution paths.
+ */
+#define _GNU_SOURCE
+
+#include <bpf/bpf.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <pthread.h>
+#include <sched.h>
+#include <scx/common.h>
+#include <stdatomic.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <sys/resource.h>
+#include <sys/syscall.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "enq_blocked.bpf.skel.h"
+#include "enq_blocked.h"
+#include "scx_test.h"
+
+#define MODULE_NAME "scx_enq_blocked_test"
+#define MODULE_FILE "test_modules/" MODULE_NAME ".ko"
+#define DEVICE_PATH "/dev/scx_enq_blocked"
+#define WAIT_STEP_US 1000
+#define WAIT_TIMEOUT_MS 2000
+#define NR_TRIALS 10
+#define JOIN_TIMEOUT_MS ((NR_TRIALS + 1) * WAIT_TIMEOUT_MS)
+#define OWNER_NICE 19
+#define DONOR_NICE -20
+#define CONTENDER_NICE 0
+
+struct thread_ctx {
+ atomic_bool start_donor;
+ atomic_bool abort;
+ atomic_bool stop_contender;
+ atomic_int contender_status;
+ atomic_int donor_pid;
+ atomic_int donor_completed;
+ int fd;
+ int cpu;
+};
+
+static bool parse_bool(const char *value, bool *result)
+{
+ if (!strcasecmp(value, "1") || !strcasecmp(value, "y") ||
+ !strcasecmp(value, "yes") || !strcasecmp(value, "on") ||
+ !strcasecmp(value, "true")) {
+ *result = true;
+ return true;
+ }
+
+ if (!strcasecmp(value, "0") || !strcasecmp(value, "n") ||
+ !strcasecmp(value, "no") || !strcasecmp(value, "off") ||
+ !strcasecmp(value, "false")) {
+ *result = false;
+ return true;
+ }
+
+ return false;
+}
+
+static bool cmdline_bool(const char *name, bool default_value)
+{
+ char cmdline[4096], *newline, *saveptr = NULL, *token;
+ size_t name_len = strlen(name);
+ bool value = default_value;
+ FILE *file;
+
+ file = fopen("/proc/cmdline", "r");
+ if (!file)
+ return default_value;
+
+ if (!fgets(cmdline, sizeof(cmdline), file)) {
+ fclose(file);
+ return default_value;
+ }
+ fclose(file);
+ newline = strchr(cmdline, '\n');
+ if (newline)
+ *newline = '\0';
+
+ for (token = strtok_r(cmdline, " ", &saveptr); token;
+ token = strtok_r(NULL, " ", &saveptr)) {
+ bool parsed;
+
+ if (strncmp(token, name, name_len) || token[name_len] != '=')
+ continue;
+ if (parse_bool(token + name_len + 1, &parsed))
+ value = parsed;
+ }
+
+ return value;
+}
+
+static int module_path(char *path, size_t size)
+{
+ ssize_t len;
+ char *slash;
+
+ len = readlink("/proc/self/exe", path, size - 1);
+ if (len < 0)
+ return -errno;
+ path[len] = '\0';
+
+ slash = strrchr(path, '/');
+ if (!slash)
+ return -EINVAL;
+ *slash = '\0';
+
+ if (snprintf(slash, size - (slash - path), "/%s", MODULE_FILE) >=
+ size - (slash - path))
+ return -ENAMETOOLONG;
+
+ return 0;
+}
+
+static int load_test_module(bool *loaded_here)
+{
+ char path[PATH_MAX];
+ int fd, err;
+
+ err = module_path(path, sizeof(path));
+ if (err)
+ return err;
+
+ fd = open(path, O_RDONLY | O_CLOEXEC);
+ if (fd < 0)
+ return -errno;
+
+ if (syscall(SYS_finit_module, fd, "", 0)) {
+ err = errno;
+ close(fd);
+ if (err == EEXIST)
+ return 0;
+ return -err;
+ }
+
+ close(fd);
+ *loaded_here = true;
+ return 0;
+}
+
+static void unload_test_module(bool loaded_here)
+{
+ if (loaded_here && syscall(SYS_delete_module, MODULE_NAME, O_NONBLOCK))
+ SCX_ERR("Failed to unload %s (%d)", MODULE_NAME, errno);
+}
+
+static int pin_to_cpu(int cpu)
+{
+ cpu_set_t mask;
+
+ CPU_ZERO(&mask);
+ CPU_SET(cpu, &mask);
+ return sched_setaffinity(0, sizeof(mask), &mask) ? errno : 0;
+}
+
+static int set_nice(int nice)
+{
+ return setpriority(PRIO_PROCESS, 0, nice) ? errno : 0;
+}
+
+static bool wait_for_pid(atomic_int *pid)
+{
+ int waited_ms;
+
+ for (waited_ms = 0; waited_ms < WAIT_TIMEOUT_MS; waited_ms++) {
+ if (atomic_load_explicit(pid, memory_order_acquire) > 0)
+ return true;
+ usleep(WAIT_STEP_US);
+ }
+
+ return false;
+}
+
+static int wait_for_contender(struct thread_ctx *ctx)
+{
+ int status, waited_ms;
+
+ for (waited_ms = 0; waited_ms < WAIT_TIMEOUT_MS; waited_ms++) {
+ status = atomic_load_explicit(&ctx->contender_status,
+ memory_order_acquire);
+ if (status)
+ return status;
+ usleep(WAIT_STEP_US);
+ }
+
+ return -ETIMEDOUT;
+}
+
+static bool wait_for_donor(struct thread_ctx *ctx, int trial)
+{
+ int waited_ms;
+
+ for (waited_ms = 0; waited_ms < WAIT_TIMEOUT_MS; waited_ms++) {
+ if (atomic_load_explicit(&ctx->donor_completed,
+ memory_order_acquire) >= trial)
+ return true;
+ if (atomic_load_explicit(&ctx->abort, memory_order_relaxed))
+ return false;
+ usleep(WAIT_STEP_US);
+ }
+
+ return false;
+}
+
+static void *contender_fn(void *arg)
+{
+ struct thread_ctx *ctx = arg;
+ int err;
+
+ err = pin_to_cpu(ctx->cpu);
+ if (!err)
+ err = set_nice(CONTENDER_NICE);
+ atomic_store_explicit(&ctx->contender_status, err ? -err : 1,
+ memory_order_release);
+ if (err)
+ return (void *)(uintptr_t)err;
+
+ while (!atomic_load_explicit(&ctx->stop_contender,
+ memory_order_relaxed))
+ ;
+
+ return NULL;
+}
+
+static void *owner_fn(void *arg)
+{
+ struct thread_ctx *ctx = arg;
+ int err, i;
+
+ err = pin_to_cpu(ctx->cpu);
+ if (err)
+ return (void *)(uintptr_t)err;
+ err = set_nice(OWNER_NICE);
+ if (err)
+ return (void *)(uintptr_t)err;
+
+ for (i = 0; i < NR_TRIALS; i++) {
+ if (ioctl(ctx->fd, ENQ_BLOCKED_IOCTL_OWNER))
+ return (void *)(uintptr_t)errno;
+ if (!wait_for_donor(ctx, i + 1))
+ return (void *)(uintptr_t)ETIMEDOUT;
+ }
+
+ return NULL;
+}
+
+static int run_donor_trial(struct thread_ctx *ctx)
+{
+ int waited_ms;
+
+ for (waited_ms = 0; waited_ms < WAIT_TIMEOUT_MS; waited_ms++) {
+ if (!ioctl(ctx->fd, ENQ_BLOCKED_IOCTL_DONOR))
+ return 0;
+ if (errno != EAGAIN)
+ return -errno;
+ usleep(WAIT_STEP_US);
+ }
+
+ return -ETIMEDOUT;
+}
+
+static void *donor_fn(void *arg)
+{
+ struct thread_ctx *ctx = arg;
+ int err, i;
+
+ err = pin_to_cpu(ctx->cpu);
+ if (err)
+ return (void *)(uintptr_t)err;
+ err = set_nice(DONOR_NICE);
+ if (err)
+ return (void *)(uintptr_t)err;
+
+ atomic_store_explicit(&ctx->donor_pid, syscall(SYS_gettid),
+ memory_order_release);
+ while (!atomic_load_explicit(&ctx->start_donor, memory_order_acquire) &&
+ !atomic_load_explicit(&ctx->abort, memory_order_relaxed))
+ sched_yield();
+
+ if (atomic_load_explicit(&ctx->abort, memory_order_relaxed))
+ return NULL;
+
+ for (i = 0; i < NR_TRIALS; i++) {
+ err = run_donor_trial(ctx);
+ if (err)
+ return (void *)(uintptr_t)-err;
+ atomic_store_explicit(&ctx->donor_completed, i + 1,
+ memory_order_release);
+ }
+
+ return NULL;
+}
+
+static void print_avg_time(const char *name, u64 total_ns, u64 samples)
+{
+ u64 avg_ns = samples ? total_ns / samples : 0;
+
+ printf(" %s_avg_ns=%llu (%llu.%03llu ms, samples=%llu)\n", name,
+ (unsigned long long)avg_ns,
+ (unsigned long long)(avg_ns / 1000000),
+ (unsigned long long)((avg_ns / 1000) % 1000),
+ (unsigned long long)samples);
+}
+
+static void print_avg_delta(const char *name, u64 disabled_total,
+ u64 disabled_samples, u64 enabled_total,
+ u64 enabled_samples)
+{
+ u64 disabled_avg, enabled_avg;
+ s64 delta_ns;
+ double delta_pct;
+
+ if (!disabled_samples || !enabled_samples)
+ return;
+
+ disabled_avg = disabled_total / disabled_samples;
+ enabled_avg = enabled_total / enabled_samples;
+ delta_ns = (s64)enabled_avg - (s64)disabled_avg;
+ delta_pct = disabled_avg ? 100.0 * delta_ns / disabled_avg : 0.0;
+
+ printf(" %s_delta_ns=%+lld (%+.2f%%)\n", name,
+ (long long)delta_ns, delta_pct);
+}
+
+static int join_thread(pthread_t thread, const struct timespec *deadline,
+ int *thread_err)
+{
+ void *result;
+ int err;
+
+ err = pthread_timedjoin_np(thread, &result, deadline);
+ if (err)
+ return err;
+
+ *thread_err = (int)(uintptr_t)result;
+ return 0;
+}
+
+static void set_join_deadline(struct timespec *deadline)
+{
+ clock_gettime(CLOCK_REALTIME, deadline);
+ deadline->tv_sec += JOIN_TIMEOUT_MS / 1000;
+ deadline->tv_nsec += (JOIN_TIMEOUT_MS % 1000) * 1000000;
+ if (deadline->tv_nsec >= 1000000000) {
+ deadline->tv_sec++;
+ deadline->tv_nsec -= 1000000000;
+ }
+}
+
+static enum scx_test_status setup(void **ctx)
+{
+ struct enq_blocked *skel;
+ u64 flag;
+
+ skel = enq_blocked__open();
+ SCX_FAIL_IF(!skel, "Failed to open skel");
+ SCX_ENUM_INIT(skel);
+
+ flag = SCX_OPS_ENQ_BLOCKED;
+ if (!flag) {
+ enq_blocked__destroy(skel);
+ fprintf(stderr, "SKIP: SCX_OPS_ENQ_BLOCKED is unavailable\n");
+ return SCX_TEST_SKIP;
+ }
+
+ enq_blocked__destroy(skel);
+ *ctx = NULL;
+ return SCX_TEST_PASS;
+}
+
+static enum scx_test_status run_one(bool enq_blocked,
+ struct enq_blocked_stats *result)
+{
+ struct enq_blocked *skel;
+ struct thread_ctx thread_ctx = {};
+ struct bpf_link *link = NULL;
+ pthread_t owner, donor, contender;
+ struct timespec join_deadline;
+ cpu_set_t mask;
+ bool module_loaded = false;
+ bool owner_started = false, donor_started = false;
+ bool contender_started = false;
+ bool join_timed_out = false;
+ bool proxy_enabled;
+ enum scx_test_status status = SCX_TEST_PASS;
+ int cpu, donor_pid, err, thread_err;
+ u64 nr_blocked;
+ struct enq_blocked_stats stats;
+
+ skel = enq_blocked__open();
+ if (!skel) {
+ SCX_ERR("Failed to open skel");
+ return SCX_TEST_FAIL;
+ }
+ SCX_ENUM_INIT(skel);
+ skel->struct_ops.enq_blocked_ops->flags =
+ SCX_OPS_ENQ_LAST |
+ (enq_blocked ? SCX_OPS_ENQ_BLOCKED : 0);
+ if (enq_blocked__load(skel)) {
+ SCX_ERR("Failed to load skel");
+ status = SCX_TEST_FAIL;
+ goto out_skel;
+ }
+
+ proxy_enabled = cmdline_bool("sched_proxy_exec", true);
+
+ err = load_test_module(&module_loaded);
+ if (err == -EPERM || err == -ENOENT) {
+ fprintf(stderr, "SKIP: cannot load mutex fixture (%d)\n", -err);
+ status = SCX_TEST_SKIP;
+ goto out_skel;
+ }
+ if (err) {
+ SCX_ERR("Failed to load mutex fixture (%d)", -err);
+ status = SCX_TEST_FAIL;
+ goto out_skel;
+ }
+
+ thread_ctx.fd = open(DEVICE_PATH, O_RDONLY | O_CLOEXEC);
+ if (thread_ctx.fd < 0) {
+ SCX_ERR("Failed to open %s (%d)", DEVICE_PATH, errno);
+ status = SCX_TEST_FAIL;
+ goto out_module;
+ }
+ if (ioctl(thread_ctx.fd, ENQ_BLOCKED_IOCTL_RESET_STATS)) {
+ SCX_ERR("Failed to reset mutex statistics (%d)", errno);
+ status = SCX_TEST_FAIL;
+ goto out_fd;
+ }
+
+ if (sched_getaffinity(0, sizeof(mask), &mask)) {
+ SCX_ERR("Failed to get CPU affinity (%d)", errno);
+ status = SCX_TEST_FAIL;
+ goto out_fd;
+ }
+ for (cpu = 0; cpu < CPU_SETSIZE; cpu++) {
+ if (CPU_ISSET(cpu, &mask))
+ break;
+ }
+ if (cpu == CPU_SETSIZE) {
+ status = SCX_TEST_SKIP;
+ goto out_fd;
+ }
+ thread_ctx.cpu = cpu;
+ skel->bss->expected_proxy_cpu = cpu;
+
+ link = bpf_map__attach_struct_ops(skel->maps.enq_blocked_ops);
+ if (!link) {
+ SCX_ERR("Failed to attach scheduler");
+ status = SCX_TEST_FAIL;
+ goto out_fd;
+ }
+
+ err = pthread_create(&contender, NULL, contender_fn, &thread_ctx);
+ if (err) {
+ SCX_ERR("Failed to create contender thread (%d)", err);
+ status = SCX_TEST_FAIL;
+ goto out;
+ }
+ contender_started = true;
+
+ err = wait_for_contender(&thread_ctx);
+ if (err != 1) {
+ SCX_ERR("Contender thread failed (%d)", -err);
+ status = SCX_TEST_FAIL;
+ goto out;
+ }
+
+ err = pthread_create(&owner, NULL, owner_fn, &thread_ctx);
+ if (err) {
+ SCX_ERR("Failed to create owner thread (%d)", err);
+ status = SCX_TEST_FAIL;
+ goto out;
+ }
+ owner_started = true;
+
+ err = pthread_create(&donor, NULL, donor_fn, &thread_ctx);
+ if (err) {
+ SCX_ERR("Failed to create donor thread (%d)", err);
+ status = SCX_TEST_FAIL;
+ goto out;
+ }
+ donor_started = true;
+
+ if (!wait_for_pid(&thread_ctx.donor_pid)) {
+ SCX_ERR("Timed out waiting for donor thread");
+ status = SCX_TEST_FAIL;
+ goto out;
+ }
+
+ donor_pid = atomic_load_explicit(&thread_ctx.donor_pid,
+ memory_order_acquire);
+ skel->bss->donor_pid = donor_pid;
+ atomic_store_explicit(&thread_ctx.start_donor, true,
+ memory_order_release);
+
+out:
+ if (status != SCX_TEST_PASS) {
+ atomic_store_explicit(&thread_ctx.abort, true, memory_order_release);
+ atomic_store_explicit(&thread_ctx.start_donor, true,
+ memory_order_release);
+ }
+
+ set_join_deadline(&join_deadline);
+ if (donor_started) {
+ err = join_thread(donor, &join_deadline, &thread_err);
+ if (err == ETIMEDOUT) {
+ SCX_ERR("Timed out waiting for donor thread");
+ join_timed_out = true;
+ status = SCX_TEST_FAIL;
+ } else if (err) {
+ SCX_ERR("Failed to join donor thread (%d)", err);
+ status = SCX_TEST_FAIL;
+ } else {
+ donor_started = false;
+ if (thread_err) {
+ SCX_ERR("Donor thread failed (%d)", thread_err);
+ status = SCX_TEST_FAIL;
+ }
+ }
+ }
+ if (!join_timed_out && owner_started) {
+ err = join_thread(owner, &join_deadline, &thread_err);
+ if (err == ETIMEDOUT) {
+ SCX_ERR("Timed out waiting for owner thread");
+ join_timed_out = true;
+ status = SCX_TEST_FAIL;
+ } else if (err) {
+ SCX_ERR("Failed to join owner thread (%d)", err);
+ status = SCX_TEST_FAIL;
+ } else {
+ owner_started = false;
+ if (thread_err) {
+ SCX_ERR("Owner thread failed (%d)", thread_err);
+ status = SCX_TEST_FAIL;
+ }
+ }
+ }
+ atomic_store_explicit(&thread_ctx.stop_contender, true,
+ memory_order_release);
+ if (!join_timed_out && contender_started) {
+ err = join_thread(contender, &join_deadline, &thread_err);
+ if (err == ETIMEDOUT) {
+ SCX_ERR("Timed out waiting for contender thread");
+ join_timed_out = true;
+ status = SCX_TEST_FAIL;
+ } else if (err) {
+ SCX_ERR("Failed to join contender thread (%d)", err);
+ status = SCX_TEST_FAIL;
+ } else {
+ contender_started = false;
+ if (thread_err) {
+ SCX_ERR("Contender thread failed (%d)", thread_err);
+ status = SCX_TEST_FAIL;
+ }
+ }
+ }
+
+ /* Restore the fair scheduler before waiting for any stranded thread. */
+ if (join_timed_out) {
+ atomic_store_explicit(&thread_ctx.abort, true,
+ memory_order_release);
+ if (link) {
+ bpf_link__destroy(link);
+ link = NULL;
+ }
+ if (donor_started)
+ pthread_join(donor, NULL);
+ if (owner_started)
+ pthread_join(owner, NULL);
+ if (contender_started)
+ pthread_join(contender, NULL);
+ }
+
+ if (ioctl(thread_ctx.fd, ENQ_BLOCKED_IOCTL_GET_STATS, &stats)) {
+ SCX_ERR("Failed to read mutex statistics (%d)", errno);
+ status = SCX_TEST_FAIL;
+ } else {
+ *result = stats;
+ printf("\n[SCX_OPS_ENQ_BLOCKED=%s]\n",
+ enq_blocked ? "enabled" : "disabled");
+ printf(" proxy_exec=%s\n",
+ proxy_enabled ? "enabled" : "disabled");
+ printf(" owner_nice=%d\n", OWNER_NICE);
+ printf(" donor_nice=%d\n", DONOR_NICE);
+ printf(" contender_nice=%d\n", CONTENDER_NICE);
+ print_avg_time("mutex_hold", stats.hold_time_ns, stats.nr_holds);
+ print_avg_time("mutex_wait", stats.wait_time_ns, stats.nr_waits);
+ }
+
+ nr_blocked = skel->bss->nr_blocked_enqueues;
+ printf(" nr_blocked_enqueues=%llu\n",
+ (unsigned long long)nr_blocked);
+ if (status == SCX_TEST_PASS) {
+ if (enq_blocked && proxy_enabled && !nr_blocked) {
+ SCX_ERR("ops.enqueue() did not receive the blocked donor");
+ status = SCX_TEST_FAIL;
+ } else if ((!enq_blocked || !proxy_enabled) && nr_blocked) {
+ SCX_ERR("ops.enqueue() unexpectedly received %llu blocked donors",
+ (unsigned long long)nr_blocked);
+ status = SCX_TEST_FAIL;
+ } else if (skel->bss->nr_bad_proxy_cpus) {
+ SCX_ERR("scx_bpf_task_proxy_cpu() returned an unexpected CPU %llu times",
+ (unsigned long long)skel->bss->nr_bad_proxy_cpus);
+ status = SCX_TEST_FAIL;
+ }
+ }
+
+ if (skel->data->uei.kind != EXIT_KIND(SCX_EXIT_NONE)) {
+ SCX_ERR("Scheduler exited unexpectedly (kind=%llu code=%lld)",
+ (unsigned long long)skel->data->uei.kind,
+ (long long)skel->data->uei.exit_code);
+ status = SCX_TEST_FAIL;
+ }
+
+ if (link)
+ bpf_link__destroy(link);
+out_fd:
+ close(thread_ctx.fd);
+out_module:
+ unload_test_module(module_loaded);
+out_skel:
+ enq_blocked__destroy(skel);
+ return status;
+}
+
+static enum scx_test_status run(void *ctx)
+{
+ struct enq_blocked_stats disabled = {}, enabled = {};
+ enum scx_test_status status;
+
+ (void)ctx;
+
+ status = run_one(false, &disabled);
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ status = run_one(true, &enabled);
+ if (status != SCX_TEST_PASS)
+ return status;
+
+ printf("\n[delta: enabled - disabled]\n");
+ print_avg_delta("mutex_hold", disabled.hold_time_ns,
+ disabled.nr_holds, enabled.hold_time_ns,
+ enabled.nr_holds);
+ print_avg_delta("mutex_wait", disabled.wait_time_ns,
+ disabled.nr_waits, enabled.wait_time_ns,
+ enabled.nr_waits);
+
+ return SCX_TEST_PASS;
+}
+
+struct scx_test enq_blocked = {
+ .name = "enq_blocked",
+ .description = "Verify BPF-driven proxy donor admission",
+ .setup = setup,
+ .run = run,
+};
+
+REGISTER_SCX_TEST(&enq_blocked)
diff --git a/tools/testing/selftests/sched_ext/enq_blocked.h b/tools/testing/selftests/sched_ext/enq_blocked.h
new file mode 100644
index 0000000000000..e2f9be0f57c9d
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/enq_blocked.h
@@ -0,0 +1,21 @@
+/* SPDX-License-Identifier: GPL-2.0 */
+/* Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES */
+#ifndef __ENQ_BLOCKED_H
+#define __ENQ_BLOCKED_H
+
+#include <linux/ioctl.h>
+#include <linux/types.h>
+
+struct enq_blocked_stats {
+ __u64 hold_time_ns;
+ __u64 wait_time_ns;
+ __u64 nr_holds;
+ __u64 nr_waits;
+};
+
+#define ENQ_BLOCKED_IOCTL_OWNER _IO('s', 1)
+#define ENQ_BLOCKED_IOCTL_DONOR _IO('s', 2)
+#define ENQ_BLOCKED_IOCTL_RESET_STATS _IO('s', 3)
+#define ENQ_BLOCKED_IOCTL_GET_STATS _IOR('s', 4, struct enq_blocked_stats)
+
+#endif /* __ENQ_BLOCKED_H */
diff --git a/tools/testing/selftests/sched_ext/test_modules/Makefile b/tools/testing/selftests/sched_ext/test_modules/Makefile
new file mode 100644
index 0000000000000..a0e9e9401ead6
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/test_modules/Makefile
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: GPL-2.0
+# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES
+
+TESTMODS_DIR := $(realpath $(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
+KDIR ?= $(if $(O),$(O),$(realpath ../../../../..))
+
+obj-m += scx_enq_blocked_test.o
+
+all:
+ +$(Q)$(MAKE) -C $(KDIR) M=$(TESTMODS_DIR) modules
+
+clean:
+ +$(Q)$(MAKE) -C $(KDIR) M=$(TESTMODS_DIR) clean
diff --git a/tools/testing/selftests/sched_ext/test_modules/scx_enq_blocked_test.c b/tools/testing/selftests/sched_ext/test_modules/scx_enq_blocked_test.c
new file mode 100644
index 0000000000000..9662d7872b03f
--- /dev/null
+++ b/tools/testing/selftests/sched_ext/test_modules/scx_enq_blocked_test.c
@@ -0,0 +1,134 @@
+// SPDX-License-Identifier: GPL-2.0
+/*
+ * Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES
+ *
+ * Kernel mutex fixture for the sched_ext SCX_OPS_ENQ_BLOCKED selftest.
+ */
+
+#include <linux/atomic.h>
+#include <linux/fs.h>
+#include <linux/jiffies.h>
+#include <linux/ktime.h>
+#include <linux/miscdevice.h>
+#include <linux/module.h>
+#include <linux/mutex.h>
+#include <linux/sched.h>
+#include <linux/uaccess.h>
+
+#include "../enq_blocked.h"
+
+#define DONOR_WAIT_TIMEOUT msecs_to_jiffies(2000)
+#define MUTEX_HOLD_TIME msecs_to_jiffies(200)
+
+static DEFINE_MUTEX(test_mutex);
+static atomic_t owner_ready = ATOMIC_INIT(0);
+static atomic_t donor_started = ATOMIC_INIT(0);
+static atomic64_t hold_time_ns = ATOMIC64_INIT(0);
+static atomic64_t wait_time_ns = ATOMIC64_INIT(0);
+static atomic64_t nr_holds = ATOMIC64_INIT(0);
+static atomic64_t nr_waits = ATOMIC64_INIT(0);
+
+static long run_owner(void)
+{
+ unsigned long timeout;
+ u64 start_ns;
+ long ret = 0;
+
+ atomic_set(&donor_started, 0);
+ mutex_lock(&test_mutex);
+ start_ns = ktime_get_ns();
+ atomic_set(&owner_ready, 1);
+
+ timeout = jiffies + DONOR_WAIT_TIMEOUT;
+ while (!atomic_read(&donor_started)) {
+ if (time_after(jiffies, timeout)) {
+ ret = -ETIMEDOUT;
+ goto out;
+ }
+ cond_resched();
+ }
+
+ /* Keep yielding while the donor blocks on test_mutex. */
+ timeout = jiffies + MUTEX_HOLD_TIME;
+ while (time_before(jiffies, timeout))
+ cond_resched();
+
+out:
+ atomic_set(&owner_ready, 0);
+ atomic64_add(ktime_get_ns() - start_ns, &hold_time_ns);
+ atomic64_inc(&nr_holds);
+ mutex_unlock(&test_mutex);
+ return ret;
+}
+
+static long run_donor(void)
+{
+ u64 start_ns;
+
+ if (!atomic_read(&owner_ready))
+ return -EAGAIN;
+
+ atomic_set(&donor_started, 1);
+ start_ns = ktime_get_ns();
+ mutex_lock(&test_mutex);
+ atomic64_add(ktime_get_ns() - start_ns, &wait_time_ns);
+ atomic64_inc(&nr_waits);
+ mutex_unlock(&test_mutex);
+ return 0;
+}
+
+static void reset_stats(void)
+{
+ atomic64_set(&hold_time_ns, 0);
+ atomic64_set(&wait_time_ns, 0);
+ atomic64_set(&nr_holds, 0);
+ atomic64_set(&nr_waits, 0);
+}
+
+static long get_stats(unsigned long arg)
+{
+ struct enq_blocked_stats stats = {
+ .hold_time_ns = atomic64_read(&hold_time_ns),
+ .wait_time_ns = atomic64_read(&wait_time_ns),
+ .nr_holds = atomic64_read(&nr_holds),
+ .nr_waits = atomic64_read(&nr_waits),
+ };
+
+ return copy_to_user((void __user *)arg, &stats, sizeof(stats)) ?
+ -EFAULT : 0;
+}
+
+static long enq_blocked_ioctl(struct file *file, unsigned int cmd,
+ unsigned long arg)
+{
+ switch (cmd) {
+ case ENQ_BLOCKED_IOCTL_OWNER:
+ return run_owner();
+ case ENQ_BLOCKED_IOCTL_DONOR:
+ return run_donor();
+ case ENQ_BLOCKED_IOCTL_RESET_STATS:
+ reset_stats();
+ return 0;
+ case ENQ_BLOCKED_IOCTL_GET_STATS:
+ return get_stats(arg);
+ default:
+ return -EINVAL;
+ }
+}
+
+static const struct file_operations enq_blocked_fops = {
+ .owner = THIS_MODULE,
+ .unlocked_ioctl = enq_blocked_ioctl,
+};
+
+static struct miscdevice enq_blocked_device = {
+ .minor = MISC_DYNAMIC_MINOR,
+ .name = "scx_enq_blocked",
+ .fops = &enq_blocked_fops,
+ .mode = 0600,
+};
+
+module_misc_device(enq_blocked_device);
+MODULE_AUTHOR("Andrea Righi <arighi@xxxxxxxxxx>");
+MODULE_LICENSE("GPL");
+MODULE_DESCRIPTION("sched_ext blocked donor test module");
--
2.55.0