[PATCH v5 08/11] test: add tests for python bindings

Alan Maguire <[email protected]>
Newsgroups dev.linux.lists.dtrace
Message-ID <[email protected]>
Add tests covering aggregation snapshot, handling of keys,
values.  Also test process creation/grabbing, and ensure
we detect processes going away or exit() calls in dt.work().
Also ensure that multiple sessions can exist in the process
image, and that closing a handle does not effect operation of
another handle in the same process.  Test compile-time and
general setopt() options.

Signed-off-by: Alan Maguire <[email protected]>
---
 test/unittest/python/test.x                   |  17 ++
 test/unittest/python/tst.aggr-actions.sh      | 152 ++++++++++++++++++
 test/unittest/python/tst.aggr-change.sh       |  74 +++++++++
 test/unittest/python/tst.aggr-clear.sh        |  44 +++++
 test/unittest/python/tst.aggr-keys.sh         |  89 ++++++++++
 test/unittest/python/tst.aggr-stack.sh        |  62 +++++++
 test/unittest/python/tst.aggr.sh              |  58 +++++++
 test/unittest/python/tst.compile-opts.sh      |  50 ++++++
 test/unittest/python/tst.exit.sh              |  67 ++++++++
 test/unittest/python/tst.multi-session-end.sh |  89 ++++++++++
 test/unittest/python/tst.multi-session.sh     |  62 +++++++
 test/unittest/python/tst.proc-create.sh       |  71 ++++++++
 test/unittest/python/tst.proc-exit.sh         |  57 +++++++
 test/unittest/python/tst.proc-grab.sh         |  84 ++++++++++
 test/unittest/python/tst.setopt.sh            |  30 ++++
 test/unittest/python/tst.work-error.sh        |  36 +++++
 16 files changed, 1042 insertions(+)
 create mode 100755 test/unittest/python/test.x
 create mode 100755 test/unittest/python/tst.aggr-actions.sh
 create mode 100755 test/unittest/python/tst.aggr-change.sh
 create mode 100755 test/unittest/python/tst.aggr-clear.sh
 create mode 100755 test/unittest/python/tst.aggr-keys.sh
 create mode 100755 test/unittest/python/tst.aggr-stack.sh
 create mode 100755 test/unittest/python/tst.aggr.sh
 create mode 100755 test/unittest/python/tst.compile-opts.sh
 create mode 100755 test/unittest/python/tst.exit.sh
 create mode 100755 test/unittest/python/tst.multi-session-end.sh
 create mode 100755 test/unittest/python/tst.multi-session.sh
 create mode 100755 test/unittest/python/tst.proc-create.sh
 create mode 100755 test/unittest/python/tst.proc-exit.sh
 create mode 100755 test/unittest/python/tst.proc-grab.sh
 create mode 100755 test/unittest/python/tst.setopt.sh
 create mode 100755 test/unittest/python/tst.work-error.sh

