diff --git a/0018-Issue-7200-repl-agmt-create-doesn-t-set-some-paramet.patch b/0018-Issue-7200-repl-agmt-create-doesn-t-set-some-paramet.patch new file mode 100644 index 0000000..a2f6305 --- /dev/null +++ b/0018-Issue-7200-repl-agmt-create-doesn-t-set-some-paramet.patch @@ -0,0 +1,171 @@ +From d8ab66a76a1d1f0811e30d370e88cd761eef5e05 Mon Sep 17 00:00:00 2001 +From: Viktor Ashirov +Date: Thu, 23 Jul 2026 09:58:40 +0200 +Subject: [PATCH] Issue 7200 - repl-agmt create doesn't set some parameters + (#7663) + +Bug Description: +The `add_agmt()` function in the dsconf CLI silently ignored flow +and timeout parameters: + --conn-timeout + --protocol-timeout + --wait-async-results + --busy-wait-time + --session-pause-time + --flow-control-window + --flow-control-pause +The CLI args and attribute mappings existed, but the properties dict was +never populated with these values during agreement creation. + +Fix Description: +Add the missing args-to-properties assignments. + +Fixes: https://github.com/389ds/389-ds-base/issues/7200 + +Reviewed by: @mreynolds389 (Thanks!) +--- + .../clu/dsconf_agmt_timeout_attrs_test.py | 106 ++++++++++++++++++ + src/lib389/lib389/cli_conf/replication.py | 14 +++ + 2 files changed, 120 insertions(+) + create mode 100644 dirsrvtests/tests/suites/clu/dsconf_agmt_timeout_attrs_test.py + +diff --git a/dirsrvtests/tests/suites/clu/dsconf_agmt_timeout_attrs_test.py b/dirsrvtests/tests/suites/clu/dsconf_agmt_timeout_attrs_test.py +new file mode 100644 +index 000000000..8c50e3dc2 +--- /dev/null ++++ b/dirsrvtests/tests/suites/clu/dsconf_agmt_timeout_attrs_test.py +@@ -0,0 +1,106 @@ ++# --- BEGIN COPYRIGHT BLOCK --- ++# Copyright (C) 2026 Red Hat, Inc. ++# All rights reserved. ++# ++# License: GPL (version 3 or any later version). ++# See LICENSE for details. ++# --- END COPYRIGHT BLOCK --- ++ ++import os ++import logging ++import pytest ++from test389.topologies import topology_st as topo ++from lib389.cli_base import FakeArgs ++from lib389.cli_conf.replication import add_agmt ++from lib389.replica import Replicas ++from lib389._constants import DEFAULT_SUFFIX ++ ++pytestmark = pytest.mark.tier1 ++ ++DEBUGGING = os.getenv("DEBUGGING", default=False) ++if DEBUGGING: ++ logging.getLogger(__name__).setLevel(logging.DEBUG) ++else: ++ logging.getLogger(__name__).setLevel(logging.INFO) ++log = logging.getLogger(__name__) ++ ++ ++def test_agmt_create_timeout_and_flow_control_attrs(topo): ++ """Verify add_agmt passes timeout and flow-control attributes ++ through to the agreement entry ++ ++ :id: 2c95ce33-7f25-480a-b827-c03ee10da650 ++ :setup: Standalone instance ++ :steps: ++ 1. Enable replication on the instance as a supplier ++ 2. Add agreement with all timeout/flow-control arguments set ++ 3. Read back the agreement and verify each attribute values ++ :expectedresults: ++ 1. Success ++ 2. Success ++ 3. Success ++ """ ++ inst = topo.standalone ++ ++ replicas = Replicas(inst) ++ replicas.create(properties={ ++ 'cn': 'replica', ++ 'nsDS5ReplicaRoot': DEFAULT_SUFFIX, ++ 'nsDS5ReplicaId': '1', ++ 'nsDS5ReplicaType': '3', ++ 'nsDS5Flags': '1', ++ }) ++ ++ EXPECTED = { ++ 'nsds5replicatimeout': '30', ++ 'nsds5replicaprotocoltimeout': '120', ++ 'nsds5replicawaitforasyncresults': '500', ++ 'nsds5replicabusywaittime': '5', ++ 'nsds5replicaSessionPauseTime': '3', ++ 'nsds5replicaflowcontrolwindow': '2000', ++ 'nsds5replicaflowcontrolpause': '4', ++ } ++ ++ args = FakeArgs() ++ # Required args ++ args.AGMT_NAME = ['test-timeout-agmt'] ++ args.suffix = DEFAULT_SUFFIX ++ args.host = 'localhost' ++ args.port = '33333' ++ args.conn_protocol = 'LDAP' ++ args.bind_dn = 'cn=replmgr,cn=config' ++ args.bind_passwd = 'replmgr' ++ args.bind_method = 'SIMPLE' ++ args.init = False ++ ++ # Optional attributes that we're testing ++ args.conn_timeout = EXPECTED['nsds5replicatimeout'] ++ args.protocol_timeout = EXPECTED['nsds5replicaprotocoltimeout'] ++ args.wait_async_results = EXPECTED['nsds5replicawaitforasyncresults'] ++ args.busy_wait_time = EXPECTED['nsds5replicabusywaittime'] ++ args.session_pause_time = EXPECTED['nsds5replicaSessionPauseTime'] ++ args.flow_control_window = EXPECTED['nsds5replicaflowcontrolwindow'] ++ args.flow_control_pause = EXPECTED['nsds5replicaflowcontrolpause'] ++ ++ # The rest is None ++ args.__class__.__getattr__ = lambda self, name: None ++ ++ # Create new agreement with optional attributes ++ add_agmt(inst, None, log, args) ++ ++ # Read it back ++ replica = replicas.get(DEFAULT_SUFFIX) ++ agmt = replica.get_agreements().list()[0] ++ ++ for attr, expected_val in EXPECTED.items(): ++ actual = agmt.get_attr_val_utf8(attr) ++ log.info(f"Checking {attr}: expected={expected_val}, actual={actual}") ++ assert actual == expected_val, \ ++ f"Attribute {attr}: expected '{expected_val}', got '{actual}'" ++ ++ log.info("All timeout and flow-control attributes verified successfully") ++ ++ ++if __name__ == '__main__': ++ CURRENT_FILE = os.path.realpath(__file__) ++ pytest.main(f"-s {CURRENT_FILE}") +diff --git a/src/lib389/lib389/cli_conf/replication.py b/src/lib389/lib389/cli_conf/replication.py +index e6bc823c6..f57f34031 100644 +--- a/src/lib389/lib389/cli_conf/replication.py ++++ b/src/lib389/lib389/cli_conf/replication.py +@@ -942,6 +942,20 @@ def add_agmt(inst, basedn, log, args): + properties['nsds5replicatedattributelisttotal'] = frac_total_list + if args.strip_list is not None: + properties['nsds5replicastripattrs'] = args.strip_list ++ if args.conn_timeout is not None: ++ properties['nsds5replicatimeout'] = args.conn_timeout ++ if args.protocol_timeout is not None: ++ properties['nsds5replicaprotocoltimeout'] = args.protocol_timeout ++ if args.wait_async_results is not None: ++ properties['nsds5replicawaitforasyncresults'] = args.wait_async_results ++ if args.busy_wait_time is not None: ++ properties['nsds5replicabusywaittime'] = args.busy_wait_time ++ if args.session_pause_time is not None: ++ properties['nsds5replicaSessionPauseTime'] = args.session_pause_time ++ if args.flow_control_window is not None: ++ properties['nsds5replicaflowcontrolwindow'] = args.flow_control_window ++ if args.flow_control_pause is not None: ++ properties['nsds5replicaflowcontrolpause'] = args.flow_control_pause + + # Handle the optional bootstrap settings + if args.bootstrap_bind_dn is not None: +-- +2.55.0 + diff --git a/0019-Issue-7490-Enable-USDT-probes-by-default-in-RPM-7491.patch b/0019-Issue-7490-Enable-USDT-probes-by-default-in-RPM-7491.patch new file mode 100644 index 0000000..34f87ef --- /dev/null +++ b/0019-Issue-7490-Enable-USDT-probes-by-default-in-RPM-7491.patch @@ -0,0 +1,2874 @@ +From 06ecd39cc3e1104254d2dddb7f4ceae60c821fb5 Mon Sep 17 00:00:00 2001 +From: Simon Pichugin +Date: Mon, 15 Jun 2026 17:49:46 -0700 +Subject: [PATCH] Issue 7490 - Enable USDT probes by default in RPM (#7491) + +Description: STAP_PROBE points exist in the source tree +but production RPMs are built without --enable-systemtap +and don't pull in systemtap-sdt-devel, so operators +cannot attach bpftrace or stap to a live ns-slapd +without rebuilding. + +Default the RPM build to USDT-on. Rename the configure +flag to --enable-usdt (SystemTap is one of several +consumers). Add five work-queue probes: work_q__enqueue, +work_q__dequeue, worker__busy, worker__idle, +work__blocked. Ship paired bpftrace .bt scripts. + +Fixes: https://github.com/389ds/389-ds-base/issues/7490 + +Assisted by (writing tests): Claude Code + +Reviewed by: @mreynolds389 (Thanks!) +--- + Makefile.am | 21 +- + configure.ac | 19 +- + dirsrvtests/tests/suites/usdt/__init__.py | 3 + + dirsrvtests/tests/suites/usdt/_common.py | 439 +++++++++++++ + .../suites/usdt/usdt_bpftrace_scripts_test.py | 363 +++++++++++ + .../tests/suites/usdt/usdt_probes_test.py | 278 +++++++++ + .../suites/usdt/usdt_stap_scripts_test.py | 354 +++++++++++ + .../tests/suites/usdt/usdt_tracing_test.py | 586 ++++++++++++++++++ + ldap/servers/slapd/connection.c | 43 ++ + ldap/servers/slapd/log.c | 26 +- + ldap/servers/slapd/opshared.c | 10 +- + ldap/servers/slapd/search.c | 6 +- + profiling/bpftrace/probe_do_search_detail.bt | 47 ++ + profiling/bpftrace/probe_log_access_detail.bt | 38 ++ + profiling/bpftrace/probe_op_shared_search.bt | 47 ++ + profiling/bpftrace/probe_work_queue.bt | 36 ++ + profiling/stap/probe_do_search_detail.stp | 12 +- + profiling/stap/probe_log_access_detail.stp | 8 +- + profiling/stap/probe_op_shared_search.stp | 8 +- + profiling/stap/probe_work_queue.stp | 52 ++ + rpm/389-ds-base.spec.in | 18 +- + 21 files changed, 2364 insertions(+), 50 deletions(-) + create mode 100644 dirsrvtests/tests/suites/usdt/__init__.py + create mode 100644 dirsrvtests/tests/suites/usdt/_common.py + create mode 100644 dirsrvtests/tests/suites/usdt/usdt_bpftrace_scripts_test.py + create mode 100644 dirsrvtests/tests/suites/usdt/usdt_probes_test.py + create mode 100644 dirsrvtests/tests/suites/usdt/usdt_stap_scripts_test.py + create mode 100644 dirsrvtests/tests/suites/usdt/usdt_tracing_test.py + create mode 100644 profiling/bpftrace/probe_do_search_detail.bt + create mode 100644 profiling/bpftrace/probe_log_access_detail.bt + create mode 100644 profiling/bpftrace/probe_op_shared_search.bt + create mode 100644 profiling/bpftrace/probe_work_queue.bt + create mode 100644 profiling/stap/probe_work_queue.stp + +diff --git a/Makefile.am b/Makefile.am +index 1986efc3b..b5e1fb5ac 100644 +--- a/Makefile.am ++++ b/Makefile.am +@@ -52,7 +52,7 @@ SYSTEMD_DEFINES = @systemd_defs@ + CMOCKA_INCLUDES = $(CMOCKA_CFLAGS) + + PROFILING_DEFINES = @profiling_defs@ +-SYSTEMTAP_DEFINES = @systemtap_defs@ ++USDT_DEFINES = @usdt_defs@ + NSPR_INCLUDES = $(NSPR_CFLAGS) + + # Rust inclusions. +@@ -158,7 +158,7 @@ PATH_DEFINES = -DLOCALSTATEDIR="\"$(localstatedir)\"" -DSYSCONFDIR="\"$(sysconfd + # Now that we have all our defines in place, setup the CPPFLAGS + + # These flags are the "must have" for all components +-AM_CPPFLAGS = $(DEBUG_DEFINES) $(PROFILING_DEFINES) $(SYSTEMTAP_DEFINES) $(RUST_DEFINES) ++AM_CPPFLAGS = $(DEBUG_DEFINES) $(PROFILING_DEFINES) $(USDT_DEFINES) $(RUST_DEFINES) + AM_CFLAGS = $(DEBUG_CFLAGS) $(GCCSEC_CFLAGS) $(ASAN_CFLAGS) $(MSAN_CFLAGS) $(TSAN_CFLAGS) $(UBSAN_CFLAGS) + AM_CXXFLAGS = $(DEBUG_CXXFLAGS) $(GCCSEC_CFLAGS) $(ASAN_CFLAGS) $(MSAN_CFLAGS) $(TSAN_CFLAGS) $(UBSAN_CFLAGS) + # Flags for Directory Server +@@ -712,6 +712,23 @@ gdbautoload_DATA = ldap/admin/src/scripts/ns-slapd-gdb.py + + dist_sysctl_DATA = ldap/admin/src/70-dirsrv.conf + ++if USDT ++profilingstapdir = $(datadir)/$(PACKAGE_NAME)/profiling/stap ++profilingbpftracedir = $(datadir)/$(PACKAGE_NAME)/profiling/bpftrace ++ ++dist_profilingstap_DATA = \ ++ profiling/stap/probe_work_queue.stp \ ++ profiling/stap/probe_do_search_detail.stp \ ++ profiling/stap/probe_op_shared_search.stp \ ++ profiling/stap/probe_log_access_detail.stp ++ ++dist_profilingbpftrace_DATA = \ ++ profiling/bpftrace/probe_work_queue.bt \ ++ profiling/bpftrace/probe_do_search_detail.bt \ ++ profiling/bpftrace/probe_op_shared_search.bt \ ++ profiling/bpftrace/probe_log_access_detail.bt ++endif ++ + if SYSTEMD + # yes, that is an @ in the filename . . . + systemdsystemunit_DATA = wrappers/$(PACKAGE_NAME)@.service \ +diff --git a/configure.ac b/configure.ac +index c770fb109..a9401665e 100644 +--- a/configure.ac ++++ b/configure.ac +@@ -286,16 +286,19 @@ fi + AC_SUBST([profiling_defs]) + AC_SUBST([profiling_links]) + +-AC_MSG_CHECKING(for --enable-systemtap) +-AC_ARG_ENABLE(systemtap, AS_HELP_STRING([--enable-systemtap], [Enable systemtap probe features (default: no)]), +- [], [ enable_systemtap=no ]) +-AC_MSG_RESULT($enable_systemtap) +-if test "$enable_systemtap" = yes ; then +- systemtap_defs="-DSYSTEMTAP" ++AC_MSG_CHECKING(for --enable-usdt) ++AC_ARG_ENABLE(usdt, AS_HELP_STRING([--enable-usdt], [Enable USDT (User-level Statically Defined Tracing) probes, consumed by bpftrace, SystemTap, dtrace, perf (default: no)]), ++ [], [ enable_usdt=no ]) ++AC_MSG_RESULT($enable_usdt) ++if test "$enable_usdt" = yes ; then ++ AC_CHECK_HEADER([sys/sdt.h], [], ++ [AC_MSG_ERROR([USDT support requires . Install systemtap-sdt-devel (Fedora/RHEL) or systemtap-sdt-dev (Debian/Ubuntu).])]) ++ usdt_defs="-DUSDT" + else +- systemtap_defs="" ++ usdt_defs="" + fi +-AC_SUBST([systemtap_defs]) ++AC_SUBST([usdt_defs]) ++AM_CONDITIONAL([USDT], [test "$enable_usdt" = yes]) + + + # these enables are for optional or experimental features +diff --git a/dirsrvtests/tests/suites/usdt/__init__.py b/dirsrvtests/tests/suites/usdt/__init__.py +new file mode 100644 +index 000000000..e41932a58 +--- /dev/null ++++ b/dirsrvtests/tests/suites/usdt/__init__.py +@@ -0,0 +1,3 @@ ++""" ++ :Requirement: 389-ds-base: USDT Probes (SystemTap / bpftrace) ++""" +diff --git a/dirsrvtests/tests/suites/usdt/_common.py b/dirsrvtests/tests/suites/usdt/_common.py +new file mode 100644 +index 000000000..ae28652e7 +--- /dev/null ++++ b/dirsrvtests/tests/suites/usdt/_common.py +@@ -0,0 +1,439 @@ ++# --- BEGIN COPYRIGHT BLOCK --- ++# Copyright (C) 2026 Red Hat, Inc. ++# All rights reserved. ++# ++# License: GPL (version 3 or any later version). ++# See LICENSE for details. ++# --- END COPYRIGHT BLOCK --- ++# ++import collections ++import concurrent.futures ++import glob ++import os ++import re ++import shutil ++import signal ++import subprocess ++import sys ++import threading ++import time ++ ++import ldap ++ ++from lib389._constants import DEFAULT_SUFFIX, DN_DM, PW_DM ++ ++ ++# bpftrace >= 0.16 prints "Attached N probes"; older versions print ++# "Attaching N probes...". Match both, on either stderr or stdout. ++BPFTRACE_READY_MARKER = re.compile(r'Attach(?:ing|ed)\s+\d+\s+probe') ++ ++ ++def ns_slapd_path(topo): ++ return os.path.join(topo.standalone.ds_paths.sbin_dir, "ns-slapd") ++ ++ ++def libslapd_path(topo): ++ candidates = [] ++ for stem in ("libslapd.so", "libslapd.so.*"): ++ candidates.extend( ++ glob.glob(os.path.join(topo.standalone.ds_paths.lib_dir, "dirsrv", stem)) ++ ) ++ candidates.extend( ++ glob.glob(os.path.join(topo.standalone.ds_paths.lib_dir, stem)) ++ ) ++ concrete = [p for p in candidates if not os.path.islink(p)] ++ return (concrete or candidates or [None])[0] ++ ++ ++def binary_has_sdt_notes(binary): ++ """True if `binary` contains stapsdt notes. Returns False when `readelf` ++ is not on PATH so callers can use this in a fixture without crashing ++ on hosts without binutils; gate the test module with a separate ++ `shutil.which('readelf')` skipif for a clear skip reason. ++ """ ++ ++ try: ++ out = subprocess.run( ++ ["readelf", "-n", binary], ++ capture_output=True, text=True, check=True, ++ ).stdout ++ except FileNotFoundError: ++ return False ++ return "stapsdt" in out ++ ++ ++def _tail(text, n): ++ return "\n".join(text.splitlines()[-n:]) ++ ++ ++def _read_float_env(name, default): ++ value = os.environ.get(name) ++ if not value: ++ return default ++ try: ++ return float(value) ++ except ValueError: ++ return default ++ ++ ++def _read_bool_env(name): ++ return os.environ.get(name, "").lower() in ("1", "true", "yes", "on") ++ ++ ++USDT_DIAG_TIMEOUT = _read_float_env("USDT_DIAG_TIMEOUT", 120.0) ++USDT_DIAG_DEEP = _read_bool_env("USDT_DIAG_DEEP") ++USDT_DIAG_LOGS = _read_bool_env("USDT_DIAG_LOGS") ++ ++ ++def _clip(text, max_chars=12000): ++ if text is None: ++ return "" ++ if len(text) <= max_chars: ++ return text ++ omitted = len(text) - max_chars ++ return f"{text[:max_chars]}\n...[truncated {omitted} chars]\n" ++ ++ ++def _run_diag_cmd(cmd, timeout=8, max_chars=12000): ++ try: ++ proc = subprocess.run( ++ cmd, ++ capture_output=True, ++ text=True, ++ check=False, ++ timeout=timeout, ++ ) ++ except FileNotFoundError as e: ++ return f"{cmd[0]} not found: {e}\n" ++ except subprocess.TimeoutExpired as e: ++ stdout = e.stdout or "" ++ stderr = e.stderr or "" ++ return _clip( ++ f"timed out after {timeout}s\nstdout:\n{stdout}\nstderr:\n{stderr}", ++ max_chars, ++ ) ++ except Exception as e: ++ return f"failed to run {cmd!r}: {e}\n" ++ ++ out = "" ++ if proc.stdout: ++ out += proc.stdout ++ if proc.stderr: ++ out += "\nSTDERR:\n" + proc.stderr ++ if proc.returncode != 0: ++ out += f"\n(exit code {proc.returncode})\n" ++ return _clip(out, max_chars) ++ ++ ++def _read_file(path, max_chars=12000): ++ if not path: ++ return "path unavailable\n" ++ try: ++ with open(path, errors="replace") as fh: ++ return _clip(fh.read(), max_chars) ++ except FileNotFoundError: ++ return f"{path} not found\n" ++ except PermissionError as e: ++ return f"permission denied reading {path}: {e}\n" ++ except OSError as e: ++ return f"failed reading {path}: {e}\n" ++ ++ ++def _tail_file(path, lines=80, max_chars=12000): ++ if not path: ++ return "path unavailable\n" ++ try: ++ with open(path, errors="replace") as fh: ++ tail = collections.deque(fh, maxlen=lines) ++ return _clip("".join(tail), max_chars) ++ except FileNotFoundError: ++ return f"{path} not found\n" ++ except PermissionError as e: ++ return f"permission denied reading {path}: {e}\n" ++ except OSError as e: ++ return f"failed reading {path}: {e}\n" ++ ++ ++def _tracefs_file(name): ++ for base in ("/sys/kernel/tracing", "/sys/kernel/debug/tracing"): ++ path = os.path.join(base, name) ++ if os.path.exists(path): ++ return path ++ return None ++ ++ ++def _proc_text(pid, name, max_chars=12000): ++ return _read_file(os.path.join("/proc", str(pid), name), max_chars=max_chars) ++ ++ ++def _thread_state(pid, max_threads=32, include_stack=False): ++ task_dir = os.path.join("/proc", str(pid), "task") ++ try: ++ tids = sorted(os.listdir(task_dir), key=int) ++ except OSError as e: ++ return f"failed reading {task_dir}: {e}\n" ++ ++ out = [] ++ for tid in tids[:max_threads]: ++ out.append(f"--- tid {tid} status ---") ++ out.append(_read_file(os.path.join(task_dir, tid, "status"), max_chars=4000)) ++ out.append(f"--- tid {tid} wchan ---") ++ out.append(_read_file(os.path.join(task_dir, tid, "wchan"), max_chars=1000)) ++ if include_stack: ++ out.append(f"--- tid {tid} kernel stack ---") ++ out.append(_read_file(os.path.join(task_dir, tid, "stack"), max_chars=4000)) ++ if len(tids) > max_threads: ++ out.append(f"...skipped {len(tids) - max_threads} more threads") ++ return _clip("\n".join(out), 40000) ++ ++ ++def _gdb_backtrace(pid, timeout=20): ++ if not pid: ++ return "pid unavailable\n" ++ if not shutil.which("gdb"): ++ return "gdb not installed\n" ++ return _run_diag_cmd( ++ [ ++ "gdb", "-q", "-batch", ++ "-ex", "set pagination off", ++ "-ex", "thread apply all bt", ++ "-p", str(pid), ++ ], ++ timeout=timeout, ++ max_chars=50000, ++ ) ++ ++ ++def collect_usdt_diagnostics(label, inst=None, target_pid=None, tracer_pid=None, ++ tracer_stderr="", extra=None): ++ """Return a bounded diagnostic bundle for live USDT tracing failures. ++ ++ Defaults stay read-only and fairly small. Set USDT_DIAG_DEEP=1 for kernel ++ stacks/gdb and USDT_DIAG_LOGS=1 for access/error log tails. ++ """ ++ ++ if target_pid is None and inst is not None: ++ try: ++ target_pid = inst.get_pid() ++ except Exception as e: ++ target_pid = None ++ extra = f"{extra or ''}\ninst.get_pid() failed: {e}" ++ ++ sections = [] ++ ++ def add(name, text): ++ sections.append(f"\n===== {name} =====\n{_clip(text)}") ++ ++ add("label", label) ++ if extra: ++ add("extra", str(extra)) ++ add("tracer stderr tail", _tail(tracer_stderr or "", 80)) ++ add("uname", _run_diag_cmd(["uname", "-a"], timeout=3)) ++ add("versions", _run_diag_cmd([ ++ "rpm", "-q", ++ "kernel-core", ++ f"kernel-devel-{os.uname().release}", ++ "systemtap", "systemtap-runtime", "bpftrace", ++ "389-ds-base", "python3-lib389", ++ ], timeout=8)) ++ add("stap version", _run_diag_cmd(["stap", "-V"], timeout=5)) ++ add("bpftrace version", _run_diag_cmd(["bpftrace", "--version"], timeout=5)) ++ add("bpftrace info", _run_diag_cmd(["bpftrace", "--info"], timeout=10)) ++ add("interesting processes", _run_diag_cmd([ ++ "sh", "-c", ++ "ps -efL | grep -E '[p]ytest|ns-slapd|stap|stapio|bpftrace'", ++ ], timeout=5, max_chars=30000)) ++ if tracer_pid: ++ add("tracer process tree", _run_diag_cmd([ ++ "sh", "-c", ++ f"pstree -ap {tracer_pid} 2>/dev/null || ps -efL | awk '$3 == {tracer_pid} || $2 == {tracer_pid}'", ++ ], timeout=5)) ++ else: ++ add("tracer process tree", "tracer pid unavailable\n") ++ add("loaded tracing modules", _run_diag_cmd([ ++ "sh", "-c", ++ "lsmod | grep -E '^(stap_|bpf|uprobe|trace)'", ++ ], timeout=5)) ++ add("uprobe events", _read_file(_tracefs_file("uprobe_events"), max_chars=30000)) ++ add("kprobe events", _read_file(_tracefs_file("kprobe_events"), max_chars=20000)) ++ add("dmesg tail", _run_diag_cmd(["sh", "-c", "dmesg | tail -200"], timeout=8, ++ max_chars=30000)) ++ ++ if tracer_pid: ++ add(f"tracer {tracer_pid} status", _proc_text(tracer_pid, "status")) ++ add(f"tracer {tracer_pid} wchan", _proc_text(tracer_pid, "wchan")) ++ if USDT_DIAG_DEEP: ++ add(f"tracer {tracer_pid} kernel stack", _proc_text(tracer_pid, "stack")) ++ ++ if target_pid: ++ add(f"ns-slapd {target_pid} status", _proc_text(target_pid, "status")) ++ add(f"ns-slapd {target_pid} wchan", _proc_text(target_pid, "wchan")) ++ if USDT_DIAG_DEEP: ++ add(f"ns-slapd {target_pid} threads", _thread_state( ++ target_pid, include_stack=True)) ++ add(f"ns-slapd {target_pid} gdb backtrace", _gdb_backtrace(target_pid)) ++ ++ if inst is not None and USDT_DIAG_LOGS: ++ for name, path in ( ++ ("access log tail", getattr(inst.ds_paths, "access_log", None)), ++ ("error log tail", getattr(inst.ds_paths, "error_log", None)), ++ ): ++ add(name, _tail_file(path)) ++ ++ return "\n".join(sections) ++ ++ ++def run_workload_with_diagnostics(drive_load, label, inst=None, target_pid=None, ++ tracer_pid=None, tracer_stderr_fn=None, ++ timeout=USDT_DIAG_TIMEOUT): ++ """Run drive_load synchronously and emit diagnostics if it appears hung. ++ ++ The workload stays in the pytest thread so lib389/python-ldap behavior is ++ unchanged. The watchdog only prints diagnostics; it does not fail the test ++ or terminate the server. ++ """ ++ ++ if timeout is None: ++ timeout = USDT_DIAG_TIMEOUT ++ ++ done = threading.Event() ++ reported = threading.Event() ++ ++ def watchdog(): ++ if timeout <= 0 or done.wait(timeout): ++ return ++ reported.set() ++ tracer_stderr = tracer_stderr_fn() if tracer_stderr_fn else "" ++ diagnostics = collect_usdt_diagnostics( ++ f"{label}: workload still running after {timeout:g}s", ++ inst=inst, target_pid=target_pid, tracer_pid=tracer_pid, ++ tracer_stderr=tracer_stderr, ++ extra=( ++ "diagnostic watchdog only; workload is still executing in " ++ "the pytest thread" ++ ), ++ ) ++ sys.stderr.write(f"\n{diagnostics}\n") ++ sys.stderr.flush() ++ ++ thread = threading.Thread( ++ target=watchdog, name=f"{label}-diag-watchdog", daemon=True) ++ thread.start() ++ try: ++ drive_load() ++ finally: ++ done.set() ++ thread.join(timeout=1) ++ return reported.is_set() ++ ++ ++def send_process_group(proc, sig): ++ try: ++ os.killpg(proc.pid, sig) ++ except ProcessLookupError: ++ return ++ except OSError: ++ try: ++ proc.send_signal(sig) ++ except ProcessLookupError: ++ return ++ ++ ++def _process_group_exists(pgid): ++ try: ++ os.killpg(pgid, 0) ++ except ProcessLookupError: ++ return False ++ except PermissionError: ++ return True ++ except OSError: ++ return False ++ return True ++ ++ ++def _wait_process_group(proc, timeout): ++ deadline = time.monotonic() + timeout ++ while True: ++ parent_running = proc.poll() is None ++ group_exists = _process_group_exists(proc.pid) ++ if not parent_running and not group_exists: ++ return True ++ ++ remaining = deadline - time.monotonic() ++ if remaining <= 0: ++ return False ++ ++ if parent_running: ++ try: ++ proc.wait(timeout=min(0.1, remaining)) ++ continue ++ except subprocess.TimeoutExpired: ++ pass ++ time.sleep(min(0.1, remaining)) ++ ++ ++def terminate_process_group(proc, wait_timeout=5): ++ send_process_group(proc, signal.SIGTERM) ++ if _wait_process_group(proc, wait_timeout): ++ return ++ ++ send_process_group(proc, signal.SIGKILL) ++ _wait_process_group(proc, wait_timeout) ++ ++ ++def _drive_searches(inst, n=100): ++ for _ in range(n): ++ inst.search_s(DEFAULT_SUFFIX, ldap.SCOPE_SUBTREE, "(objectClass=*)") ++ ++ ++def _drive_searches_concurrent(inst, n=100, parallel=10): ++ """Drive n searches over parallel fresh connections; persistent conns enter ++ turbo mode and bypass the work queue. ++ """ ++ ++ url = f"ldap://localhost:{inst.port}" ++ ++ def one_search(_i): ++ c = ldap.initialize(url) ++ try: ++ c.simple_bind_s(DN_DM, PW_DM) ++ c.search_s(DEFAULT_SUFFIX, ldap.SCOPE_SUBTREE, "(objectClass=*)") ++ finally: ++ try: ++ c.unbind_s() ++ except Exception: ++ pass ++ ++ with concurrent.futures.ThreadPoolExecutor(max_workers=parallel) as ex: ++ list(ex.map(one_search, range(n))) ++ ++ ++class _StderrReader(threading.Thread): ++ """Drain a subprocess's stderr; signal once `ready_marker` is seen. ++ `ready_marker` may be a substring or a compiled re.Pattern. ++ Callers can read `stderr_text` once the process has exited. ++ """ ++ ++ def __init__(self, proc, ready_marker): ++ super().__init__(daemon=True) ++ self._proc = proc ++ self._ready_marker = ready_marker ++ self._lines = [] ++ self.ready = threading.Event() ++ ++ def _matches(self, line): ++ m = self._ready_marker ++ if isinstance(m, str): ++ return m in line ++ return m.search(line) is not None ++ ++ def run(self): ++ for line in iter(self._proc.stderr.readline, ""): ++ self._lines.append(line) ++ if self._matches(line): ++ self.ready.set() ++ self.ready.set() ++ ++ @property ++ def stderr_text(self): ++ return "".join(self._lines) +diff --git a/dirsrvtests/tests/suites/usdt/usdt_bpftrace_scripts_test.py b/dirsrvtests/tests/suites/usdt/usdt_bpftrace_scripts_test.py +new file mode 100644 +index 000000000..ee2df4507 +--- /dev/null ++++ b/dirsrvtests/tests/suites/usdt/usdt_bpftrace_scripts_test.py +@@ -0,0 +1,363 @@ ++# --- BEGIN COPYRIGHT BLOCK --- ++# Copyright (C) 2026 Red Hat, Inc. ++# All rights reserved. ++# ++# License: GPL (version 3 or any later version). ++# See LICENSE for details. ++# --- END COPYRIGHT BLOCK --- ++# ++import logging ++import os ++import re ++import shutil ++import signal ++import subprocess ++import time ++ ++import ldap ++import pytest ++ ++from lib389._constants import DEFAULT_SUFFIX ++from lib389.idm.user import UserAccounts ++from test389.topologies import topology_st as topo ++ ++from ._common import ( ++ ns_slapd_path, libslapd_path, binary_has_sdt_notes, ++ _tail, _drive_searches, _drive_searches_concurrent, _StderrReader, ++ BPFTRACE_READY_MARKER, ++ collect_usdt_diagnostics, run_workload_with_diagnostics, ++ terminate_process_group, ++) ++ ++DEBUGGING = os.getenv("DEBUGGING", default=False) ++log = logging.getLogger(__name__) ++log.setLevel(logging.DEBUG if DEBUGGING else logging.INFO) ++ ++ ++_USDT_LIVE_ACK = os.environ.get('USDT_LIVE_ACK', '').lower() in ('1', 'true', 'yes') ++ ++pytestmark = [ ++ pytest.mark.tier2, ++ pytest.mark.skipif(not _USDT_LIVE_ACK, ++ reason="set USDT_LIVE_ACK=1 to run live bpftrace tests"), ++ pytest.mark.skipif(not shutil.which("bpftrace"), ++ reason="bpftrace is not installed"), ++ pytest.mark.skipif(not shutil.which("readelf"), ++ reason="readelf (binutils) is required"), ++ pytest.mark.skipif(os.geteuid() != 0, ++ reason="bpftrace requires root"), ++] ++ ++PROFILING_DIR = os.path.normpath( ++ os.path.join(os.path.dirname(__file__), ++ "..", "..", "..", "..", "profiling", "bpftrace") ++) ++ ++# Histogram bucket line, e.g. "[1, 2) 42 |@@@@@@@@@@@..." ++# Also matches single-value bracket form like "[0] ..." for the lowest bucket. ++_HIST_BUCKET_RE = re.compile(r'^\s*\[\S+(?:,\s*\S+)?[\)\]]\s+(\d+)\s+\|') ++ ++ ++@pytest.fixture(scope="module") ++def usdt_topo(topo): ++ binary = ns_slapd_path(topo) ++ if not binary_has_sdt_notes(binary): ++ pytest.skip("ns-slapd not built with --enable-usdt") ++ if not libslapd_path(topo): ++ pytest.skip("libslapd.so not located under the instance prefix") ++ return topo ++ ++ ++@pytest.fixture ++def workload_users(usdt_topo): ++ inst = usdt_topo.standalone ++ users = UserAccounts(inst, DEFAULT_SUFFIX) ++ created = [] ++ for i in range(5): ++ try: ++ created.append(users.create_test_user(uid=900000 + i)) ++ except ldap.ALREADY_EXISTS: ++ created.append(users.get(f"test_user_{900000 + i}")) ++ yield created ++ for u in created: ++ try: ++ u.delete() ++ except ldap.NO_SUCH_OBJECT: ++ pass ++ ++ ++def _run_bpftrace_script(script_path, binary_args, drive_load, inst=None, ++ ready_timeout=60.0, drain_wait=1.5, exit_timeout=30.0, ++ workload_timeout=None): ++ """Spawn bpftrace, drive load after attach, SIGINT, return (stdout, stderr, rc).""" ++ ++ cmd = ["bpftrace", script_path, *binary_args] ++ label = os.path.basename(script_path) ++ target_pid = inst.get_pid() if inst is not None else None ++ log.info("running: %s", " ".join(cmd)) ++ proc = subprocess.Popen( ++ cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ++ text=True, bufsize=1, start_new_session=True, ++ ) ++ log.info("started bpftrace pid=%s target_pid=%s script=%s", ++ proc.pid, target_pid, label) ++ reader = _StderrReader(proc, BPFTRACE_READY_MARKER) ++ reader.start() ++ ++ try: ++ if not reader.ready.wait(timeout=ready_timeout): ++ diagnostics = collect_usdt_diagnostics( ++ f"{label}: bpftrace did not attach", ++ inst=inst, target_pid=target_pid, tracer_pid=proc.pid, ++ tracer_stderr=reader.stderr_text, ++ ) ++ terminate_process_group(proc) ++ stdout, _ = proc.communicate() ++ reader.join(timeout=2) ++ pytest.fail( ++ f"bpftrace did not attach within {ready_timeout}s.\n" ++ f"stderr tail:\n{_tail(reader.stderr_text, 40)}\n" ++ f"stdout:\n{stdout}\n{diagnostics}" ++ ) ++ if proc.poll() is not None: ++ stdout, _ = proc.communicate() ++ reader.join(timeout=2) ++ diagnostics = collect_usdt_diagnostics( ++ f"{label}: bpftrace exited at attach", ++ inst=inst, target_pid=target_pid, tracer_pid=proc.pid, ++ tracer_stderr=reader.stderr_text, ++ extra=f"returncode={proc.returncode}", ++ ) ++ pytest.fail( ++ f"bpftrace exited at attach with code {proc.returncode}.\n" ++ f"stderr tail:\n{_tail(reader.stderr_text, 40)}\n" ++ f"stdout:\n{stdout}\n{diagnostics}" ++ ) ++ ++ log.info("bpftrace attached; driving workload script=%s", label) ++ run_workload_with_diagnostics( ++ drive_load, f"bpftrace {label}", ++ inst=inst, target_pid=target_pid, tracer_pid=proc.pid, ++ tracer_stderr_fn=lambda: reader.stderr_text, ++ timeout=workload_timeout, ++ ) ++ log.info("bpftrace workload finished script=%s", label) ++ time.sleep(drain_wait) ++ ++ log.info("sending SIGINT to bpftrace pid=%s script=%s", proc.pid, label) ++ if proc.poll() is None: ++ try: ++ proc.send_signal(signal.SIGINT) ++ except ProcessLookupError: ++ pass ++ stdout, _ = proc.communicate(timeout=exit_timeout) ++ except subprocess.TimeoutExpired: ++ diagnostics = collect_usdt_diagnostics( ++ f"{label}: bpftrace did not exit after SIGINT", ++ inst=inst, target_pid=target_pid, tracer_pid=proc.pid, ++ tracer_stderr=reader.stderr_text, ++ ) ++ terminate_process_group(proc) ++ stdout, _ = proc.communicate() ++ reader.join(timeout=2) ++ pytest.fail( ++ f"bpftrace did not exit within {exit_timeout}s after SIGINT.\n" ++ f"stderr tail:\n{_tail(reader.stderr_text, 40)}\n" ++ f"stdout:\n{stdout}\n{diagnostics}" ++ ) ++ except BaseException: ++ terminate_process_group(proc) ++ reader.join(timeout=2) ++ raise ++ ++ reader.join(timeout=5) ++ log.info("bpftrace exited rc=%s script=%s", proc.returncode, label) ++ return stdout, reader.stderr_text, proc.returncode ++ ++ ++def _nonzero_buckets_for_hist(stdout, hist_name): ++ """Count buckets with count > 0 under `@:` header. Scan until ++ the next blank line or next `@:` header. ++ """ ++ ++ header = f"@{hist_name}:" ++ in_block = False ++ count = 0 ++ for line in stdout.splitlines(): ++ stripped = line.rstrip() ++ if not in_block: ++ if stripped == header or stripped.startswith(header): ++ in_block = True ++ continue ++ if not stripped: ++ break ++ # next map starts a new block; stop counting. ++ if stripped.startswith("@") and ":" in stripped: ++ break ++ m = _HIST_BUCKET_RE.match(line) ++ if m and int(m.group(1)) > 0: ++ count += 1 ++ return count ++ ++ ++def _nonzero_keyed_map_entries(stdout, name): ++ """Count `@[k]: v` lines where v > 0.""" ++ pattern = re.compile(rf'^@{re.escape(name)}\[[^\]]+\]:\s+(\d+)\s*$') ++ return sum( ++ 1 for line in stdout.splitlines() ++ if (m := pattern.match(line.strip())) and int(m.group(1)) > 0 ++ ) ++ ++ ++def _assert_hist_present(stdout, hist_names): ++ """Each name must have a `@:` header in stdout with at least one ++ non-zero bucket below it. ++ """ ++ ++ missing = [n for n in hist_names if f"@{n}:" not in stdout] ++ assert not missing, ( ++ f"missing histogram header(s): {missing}\nstdout:\n{stdout}" ++ ) ++ empty = [(n, _nonzero_buckets_for_hist(stdout, n)) for n in hist_names] ++ bad = [(n, c) for n, c in empty if c < 1] ++ assert not bad, ( ++ "histograms with no non-zero buckets:\n " + ++ "\n ".join(f"@{n}: nonzero_buckets={c}" for n, c in bad) + ++ f"\n\nstdout:\n{stdout}" ++ ) ++ ++ ++def test_probe_work_queue_bt(usdt_topo, workload_users): ++ """probe_work_queue.bt produces queue-depth, wait-latency, idle-counts maps under load. ++ ++ :id: 937d039a-95b0-4fe5-bebf-ee4d703f509a ++ :setup: Standalone instance ++ :steps: ++ 1. Run probe_work_queue.bt against ns-slapd ++ 2. Drive 100 searches over 10 concurrent fresh connections ++ 3. SIGINT and capture default-print ++ :expectedresults: ++ 1. bpftrace exits 0 ++ 2. @queue_depth and @wait_us histograms have at least one non-zero bucket ++ 3. @idle_counts has at least one keyed entry with count > 0 ++ """ ++ ++ inst = usdt_topo.standalone ++ script = os.path.join(PROFILING_DIR, "probe_work_queue.bt") ++ stdout, stderr, rc = _run_bpftrace_script( ++ script, [ns_slapd_path(usdt_topo)], ++ drive_load=lambda: _drive_searches_concurrent(inst, n=100, parallel=10), ++ inst=inst, ++ ) ++ log.debug("stdout:\n%s", stdout) ++ assert rc == 0, f"bpftrace exited {rc}\nstderr tail:\n{_tail(stderr, 40)}" ++ ++ _assert_hist_present(stdout, ["queue_depth", "wait_us"]) ++ ++ idle = _nonzero_keyed_map_entries(stdout, "idle_counts") ++ assert idle >= 1, ( ++ f"@idle_counts has no non-zero keyed entries.\nstdout:\n{stdout}" ++ ) ++ ++ ++def test_probe_do_search_detail_bt(usdt_topo, workload_users): ++ """probe_do_search_detail.bt aggregates four search-phase latencies. ++ ++ :id: 22f219c9-a4a9-4312-aa31-359878da7bb4 ++ :setup: Standalone instance ++ :steps: ++ 1. Run probe_do_search_detail.bt with $1=ns-slapd $2=libslapd.so ++ 2. Drive 100 searches ++ 3. SIGINT and capture default-print ++ :expectedresults: ++ 1. bpftrace exits 0 ++ 2. All four @do_search_ histograms have at least one non-zero bucket ++ """ ++ ++ inst = usdt_topo.standalone ++ script = os.path.join(PROFILING_DIR, "probe_do_search_detail.bt") ++ stdout, stderr, rc = _run_bpftrace_script( ++ script, ++ [ns_slapd_path(usdt_topo), libslapd_path(usdt_topo)], ++ drive_load=lambda: _drive_searches(inst, 100), ++ inst=inst, ++ ) ++ log.debug("stdout:\n%s", stdout) ++ assert rc == 0, f"bpftrace exited {rc}\nstderr tail:\n{_tail(stderr, 40)}" ++ ++ _assert_hist_present(stdout, [ ++ "do_search_full", ++ "do_search_prepared", ++ "do_search_complete", ++ "do_search_finalise", ++ ]) ++ ++ ++def test_probe_op_shared_search_bt(usdt_topo, workload_users): ++ """probe_op_shared_search.bt aggregates four phases of op_shared_search(). ++ ++ :id: 3ad8af11-190b-4c06-bfab-e15daacef432 ++ :setup: Standalone instance ++ :steps: ++ 1. Run probe_op_shared_search.bt against libslapd.so ++ 2. Drive 100 searches ++ 3. SIGINT and capture default-print ++ :expectedresults: ++ 1. bpftrace exits 0 ++ 2. All four @op_shared_search_ histograms have at least one non-zero bucket ++ """ ++ ++ inst = usdt_topo.standalone ++ script = os.path.join(PROFILING_DIR, "probe_op_shared_search.bt") ++ stdout, stderr, rc = _run_bpftrace_script( ++ script, [libslapd_path(usdt_topo)], ++ drive_load=lambda: _drive_searches(inst, 100), ++ inst=inst, ++ ) ++ log.debug("stdout:\n%s", stdout) ++ assert rc == 0, f"bpftrace exited {rc}\nstderr tail:\n{_tail(stderr, 40)}" ++ ++ _assert_hist_present(stdout, [ ++ "op_shared_search_full", ++ "op_shared_search_prepared", ++ "op_shared_search_complete", ++ "op_shared_search_finalise", ++ ]) ++ ++ ++def test_probe_log_access_detail_bt(usdt_topo, workload_users): ++ """probe_log_access_detail.bt aggregates three access-log write phases. ++ ++ :id: 5af6d170-2674-4480-b4f5-a60802a07c08 ++ :setup: Standalone instance ++ :steps: ++ 1. Run probe_log_access_detail.bt against libslapd.so ++ 2. Drive 100 searches ++ 3. SIGINT and capture default-print ++ :expectedresults: ++ 1. bpftrace exits 0 ++ 2. All three @log_access_ histograms have at least one non-zero bucket ++ """ ++ ++ inst = usdt_topo.standalone ++ script = os.path.join(PROFILING_DIR, "probe_log_access_detail.bt") ++ stdout, stderr, rc = _run_bpftrace_script( ++ script, [libslapd_path(usdt_topo)], ++ drive_load=lambda: _drive_searches(inst, 100), ++ inst=inst, ++ ) ++ log.debug("stdout:\n%s", stdout) ++ assert rc == 0, f"bpftrace exited {rc}\nstderr tail:\n{_tail(stderr, 40)}" ++ ++ _assert_hist_present(stdout, [ ++ "log_access_full", ++ "log_access_prepared", ++ "log_access_complete", ++ ]) ++ ++ ++if __name__ == '__main__': ++ # Run isolated ++ # -s for DEBUG mode ++ CURRENT_FILE = os.path.realpath(__file__) ++ pytest.main(["-s", CURRENT_FILE]) +diff --git a/dirsrvtests/tests/suites/usdt/usdt_probes_test.py b/dirsrvtests/tests/suites/usdt/usdt_probes_test.py +new file mode 100644 +index 000000000..1434f1981 +--- /dev/null ++++ b/dirsrvtests/tests/suites/usdt/usdt_probes_test.py +@@ -0,0 +1,278 @@ ++# --- BEGIN COPYRIGHT BLOCK --- ++# Copyright (C) 2026 Red Hat, Inc. ++# All rights reserved. ++# ++# License: GPL (version 3 or any later version). ++# See LICENSE for details. ++# --- END COPYRIGHT BLOCK --- ++# ++import glob ++import logging ++import os ++import shutil ++import subprocess ++ ++import pytest ++ ++from test389.topologies import topology_st as topo ++ ++from ._common import ns_slapd_path, libslapd_path, binary_has_sdt_notes ++ ++DEBUGGING = os.getenv("DEBUGGING", default=False) ++log = logging.getLogger(__name__) ++log.setLevel(logging.DEBUG if DEBUGGING else logging.INFO) ++ ++ ++# Probe sets mirror STAP_PROBE call sites; keep in sync with the C source. ++EXISTING_PROBES = { ++ "do_search__entry", "do_search__return", ++ "op_shared_search__entry", "op_shared_search__prepared", ++ "op_shared_search__backends", "op_shared_search__return", ++ "vslapd_log_audit__entry", "vslapd_log_audit__prepared", ++ "vslapd_log_audit__buffer", ++ "vslapd_log_auditfail__entry", "vslapd_log_auditfail__prepared", ++ "vslapd_log_auditfail__buffer", ++ "vslapd_log_access__entry", "vslapd_log_access__prepared", ++ "vslapd_log_access__buffer", ++ "vslapd_log_security__entry", "vslapd_log_security__prepared", ++ "vslapd_log_security__buffer", ++} ++WORK_QUEUE_PROBES = { ++ "work_q__enqueue", "work_q__dequeue", ++ "worker__busy", "worker__idle", ++ "work__blocked", ++} ++ ++EXPECTED_ARG_COUNTS = { ++ "do_search__entry": 0, "do_search__return": 0, ++ "op_shared_search__entry": 0, "op_shared_search__prepared": 0, ++ "op_shared_search__backends": 0, "op_shared_search__return": 0, ++ "vslapd_log_audit__entry": 0, "vslapd_log_audit__prepared": 0, ++ "vslapd_log_audit__buffer": 0, ++ "vslapd_log_auditfail__entry": 0, "vslapd_log_auditfail__prepared": 0, ++ "vslapd_log_auditfail__buffer": 0, ++ "vslapd_log_access__entry": 0, "vslapd_log_access__prepared": 0, ++ "vslapd_log_access__buffer": 0, ++ "vslapd_log_security__entry": 0, "vslapd_log_security__prepared": 0, ++ "vslapd_log_security__buffer": 0, ++ "work_q__enqueue": 3, ++ "work_q__dequeue": 3, ++ "worker__busy": 4, ++ "worker__idle": 1, ++ "work__blocked": 2, ++} ++ ++PROFILING_DIR = os.path.normpath( ++ os.path.join(os.path.dirname(__file__), ++ "..", "..", "..", "..", "profiling", "stap") ++) ++ ++ ++def _elf_targets(topo): ++ yield ns_slapd_path(topo) ++ lib = libslapd_path(topo) ++ if lib: ++ yield lib ++ ++ ++def _readelf_notes(binary): ++ return subprocess.run( ++ ["readelf", "-n", binary], ++ capture_output=True, text=True, check=True, ++ ).stdout ++ ++ ++def _read_probe_names(binary): ++ names = set() ++ for line in _readelf_notes(binary).splitlines(): ++ line = line.strip() ++ if line.startswith("Name:"): ++ names.add(line.split(":", 1)[1].strip()) ++ return names ++ ++ ++def _read_all_probe_names(topo): ++ found = set() ++ for path in _elf_targets(topo): ++ if path and os.path.exists(path): ++ found |= _read_probe_names(path) ++ return found ++ ++ ++def _read_probe_arg_specs(binary): ++ """{probe_name: [arg_specs]} parsed from readelf -n.""" ++ ++ specs = {} ++ current = None ++ for line in _readelf_notes(binary).splitlines(): ++ line = line.strip() ++ if line.startswith("Name:"): ++ current = line.split(":", 1)[1].strip() ++ elif line.startswith("Arguments:") and current is not None: ++ args = line.split(":", 1)[1].strip() ++ specs[current] = args.split() if args else [] ++ current = None ++ return specs ++ ++ ++def _read_all_probe_arg_specs(topo): ++ out = {} ++ for path in _elf_targets(topo): ++ if path and os.path.exists(path): ++ out.update(_read_probe_arg_specs(path)) ++ return out ++ ++ ++def _usdt_targets_or_skip(topo): ++ targets = [p for p in _elf_targets(topo) ++ if p and os.path.exists(p) and binary_has_sdt_notes(p)] ++ if not targets: ++ pytest.skip("ns-slapd not built with --enable-usdt " ++ "(no stapsdt notes in ns-slapd or libslapd.so)") ++ return targets ++ ++ ++@pytest.mark.tier1 ++@pytest.mark.skipif(not shutil.which("readelf"), ++ reason="readelf (binutils) is required") ++def test_usdt_probes_compiled_in(topo): ++ """All expected probes are present in ns-slapd / libslapd.so. ++ ++ :id: e71ae58c-cbd9-41ab-912c-172f60e027b0 ++ :setup: Standalone instance ++ :steps: ++ 1. Locate ns-slapd and libslapd.so, skip if no stapsdt notes ++ 2. Read every embedded probe name via readelf -n ++ 3. Assert EXISTING_PROBES is a subset ++ 4. Assert WORK_QUEUE_PROBES is a subset ++ :expectedresults: ++ 1. Skip cleanly when not built with --enable-usdt ++ 2. Probe-name set is non-empty ++ 3. No regressions in existing probes ++ 4. All four new probes present ++ """ ++ ++ _usdt_targets_or_skip(topo) ++ found = _read_all_probe_names(topo) ++ log.info("Found %d USDT probes", len(found)) ++ log.debug("Probes: %s", sorted(found)) ++ ++ missing_existing = sorted(EXISTING_PROBES - found) ++ assert not missing_existing, ( ++ f"Existing USDT probes missing (regression!): {missing_existing}" ++ ) ++ missing_new = sorted(WORK_QUEUE_PROBES - found) ++ assert not missing_new, ( ++ f"New work-queue/worker USDT probes missing: {missing_new}" ++ ) ++ ++ ++@pytest.mark.tier1 ++@pytest.mark.skipif(not shutil.which("readelf"), ++ reason="readelf (binutils) is required") ++def test_probe_argument_counts_match(topo): ++ """Compiled-in probe argument counts match their STAP_PROBE call sites. ++ ++ :id: da0c7c41-637c-4805-9c18-2fe4a08af9e9 ++ :setup: Standalone instance ++ :steps: ++ 1. Read probe arg specs from both ELFs via readelf -n ++ 2. Compare each probe's actual arg count against EXPECTED_ARG_COUNTS ++ :expectedresults: ++ 1. Specs parse ++ 2. All counts match ++ """ ++ ++ _usdt_targets_or_skip(topo) ++ specs = _read_all_probe_arg_specs(topo) ++ ++ mismatches = [] ++ for probe, expected in EXPECTED_ARG_COUNTS.items(): ++ if probe not in specs: ++ mismatches.append(f"{probe}: missing from binary entirely") ++ continue ++ actual = len(specs[probe]) ++ if actual != expected: ++ mismatches.append( ++ f"{probe}: expected {expected} args, got {actual} " ++ f"({specs[probe]})" ++ ) ++ assert not mismatches, ( ++ "Probe argument count mismatch:\n " + "\n ".join(mismatches) ++ ) ++ ++ ++@pytest.mark.tier1 ++@pytest.mark.skipif(not shutil.which("readelf"), ++ reason="readelf (binutils) is required") ++def test_no_unexpected_probes(topo): ++ """Every compiled-in probe is declared in EXPECTED_ARG_COUNTS. ++ ++ :id: 891e9048-a32e-4a6e-8b24-6c9b3fa6fef9 ++ :setup: Standalone instance ++ :steps: ++ 1. Read all probe names from both ELFs ++ 2. Diff against EXPECTED_ARG_COUNTS keys ++ :expectedresults: ++ 1. Probe-name set is non-empty ++ 2. Every binary-resident probe is declared in this test ++ """ ++ ++ _usdt_targets_or_skip(topo) ++ found = _read_all_probe_names(topo) ++ unknown = sorted(found - set(EXPECTED_ARG_COUNTS.keys())) ++ assert not unknown, ( ++ f"Probes found in binary but not declared in this test: {unknown}. " ++ f"Add them to EXPECTED_ARG_COUNTS (and EXISTING_PROBES or " ++ f"WORK_QUEUE_PROBES)." ++ ) ++ ++ ++@pytest.mark.tier1 ++@pytest.mark.skipif(not shutil.which("stap"), ++ reason="systemtap (stap) is required") ++def test_stp_scripts_parse(topo): ++ """All shipped .stp scripts parse cleanly via stap -p1. ++ ++ :id: 18ef9213-753f-48bd-83c5-d0c06dd6e95d ++ :setup: Standalone instance ++ :steps: ++ 1. Glob systemtap scripts in the profiling/stap directory ++ 2. Run stap -p1