diff --git a/test/unittest/python/test.x b/test/unittest/python/test.x
new file mode 100755
index 00000000..36ea27d6
--- /dev/null
+++ b/test/unittest/python/test.x
@@ -0,0 +1,17 @@
+#!/bin/bash
+#
+#
+# Oracle Linux DTrace.
+# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
+# Licensed under the Universal Permissive License v 1.0 as shown at
+# http://oss.oracle.com/licenses/upl.
+#
+# Skip the Python binding tests when the bindings were not built or installed.
+#
+
+if ! "$DTRACE_PYTHON" -c 'import dtrace' >/dev/null 2>&1; then
+	echo "Python bindings are unavailable"
+	exit 2
+fi
+
+exit 0
diff --git a/test/unittest/python/tst.aggr-actions.sh b/test/unittest/python/tst.aggr-actions.sh
new file mode 100755
index 00000000..f09d3b13
--- /dev/null
+++ b/test/unittest/python/tst.aggr-actions.sh
@@ -0,0 +1,152 @@
+#!/bin/bash
+#
+# Oracle Linux DTrace.
+# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
+# Licensed under the Universal Permissive License v 1.0 as shown at
+# http://oss.oracle.com/licenses/upl.
+#
+# Ensure aggregation actions return expected values.
+
+if [ $# -ne 1 ]; then
+    echo "usage: $0 <dtrace>" >&2
+    exit 2
+fi
+
+dtrace=$1
+
+tmpdir=$(mktemp -d)
+cleanup() {
+    rm -rf "$tmpdir"
+}
+trap cleanup EXIT
+
+cat >"$tmpdir"/script.d <<EOF
+#!/usr/bin/env python3
+from pathlib import Path
+from subprocess import Popen
+
+from dtrace import DTraceSession
+
+prog = r"""
+BEGIN
+{
+    @counts = count();
+    @counts = count();
+    @counts = count();
+    @sums = sum(100);
+    @sums = sum(50);
+    @sums = sum(32);
+    @avgs = avg(4);
+    @avgs = avg(5);
+    @mins = min(100);
+    @mins = min(99);
+    @mins = min(101);
+    @maxs = max(12);
+    @maxs = max(19);
+    @maxs = max(-4);
+    @stddevs = stddev(5000000000);
+    @stddevs = stddev(5000000100);
+    @stddevs = stddev(5000000200);
+    @stddevs = stddev(5000000300);
+    @stddevs = stddev(5000000400);
+    @stddevs = stddev(5000000500);
+    @stddevs = stddev(5000000600);
+    @stddevs = stddev(5000000700);
+    @stddevs = stddev(5000000800);
+    @stddevs = stddev(5000000900);
+    @quants = quantize(1);
+    @quants = quantize(2);
+    @quants = quantize(1);
+    @quants = quantize(3);
+    @quants = quantize(128);
+    @lquants = lquantize(1, 1, 10, 2);
+    @lquants = lquantize(3, 1, 10, 2);
+    @lquants = lquantize(1, 1, 10, 2);
+    @lquants = lquantize(9, 1, 10, 2);
+    @lquants = lquantize(9, 1, 10, 2);
+    @lquants = lquantize(1, 1, 10, 2);
+    @llquants = llquantize(1, 3, 1, 4, 3);
+    @llquants = llquantize(26, 3, 1, 4, 3);
+    @llquants = llquantize(2, 3, 1, 4, 3);
+    @llquants = llquantize(13, 3, 1, 4, 3);
+    @llquants = llquantize(6, 3, 1, 4, 3);
+    @llquants = llquantize(81, 3, 1, 4, 3);
+    exit(0);
+}
+"""
+
+with DTraceSession() as dt:
+    p = dt.compile(prog)
+    dt.enable(p)
+    dt.go()
+    pid = Popen(["/bin/sleep", "1"]).wait()
+    dt.stop()
+    dt.agg_snap()
+    rows = dt.agg_walk()
+
+expected = {
+    "counts", "sums", "avgs", "mins", "maxs", "stddevs", "quants",
+    "lquants", "llquants",
+}
+seen = set()
+
+if not rows:
+    raise SystemExit("no rows returned")
+
+for row in rows:
+    if row["name"] == "counts":
+       seen.add(row["name"])
+       v = int(row["value"])
+       if v != 3:
+           raise SystemExit(f"unexpected count value {v}")
+    if row["name"] == "avgs":
+       seen.add(row["name"])
+       v = float(row["value"])
+       if v != 4.5:
+           raise SystemExit(f"unexpected avg value {v}")
+    if row["name"] == "sums":
+       seen.add(row["name"])
+       v = int(row["value"])
+       if v != 182:
+           raise SystemExit(f"unexpected sum value {v}")
+    if row["name"] == "mins":
+       seen.add(row["name"])
+       v = int(row["value"])
+       if v != 99:
+           raise SystemExit(f"unexpected min value {v}")
+    if row["name"] == "maxs":
+       seen.add(row["name"])
+       v = int(row["value"])
+       if v != 19:
+           raise SystemExit(f"unexpected max value {v}")
+    if row["name"] == "stddevs":
+       seen.add(row["name"])
+       v = int(row["value"])
+       if v != 287:
+           raise SystemExit(f"unexecpted stddev value {v}")
+    if row["name"] == "quants":
+       seen.add(row["name"])
+       v = row["value"]
+       if v != {1:2, 2:2, 128:1}:
+           raise SystemExit(f"unexecpted quantize value {v}")
+    if row["name"] == "lquants":
+       seen.add(row["name"])
+       v = row["value"]
+       if v != {1:3, 3:1, 9:2}:
+           raise SystemExit(f"unexecpted lquantize value {v}")
+    if row["name"] == "llquants":
+       seen.add(row["name"])
+       v = row["value"]
+       if v != {0:2, 9:1, 18:1, 27:1, 162:1}:
+           raise SystemExit(f"unexecpted llquantize value {v}")
+
+missing = expected - seen
+if missing:
+    raise SystemExit(f"missing aggregation(s): {sorted(missing)}")
+
+EOF
+
+chmod +x "$tmpdir/script.d"
+PATH="$tmpdir:$PATH"
+
+"$DTRACE_PYTHON" "$tmpdir/script.d"
diff --git a/test/unittest/python/tst.aggr-change.sh b/test/unittest/python/tst.aggr-change.sh
new file mode 100755
index 00000000..7c3f8594
--- /dev/null
+++ b/test/unittest/python/tst.aggr-change.sh
@@ -0,0 +1,74 @@
+#!/bin/bash
+#
+# Oracle Linux DTrace.
+# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
+# Licensed under the Universal Permissive License v 1.0 as shown at
+# http://oss.oracle.com/licenses/upl.
+#
+# Ensure aggregation values are updated across dt.work() calls.
+
+if [ $# -ne 1 ]; then
+    echo "usage: $0 <dtrace>" >&2
+    exit 2
+fi
+
+dtrace=$1
+
+tmpdir=$(mktemp -d)
+cleanup() {
+    rm -rf "$tmpdir"
+}
+trap cleanup EXIT
+
+cat >"$tmpdir"/script.d <<EOF
+#!/usr/bin/env python3
+from pathlib import Path
+from subprocess import Popen
+
+from dtrace import DTraceSession
+
+prog = r"""
+syscall:::entry
+{
+    @counts["syscalls"] = count();
+}
+"""
+
+with DTraceSession() as dt:
+    p = dt.compile(prog)
+    dt.enable(p)
+    dt.go()
+    pid = Popen(["/bin/sleep", "1"]).wait()
+    dt.work()
+    dt.agg_snap()
+    rows = dt.agg_walk()
+    if not rows:
+        raise SystemExit("no rows returned")
+    entry = rows[0]
+    val1 = int(entry["value"])
+    pid = Popen(["/bin/sleep", "1"]).wait()
+    dt.work()
+    dt.agg_snap()
+    rows = dt.agg_walk()
+    if not rows:
+        raise SystemExit("no rows returned")
+    entry = rows[0]
+    val2 = int(entry["value"])
+    if val2 <= val1:
+        raise SystemExit("aggr value unchanged")
+    dt.stop()
+    rows = dt.agg_walk()
+
+if not rows:
+    raise SystemExit("no rows returned")
+
+entry = rows[0]
+if "keys" not in entry or "value" not in entry:
+    raise SystemExit("missing keys or value")
+
+EOF
+
+chmod +x "$tmpdir/script.d"
+PATH="$tmpdir:$PATH"
+
+"$DTRACE_PYTHON" "$tmpdir/script.d"
diff --git a/test/unittest/python/tst.aggr-clear.sh b/test/unittest/python/tst.aggr-clear.sh
new file mode 100755
index 00000000..912bab0c
--- /dev/null
+++ b/test/unittest/python/tst.aggr-clear.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+#
+# Ensure aggregation buffers can be cleared through the Python binding.
+
+if [ $# -ne 1 ]; then
+    echo "usage: $0 <dtrace>" >&2
+    exit 2
+fi
+
+tmpdir=$(mktemp -d)
+cleanup() { rm -rf "$tmpdir"; }
+trap cleanup EXIT
+
+cat >"$tmpdir/script.py" <<'EOF'
+#!/usr/bin/env python3
+from dtrace import DTraceSession
+
+program = r'''
+BEGIN
+{
+    @counts = count();
+}
+'''
+
+with DTraceSession() as session:
+    compiled = session.compile(program)
+    session.enable(compiled)
+    session.go()
+    session.stop()
+    session.agg_snap()
+    if not session.agg_walk():
+        raise SystemExit("aggregation was not populated")
+    session.agg_clear()
+    session.agg_snap()
+    rows = session.agg_walk()
+    if not rows:
+        raise SystemExit("aggregation descriptor disappeared after agg_clear()")
+    for row in rows:
+        if row["name"] == "counts" and int(row["value"]) != 0:
+            raise SystemExit(f"aggregation was not cleared: {row}")
+EOF
+
+chmod +x "$tmpdir/script.py"
+"$DTRACE_PYTHON" "$tmpdir/script.py"
diff --git a/test/unittest/python/tst.aggr-keys.sh b/test/unittest/python/tst.aggr-keys.sh
new file mode 100755
index 00000000..e81b1c2d
--- /dev/null
+++ b/test/unittest/python/tst.aggr-keys.sh
@@ -0,0 +1,89 @@
+#!/bin/bash
+#
+# Verify Python aggregation-key decoding for zero, string, numeric, and
+# mixed key tuples.
+#
+
+if [ $# -ne 1 ]; then
+	echo "usage: $0 <dtrace>" >&2
+	exit 2
+fi
+
+tmpdir=$(mktemp -d)
+trap 'rm -rf "$tmpdir"' EXIT
+
+cat >"$tmpdir/script.py" <<'EOF'
+#!/usr/bin/env python3
+from dtrace import DTraceSession
+
+program = r'''
+BEGIN
+{
+	@zero = count();
+	@string["alpha"] = count();
+	@numeric[17] = count();
+	@mixed["irq", 17] = count();
+}
+
+fbt::schedule:entry
+{
+	@symkey[sym(caller)] = count();
+	@modkey[mod(caller)] = count();
+	@stackkey[stack()] = count();
+}
+
+pid$target::getopt*:entry
+{
+	@usymkey[usym(ucaller)] = count();
+	@umodkey[umod(ucaller)] = count();
+}
+
+profile:::tick-5s
+{
+	exit(0);
+}
+'''
+
+with DTraceSession() as session:
+    from dtrace import DTRACE_C_ZDEFS
+    target = session.proc_create(["/bin/sleep", "2"])
+    program_obj = session.compile(program, cflags=DTRACE_C_ZDEFS)
+    session.enable(program_obj)
+    session.go()
+    session.proc_continue(target)
+    import time
+    time.sleep(2)
+    session.proc_release(target)
+    session.stop()
+    session.agg_snap()
+    rows = session.agg_walk()
+
+def hashable_key(key):
+    return tuple(hashable_key(item) for item in key) if isinstance(key, list) else key
+
+seen = {
+    (row["name"], tuple(hashable_key(key) for key in row["keys"]))
+    for row in rows
+}
+expected = {
+    ("zero", ()),
+    ("string", (("alpha",),)),
+    ("numeric", ((17,),)),
+    ("mixed", (("irq",), (17,))),
+}
+missing = expected - seen
+if missing:
+    raise SystemExit(f"missing aggregation keys: {missing}; rows={rows}")
+print(f"rows={rows}")
+
+for name in ("symkey", "modkey", "usymkey", "umodkey"):
+    matching = [row for row in rows if row["name"] == name]
+    if not matching or not isinstance(matching[0]["keys"][0], list):
+        raise SystemExit(f"symbolic key was not formatted: {name}: {matching}")
+stack_rows = [row for row in rows if row["name"] == "stackkey"]
+if not stack_rows or not isinstance(stack_rows[0]["keys"][0], list):
+    raise SystemExit(f"stack key was not formatted: {stack_rows}")
+EOF
+
+chmod +x "$tmpdir/script.py"
+"$DTRACE_PYTHON" "$tmpdir/script.py"
diff --git a/test/unittest/python/tst.aggr-stack.sh b/test/unittest/python/tst.aggr-stack.sh
new file mode 100755
index 00000000..1b398a04
--- /dev/null
+++ b/test/unittest/python/tst.aggr-stack.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+#
+# Oracle Linux DTrace.
+# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
+# Licensed under the Universal Permissive License v 1.0 as shown at
+# http://oss.oracle.com/licenses/upl.
+#
+# Ensure aggregation can retrieve kernel stack key.
+
+if [ $# -ne 1 ]; then
+    echo "usage: $0 <dtrace>" >&2
+    exit 2
+fi
+
+dtrace=$1
+
+tmpdir=$(mktemp -d)
+cleanup() {
+    rm -rf "$tmpdir"
+}
+trap cleanup EXIT
+
+cat >"$tmpdir"/script.d <<EOF
+#!/usr/bin/env python3
+from pathlib import Path
+from subprocess import Popen
+
+from dtrace import DTraceSession
+
+prog = r"""
+syscall:::entry
+{
+    @counts[stack()] = count();
+}
+"""
+
+with DTraceSession() as dt:
+    p = dt.compile(prog)
+    dt.enable(p)
+    dt.go()
+    pid = Popen(["/bin/sleep", "1"]).wait()
+    dt.stop()
+    dt.agg_snap()
+    rows = dt.agg_walk()
+
+if not rows:
+    raise SystemExit("no rows returned")
+
+entry = rows[0]
+if "keys" not in entry or "value" not in entry:
+    raise SystemExit("missing keys or value")
+
+if not entry["keys"] or not isinstance(entry["keys"][0], list) or \
+   not entry["keys"][0] or len(entry["keys"][0][0]) < 2:
+    raise SystemExit("missing stack list")
+
+EOF
+
+chmod +x "$tmpdir/script.d"
+PATH="$tmpdir:$PATH"
+
+"$DTRACE_PYTHON" "$tmpdir/script.d"
diff --git a/test/unittest/python/tst.aggr.sh b/test/unittest/python/tst.aggr.sh
new file mode 100755
index 00000000..235de73d
--- /dev/null
+++ b/test/unittest/python/tst.aggr.sh
@@ -0,0 +1,58 @@
+#!/bin/bash
+#
+# Oracle Linux DTrace.
+# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
+# Licensed under the Universal Permissive License v 1.0 as shown at
+# http://oss.oracle.com/licenses/upl.
+#
+# Ensure aggregation can be retrieved from simple D script.
+
+if [ $# -ne 1 ]; then
+    echo "usage: $0 <dtrace>" >&2
+    exit 2
+fi
+
+dtrace=$1
+
+tmpdir=$(mktemp -d)
+cleanup() {
+    rm -rf "$tmpdir"
+}
+trap cleanup EXIT
+
+cat >"$tmpdir"/script.d <<EOF
+#!/usr/bin/env python3
+from pathlib import Path
+from subprocess import Popen
+
+from dtrace import DTraceSession
+
+prog = r"""
+syscall:::entry
+{
+    @counts[execname, probefunc] = count();
+}
+"""
+
+with DTraceSession() as dt:
+    p = dt.compile(prog)
+    dt.enable(p)
+    dt.go()
+    pid = Popen(["/bin/sleep", "1"]).wait()
+    dt.stop()
+    dt.agg_snap()
+    rows = dt.agg_walk()
+
+if not rows:
+    raise SystemExit("no rows returned")
+
+entry = rows[0]
+if "keys" not in entry or "value" not in entry:
+    raise SystemExit("missing keys or value")
+
+EOF
+
+chmod +x "$tmpdir/script.d"
+PATH="$tmpdir:$PATH"
+
+"$DTRACE_PYTHON" "$tmpdir/script.d"
diff --git a/test/unittest/python/tst.compile-opts.sh b/test/unittest/python/tst.compile-opts.sh
new file mode 100755
index 00000000..7e0c3e61
--- /dev/null
+++ b/test/unittest/python/tst.compile-opts.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+#
+# Ensure compile flags and preprocessor macro arguments are accepted.
+
+if [ $# -ne 1 ]; then
+    echo "usage: $0 <dtrace>" >&2
+    exit 2
+fi
+
+tmpdir=$(mktemp -d)
+cleanup() { rm -rf "$tmpdir"; }
+trap cleanup EXIT
+
+cat >"$tmpdir/script.py" <<'EOF'
+#!/usr/bin/env python3
+from dtrace import DTRACE_C_ZDEFS, DTraceSession
+
+program = r'''
+missing-provider:::missing-probe
+{
+    @missing = count();
+}
+
+BEGIN
+{
+    @value = sum(TEST_VALUE);
+}
+'''
+
+with DTraceSession() as session:
+    compiled = session.compile(
+        program,
+        cflags=DTRACE_C_ZDEFS,
+        defines=["TEST_VALUE=42"],
+    )
+    session.enable(compiled)
+    session.go()
+    session.stop()
+    session.agg_snap()
+    rows = session.agg_walk()
+
+values = {row["name"]: row["value"] for row in rows}
+if int(values.get("value", -1)) != 42:
+    raise SystemExit(f"macro definition was not applied: {values}")
+if "missing" in values:
+    raise SystemExit("missing probe unexpectedly generated an aggregation")
+EOF
+
+chmod +x "$tmpdir/script.py"
+"$DTRACE_PYTHON" "$tmpdir/script.py"
diff --git a/test/unittest/python/tst.exit.sh b/test/unittest/python/tst.exit.sh
new file mode 100755
index 00000000..0d5dfb42
--- /dev/null
+++ b/test/unittest/python/tst.exit.sh
@@ -0,0 +1,67 @@
+#!/bin/bash
+#
+# Oracle Linux DTrace.
+# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
+# Licensed under the Universal Permissive License v 1.0 as shown at
+# http://oss.oracle.com/licenses/upl.
+#
+# Ensure we see exit() from script with dt.status().
+
+if [ $# -ne 1 ]; then
+    echo "usage: $0 <dtrace>" >&2
+    exit 2
+fi
+
+dtrace=$1
+
+tmpdir=$(mktemp -d)
+cleanup() {
+    rm -rf "$tmpdir"
+}
+trap cleanup EXIT
+
+cat >"$tmpdir"/script.d <<EOF
+#!/usr/bin/env python3
+from pathlib import Path
+from subprocess import Popen
+
+from dtrace import DTraceSession, DTRACE_STATUS_EXITED, DTRACE_STATUS_STOPPED
+
+prog = r"""
+syscall:::entry
+{
+    c++;
+}
+
+syscall:::entry
+/ c > 1024 /
+{
+    exit(0);
+}
+"""
+
+with DTraceSession() as dt:
+    p = dt.compile(prog)
+    dt.enable(p)
+    dt.go()
+    exited = False
+    iters = 0
+    while iters < 10:
+        pid = Popen(["/bin/sleep", "1"]).wait()
+        if dt.status() == DTRACE_STATUS_EXITED:
+            exited = True
+            break
+        iters += 1
+    if not exited:
+        raise SystemExit("missing DTRACE_STATUS_EXIT")
+    work_status, _ = dt.work()
+    if work_status != DTRACE_STATUS_STOPPED:
+        raise SystemExit(f"exit() was not drained by work(): got {work_status}")
+    dt.stop()
+
+EOF
+
+chmod +x "$tmpdir/script.d"
+PATH="$tmpdir:$PATH"
+
+"$DTRACE_PYTHON" "$tmpdir/script.d"
diff --git a/test/unittest/python/tst.multi-session-end.sh b/test/unittest/python/tst.multi-session-end.sh
new file mode 100755
index 00000000..cf3430c2
--- /dev/null
+++ b/test/unittest/python/tst.multi-session-end.sh
@@ -0,0 +1,89 @@
+#!/bin/bash
+#
+# Oracle Linux DTrace.
+# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
+# Licensed under the Universal Permissive License v 1.0 as shown at
+# http://oss.oracle.com/licenses/upl.
+#
+# Ensure END probes remain attached when another DTrace session in the same
+# process closes.
+
+if [ $# -ne 1 ]; then
+    echo "usage: $0 <dtrace>" >&2
+    exit 2
+fi
+
+tmpdir=$(mktemp -d)
+cleanup() {
+    rm -rf "$tmpdir"
+}
+trap cleanup EXIT
+
+cat >"$tmpdir/script.d" <<'EOF'
+#!/usr/bin/env python3
+from dtrace import DTraceSession
+
+
+program = r'''
+END
+{
+    @ended = count();
+}
+'''
+
+
+def start_session():
+    session = DTraceSession()
+    program_handle = session.compile(program)
+    session.enable(program_handle)
+    session.go()
+    return session
+
+
+def assert_end_fired(session, survivor):
+    session.stop()
+    session.agg_snap()
+    rows = session.agg_walk()
+
+    for row in rows:
+        if row["name"] == "ended" and int(row["value"]) == 1:
+            return
+
+    raise SystemExit(f"END probe did not fire for surviving handle {survivor}")
+
+
+def close_session(session):
+    if session is not None:
+        try:
+            session.close()
+        except Exception:
+            pass
+
+
+# Closing A must not remove B's shared END uprobe.
+handle_a = handle_b = None
+try:
+    handle_a = start_session()
+    handle_b = start_session()
+    handle_a.close()
+    handle_a = None
+    assert_end_fired(handle_b, "B")
+finally:
+    close_session(handle_a)
+    close_session(handle_b)
+
+# Closing B must not remove A's shared END uprobe.
+handle_a = handle_b = None
+try:
+    handle_a = start_session()
+    handle_b = start_session()
+    handle_b.close()
+    handle_b = None
+    assert_end_fired(handle_a, "A")
+finally:
+    close_session(handle_a)
+    close_session(handle_b)
+EOF
+
+chmod +x "$tmpdir/script.d"
+"$DTRACE_PYTHON" "$tmpdir/script.d"
diff --git a/test/unittest/python/tst.multi-session.sh b/test/unittest/python/tst.multi-session.sh
new file mode 100755
index 00000000..3709ca1a
--- /dev/null
+++ b/test/unittest/python/tst.multi-session.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+#
+# Oracle Linux DTrace.
+# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
+# Licensed under the Universal Permissive License v 1.0 as shown at
+# http://oss.oracle.com/licenses/upl.
+#
+# Ensure multiple sessions within same process image work.
+
+if [ $# -ne 1 ]; then
+    echo "usage: $0 <dtrace>" >&2
+    exit 2
+fi
+
+dtrace=$1
+
+tmpdir=$(mktemp -d)
+cleanup() {
+    rm -rf "$tmpdir"
+}
+trap cleanup EXIT
+
+cat >"$tmpdir"/script.d <<EOF
+#!/usr/bin/env python3
+from pathlib import Path
+from subprocess import Popen
+
+from dtrace import DTraceSession
+
+prog = r"""
+syscall::read:entry
+{
+    @counts["syscalls"] = count();
+}
+"""
+
+sessions = []
+max_sessions = 10
+
+try:
+    for i in range(max_sessions):
+        session = DTraceSession()
+        sessions.append(session)
+        p = session.compile(prog)
+        session.enable(p)
+        session.go()
+        session.work()
+except Exception as exc:
+    raise SystemExit(f"got err with {i} multiple sessions: {exc}")
+finally:
+    for session in sessions:
+        try:
+            session.close()
+        except Exception:
+            pass
+
+EOF
+
+chmod +x "$tmpdir/script.d"
+PATH="$tmpdir:$PATH"
+
+"$DTRACE_PYTHON" "$tmpdir/script.d"
diff --git a/test/unittest/python/tst.proc-create.sh b/test/unittest/python/tst.proc-create.sh
new file mode 100755
index 00000000..95e9db85
--- /dev/null
+++ b/test/unittest/python/tst.proc-create.sh
@@ -0,0 +1,71 @@
+#!/bin/bash
+#
+# Oracle Linux DTrace.
+# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
+# Licensed under the Universal Permissive License v 1.0 as shown at
+# http://oss.oracle.com/licenses/upl.
+#
+# Validate tracing of a spawned command.
+
+if [ $# -ne 1 ]; then
+    echo "usage: $0 <dtrace>" >&2
+    exit 2
+fi
+
+dtrace=$1
+
+tmpdir=$(mktemp -d)
+cleanup() {
+    rm -rf "$tmpdir"
+}
+trap cleanup EXIT
+
+cat >"$tmpdir"/script.py <<'EOF'
+#!/usr/bin/env python3
+import time
+
+from dtrace import DTRACE_STATUS_STOPPED, DTraceSession
+
+prog = r"""
+syscall::read:entry
+{
+    @calls[execname] = count();
+}
+"""
+
+with DTraceSession() as dt:
+    p = dt.compile(prog)
+    dt.enable(p)
+    dt.go()
+    proc = dt.proc_create(["/bin/echo", "hello"])
+    dt.proc_continue(proc)
+    deadline = time.monotonic() + 5
+    try:
+        while time.monotonic() < deadline:
+            status, _ = dt.work()
+            if status == DTRACE_STATUS_STOPPED:
+                break
+            time.sleep(0.05)
+        else:
+            raise SystemExit("created process did not complete")
+    finally:
+        dt.proc_release(proc)
+    dt.agg_snap()
+    rows = dt.agg_walk()
+
+if not rows:
+    raise SystemExit("no aggregation rows")
+
+def flatten(key):
+    if isinstance(key, list):
+        return [item for child in key for item in flatten(child)]
+    return [key]
+
+names = [flatten(entry["keys"]) for entry in rows]
+if not any("echo" in key for key in names):
+    raise SystemExit("expected echo entry in aggregation")
+EOF
+
+chmod +x "$tmpdir/script.py"
+
+"$DTRACE_PYTHON" "$tmpdir/script.py"
diff --git a/test/unittest/python/tst.proc-exit.sh b/test/unittest/python/tst.proc-exit.sh
new file mode 100755
index 00000000..2c7d02da
--- /dev/null
+++ b/test/unittest/python/tst.proc-exit.sh
@@ -0,0 +1,57 @@
+#!/bin/bash
+#
+# Oracle Linux DTrace.
+# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
+# Licensed under the Universal Permissive License v 1.0 as shown at
+# http://oss.oracle.com/licenses/upl.
+#
+# Ensure we see stopped state when traced process goes away.
+
+if [ $# -ne 1 ]; then
+    echo "usage: $0 <dtrace>" >&2
+    exit 2
+fi
+
+dtrace=$1
+
+tmpdir=$(mktemp -d)
+cleanup() {
+    rm -rf "$tmpdir"
+}
+trap cleanup EXIT
+
+cat >"$tmpdir"/script.d <<EOF
+#!/usr/bin/env python3
+from pathlib import Path
+from subprocess import Popen
+
+from dtrace import DTraceSession, DTRACE_STATUS_STOPPED
+
+prog = r"""
+syscall:::entry
+{
+    c++;
+}
+"""
+
+with DTraceSession() as dt:
+    p = dt.compile(prog)
+    dt.enable(p)
+    dt.go()
+    proc = dt.proc_create(["/bin/sleep", "5"])
+    dt.proc_continue(proc)
+    Popen(["/bin/sleep", "10"]).wait()
+    dt.work()
+    status = dt.status()
+    dt.proc_release(proc)
+    dt.stop()
+
+if status != DTRACE_STATUS_STOPPED:
+    raise SystemExit(f"missing DTRACE_STATUS_STOPPED: got {status}")
+
+EOF
+
+chmod +x "$tmpdir/script.d"
+PATH="$tmpdir:$PATH"
+
+"$DTRACE_PYTHON" "$tmpdir/script.d"
diff --git a/test/unittest/python/tst.proc-grab.sh b/test/unittest/python/tst.proc-grab.sh
new file mode 100755
index 00000000..5655303b
--- /dev/null
+++ b/test/unittest/python/tst.proc-grab.sh
@@ -0,0 +1,84 @@
+#!/bin/bash
+#
+# Oracle Linux DTrace.
+# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
+# Licensed under the Universal Permissive License v 1.0 as shown at
+# http://oss.oracle.com/licenses/upl.
+#
+# Ensure Python bindings can grab an existing process and trace its syscalls.
+
+if [ $# -ne 1 ]; then
+    echo "usage: $0 <dtrace>" >&2
+    exit 2
+fi
+
+dtrace=$1
+helper=
+
+tmpdir=$(mktemp -d)
+cleanup() {
+    if [ -n "$helper" ]; then
+        kill "$helper" 2>/dev/null || true
+        wait "$helper" 2>/dev/null || true
+    fi
+    rm -rf "$tmpdir"
+}
+trap cleanup EXIT
+
+# Start a long-lived helper shell to grab.
+sleep_inf="$tmpdir/sleep.sh"
+cat >"$sleep_inf" <<'EOF'
+#!/bin/bash
+while :; do
+    sleep 1
+done
+EOF
+chmod +x "$sleep_inf"
+"$sleep_inf" &
+helper=$!
+
+cat >"$tmpdir"/script.py <<'EOF'
+#!/usr/bin/env python3
+import os
+import sys
+from subprocess import Popen
+from dtrace import DTraceSession
+
+prog = r"""
+syscall:::entry
+/ pid == $target /
+{
+    @counts[execname, probefunc] = count();
+}
+"""
+
+pid = int(os.environ["TARGET_PID"])
+
+with DTraceSession() as dt:
+    proc = dt.proc_grab_pid(pid)
+    if proc.getpid() != pid:
+        raise SystemExit("grabbed PID mismatch")
+    p = dt.compile(prog)
+    dt.enable(p)
+    dt.go()
+    dt.proc_continue(proc)
+    Popen(["/bin/sleep", "5"]).wait()
+    dt.stop()
+    dt.proc_release(proc)
+    dt.agg_snap()
+    rows = dt.agg_walk()
+
+if not rows:
+    raise SystemExit("no rows returned")
+
+entry = rows[0]
+if "keys" not in entry or "value" not in entry:
+    raise SystemExit("missing keys or value")
+EOF
+
+chmod +x "$tmpdir/script.py"
+export TARGET_PID=$helper
+"$DTRACE_PYTHON" "$tmpdir/script.py"
+ret=$?
+
+exit $ret
diff --git a/test/unittest/python/tst.setopt.sh b/test/unittest/python/tst.setopt.sh
new file mode 100755
index 00000000..1efc8c06
--- /dev/null
+++ b/test/unittest/python/tst.setopt.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+#
+# Ensure valid and invalid options are handled through the Python binding.
+
+if [ $# -ne 1 ]; then
+    echo "usage: $0 <dtrace>" >&2
+    exit 2
+fi
+
+tmpdir=$(mktemp -d)
+cleanup() { rm -rf "$tmpdir"; }
+trap cleanup EXIT
+
+cat >"$tmpdir/script.py" <<'EOF'
+#!/usr/bin/env python3
+from dtrace import DTraceError, DTraceSession
+
+with DTraceSession() as session:
+    session.setopt("bufsize", "4m")
+    session.setopt("quiet", None)
+    try:
+        session.setopt("not-a-dtrace-option", "1")
+    except DTraceError:
+        pass
+    else:
+        raise SystemExit("invalid option was accepted")
+EOF
+
+chmod +x "$tmpdir/script.py"
+"$DTRACE_PYTHON" "$tmpdir/script.py"
diff --git a/test/unittest/python/tst.work-error.sh b/test/unittest/python/tst.work-error.sh
new file mode 100755
index 00000000..abf6e589
--- /dev/null
+++ b/test/unittest/python/tst.work-error.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+#
+# Oracle Linux DTrace.
+# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
+# Licensed under the Universal Permissive License v 1.0 as shown at
+# http://oss.oracle.com/licenses/upl.
+#
+# Ensure libdtrace errors from work() are reported as Python exceptions.
+
+if [ $# -ne 1 ]; then
+    echo "usage: $0 <dtrace>" >&2
+    exit 2
+fi
+
+tmpdir=$(mktemp -d)
+cleanup() {
+    rm -rf "$tmpdir"
+}
+trap cleanup EXIT
+
+cat >"$tmpdir/script.py" <<'EOF'
+#!/usr/bin/env python3
+from dtrace import DTraceError, DTraceSession
+
+
+with DTraceSession() as session:
+    try:
+        session.work()
+    except DTraceError:
+        pass
+    else:
+        raise SystemExit("work() did not report its libdtrace error")
+EOF
+
+chmod +x "$tmpdir/script.py"
+"$DTRACE_PYTHON" "$tmpdir/script.py"
-- 
2.43.5
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.