From: Stephen Hemminger <stephen@networkplumber.org>
To: dev@dpdk.org
Cc: Stephen Hemminger <stephen@networkplumber.org>
Subject: [PATCH 5/5] usertools/dpdk-dumpcap: add script for file capture
Date: Fri, 24 Jul 2026 14:11:57 -0700 [thread overview]
Message-ID: <20260724212238.864798-6-stephen@networkplumber.org> (raw)
In-Reply-To: <20260724212238.864798-1-stephen@networkplumber.org>
This is a python version using capture library that provides
the same syntax and functionality as wireshark dumpcap and
the existing secondary process version dpdk-dumpcap.
Since it is a python script and uses capture it does not
need secondary process support and should be portable
to other OS environments.
It is designed to be a replacement for dpdk-dumpcap
program which uses pdump.
Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
MAINTAINERS | 1 +
doc/guides/rel_notes/release_26_11.rst | 1 +
usertools/dpdk-dumpcap.py | 426 +++++++++++++++++++++++++
3 files changed, 428 insertions(+)
create mode 100755 usertools/dpdk-dumpcap.py
diff --git a/MAINTAINERS b/MAINTAINERS
index 5ce969d02f..e682823df9 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -1722,6 +1722,7 @@ M: Reshma Pattan <reshma.pattan@intel.com>
M: Stephen Hemminger <stephen@networkplumber.org>
F: lib/capture/
F: usertools/dpdk-wireshark-extcap.py
+F: usertools/dpdk-dumpcap.py
F: doc/guides/tools/wireshark_extcap.rst
F: lib/pdump/
F: doc/guides/prog_guide/pdump_lib.rst
diff --git a/doc/guides/rel_notes/release_26_11.rst b/doc/guides/rel_notes/release_26_11.rst
index 9835807fcc..6590899585 100644
--- a/doc/guides/rel_notes/release_26_11.rst
+++ b/doc/guides/rel_notes/release_26_11.rst
@@ -59,6 +59,7 @@ New Features
* Added ``capture`` library for packet capture via telemetry API.
* Added python script for wireshark external capture integration.
+ * Added python version of dpdk-dumpcap.
Removed Items
diff --git a/usertools/dpdk-dumpcap.py b/usertools/dpdk-dumpcap.py
new file mode 100755
index 0000000000..bdec55964a
--- /dev/null
+++ b/usertools/dpdk-dumpcap.py
@@ -0,0 +1,426 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2026 Stephen Hemminger
+
+"""
+dpdk-dumpcap: capture packets from a running DPDK primary process.
+
+A tcpdump/dumpcap-style front end for the DPDK 'capture' library. Unlike the
+legacy secondary-process dpdk-dumpcap (which used pdump and required the primary
+to initialise the capture framework), this drives the primary directly over its
+telemetry socket: the primary opens the output file itself and writes pcapng
+into it. It works with any primary that links lib/capture; no application
+changes are needed.
+
+Examples:
+ dpdk-dumpcap --list-interfaces
+ dpdk-dumpcap -i 0 -w /tmp/port0.pcapng
+ dpdk-dumpcap -i 0 -c 100 -f 'tcp port 80'
+"""
+
+import argparse
+import json
+import os
+import signal
+import socket
+import sys
+import tempfile
+import time
+
+TELEMETRY_SOCKET = "dpdk_telemetry.v2"
+CAPTURE_START_CMD = "/ethdev/capture/start"
+CAPTURE_STOP_CMD = "/ethdev/capture/stop"
+CAPTURE_STATS_CMD = "/ethdev/capture/stats"
+ETHDEV_LIST = "/ethdev/list"
+ETHDEV_INFO = "/ethdev/info"
+DEFAULT_SNAPLEN = 262144
+COUNT_POLL_INTERVAL = 0.1 # fast stats poll cadence when -c is set
+POLL_TICK = 0.5 # loop wake granularity; bounds Ctrl-C latency when idle
+DEFAULT_STATS_INTERVAL = 0.5 # seconds between liveness updates
+
+
+# --- DPDK runtime directory / socket discovery ---------------------------
+
+
+def dpdk_dir():
+ override = os.environ.get("DPDK_EXTCAP_PATH")
+ if override:
+ return override
+ base = (
+ "/var/run" if os.geteuid() == 0 else os.environ.get("XDG_RUNTIME_DIR", "/tmp")
+ )
+ return os.path.join(base, "dpdk")
+
+
+def socket_path(prefix):
+ return os.path.join(dpdk_dir(), prefix, TELEMETRY_SOCKET)
+
+
+def list_prefixes():
+ """Yield (prefix, path) for each DPDK telemetry socket found."""
+ root = dpdk_dir()
+ try:
+ entries = os.listdir(root)
+ except FileNotFoundError:
+ return
+ except PermissionError:
+ sys.stderr.write(
+ f"cannot read {root}: permission denied. The DPDK runtime directory "
+ "is mode 0700, so capture must run as the same user as the DPDK "
+ "application, or set DPDK_EXTCAP_PATH.\n"
+ )
+ return
+ for prefix in sorted(entries):
+ path = os.path.join(root, prefix, TELEMETRY_SOCKET)
+ if os.path.exists(path):
+ yield prefix, path
+
+
+def resolve_prefix(requested):
+ """Pick the file-prefix to talk to. If one was requested, use it; else use
+ the sole running prefix, or error if there are zero or several."""
+ prefixes = [p for p, _ in list_prefixes()]
+ if requested is not None:
+ if requested not in prefixes:
+ raise SystemExit(f"no DPDK process with file-prefix '{requested}'")
+ return requested
+ if not prefixes:
+ raise SystemExit("no running DPDK process found")
+ if len(prefixes) > 1:
+ raise SystemExit(
+ "multiple DPDK processes running; select one with --file-prefix: "
+ + ", ".join(prefixes)
+ )
+ return prefixes[0]
+
+
+# --- Telemetry transport -------------------------------------------------
+
+
+class Telemetry:
+ """Minimal client for the DPDK v2 telemetry socket (SOCK_SEQPACKET)."""
+
+ def __init__(self, path):
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
+ self.sock.connect(path)
+ info = json.loads(self.sock.recv(1024).decode())
+ self.max_output_len = info.get("max_output_len", 16384)
+
+ def command(self, cmd):
+ self.sock.send(cmd.encode())
+ reply = self.sock.recv(self.max_output_len)
+ if not reply:
+ return None
+ return json.loads(reply.decode())
+
+ def close(self):
+ self.sock.close()
+
+
+# --- operations ----------------------------------------------------------
+
+
+def port_name(tel, port):
+ """Device name for a port via /ethdev/info, or 'port<N>' if unreadable.
+ For a physical port this is the PCI address; for a vdev, the vdev name."""
+ try:
+ reply = tel.command(f"{ETHDEV_INFO},{port}")
+ except OSError:
+ reply = None
+ info = (reply or {}).get(ETHDEV_INFO) or {}
+ return info.get("name") or f"port{port}"
+
+
+def list_interfaces():
+ """List ports as '<port>. <name>', matching Wireshark/C dpdk-dumpcap -D.
+ Port ids are per-primary; if several primaries run, capture disambiguates
+ with --file-prefix (see resolve_prefix)."""
+ any_found = False
+ for _prefix, path in list_prefixes():
+ try:
+ tel = Telemetry(path)
+ except OSError as e:
+ sys.stderr.write(f"cannot query {path}: {e}\n")
+ continue
+ try:
+ ports = (tel.command(ETHDEV_LIST) or {}).get(ETHDEV_LIST) or []
+ for port in ports:
+ any_found = True
+ print(f"{port}. {port_name(tel, port)}")
+ except OSError as e:
+ sys.stderr.write(f"cannot query {path}: {e}\n")
+ finally:
+ tel.close()
+ if not any_found:
+ sys.stderr.write("no DPDK interfaces available for capture\n")
+
+
+def resolve_interface(tel, spec):
+ """Port id to capture on the connected primary. spec None selects the first
+ interface (like Wireshark and the C dpdk-dumpcap); otherwise it is a port id
+ or a device name as shown by --list-interfaces."""
+ ports = (tel.command(ETHDEV_LIST) or {}).get(ETHDEV_LIST) or []
+ if not ports:
+ raise SystemExit("no interfaces on this DPDK process")
+ if spec is None:
+ return ports[0]
+ try:
+ port = int(spec)
+ except ValueError:
+ port = None
+ if port is not None:
+ if port in ports:
+ return port
+ raise SystemExit(f"no port {port} on this DPDK process")
+ for port in ports:
+ if port_name(tel, port) == spec:
+ return port
+ raise SystemExit(f"no interface '{spec}' on this DPDK process")
+
+
+def default_output(name):
+ r"""Auto-named output in the temp dir, matching how current Wireshark
+ dumpcap names its default file: '<tool>_<iface><unique>.pcapng', with a
+ random unique suffix and no timestamp.
+ basename() strips any path-like parts of the device name, as Wireshark does;
+ mkstemp pre-creates the file empty, which is what the capture library wants."""
+ basename = os.path.basename(name)
+ fd, path = tempfile.mkstemp(prefix=f"dpdk-dumpcap_{basename}", suffix=".pcapng")
+ os.close(fd)
+ return path
+
+
+def read_stats(tel, cap_id):
+ """Return the stats dict, or None if the telemetry peer has closed (the
+ primary exited). An empty reply from a SEQPACKET socket is EOF."""
+ reply = tel.command(f"{CAPTURE_STATS_CMD},{cap_id}")
+ if reply is None:
+ return None
+ return reply.get(CAPTURE_STATS_CMD) or {}
+
+
+def print_progress(stats):
+ """Live packet counter, overwritten in place (carriage return, no newline)
+ like Wireshark: the running line reads 'Packets:', and print_summary
+ finalises it as 'Packets captured:' with a trailing newline."""
+ captured = stats.get("accepted", 0) - stats.get("ringfull", 0)
+ sys.stderr.write(f"\rPackets: {captured} ")
+ sys.stderr.flush()
+
+
+def print_summary(stats, name):
+ r"""dumpcap-style end-of-capture summary on stderr. The leading '\r'
+ finalises the in-place live counter (same line, overwritten). Counters map
+ onto the pcapng ISB the primary writes, so this agrees with the file trailer:
+ received (ifrecv) = accepted + filtered + nombuf -- true total seen
+ dropped (ifdrop) = nombuf + ringfull
+ captured = accepted - ringfull -- written to file
+ The percentage is 'fraction not dropped', off the true total (ifrecv already
+ includes the drops). An empty capture prints 0.0%, as dumpcap does."""
+ accepted = stats.get("accepted", 0)
+ filtered = stats.get("filtered", 0)
+ nombuf = stats.get("nombuf", 0)
+ ringfull = stats.get("ringfull", 0)
+
+ captured = accepted - ringfull
+ received = accepted + filtered + nombuf
+ dropped = nombuf + ringfull
+ pct = 100.0 * (received - dropped) / received if received else 0.0
+
+ print(f"\rPackets captured: {captured}", file=sys.stderr)
+ print(
+ f"Packets received/dropped on interface '{name}': "
+ f"{received}/{dropped} ({pct:.1f}%)",
+ file=sys.stderr,
+ )
+
+
+def capture(args):
+ prefix = resolve_prefix(args.file_prefix)
+ path = socket_path(prefix)
+
+ try:
+ tel = Telemetry(path)
+ except OSError as e:
+ raise SystemExit(f"cannot connect to DPDK telemetry at {path}: {e}")
+
+ port = resolve_interface(tel, args.interface)
+ name = port_name(tel, port)
+
+ output = args.write or default_output(name)
+
+ # The capture library opens the output O_WRONLY|O_NOFOLLOW without O_CREAT
+ # and rejects a non-empty file, so create/truncate it to an empty regular
+ # file here before handing over the path.
+ try:
+ open(output, "wb").close()
+ except OSError as e:
+ tel.close()
+ raise SystemExit(f"cannot create output {output}: {e}")
+
+ params = [str(port), f"out={output}"]
+ if args.snaplen is not None:
+ params.append(f"snaplen={args.snaplen}")
+ if args.queue is not None and args.queue >= 0:
+ params.append(f"queue={args.queue}")
+ if args.filter:
+ params.append(f"filter={args.filter}")
+ cmd = CAPTURE_START_CMD + "," + ",".join(params)
+
+ result = (tel.command(cmd) or {}).get(CAPTURE_START_CMD) or {}
+ if "error" in result:
+ tel.close()
+ raise SystemExit(f"capture start failed: {result['error']}")
+ cap_id = result.get("id")
+ if cap_id is None:
+ tel.close()
+ raise SystemExit("capture start did not return an id")
+
+ qnote = "" if args.queue is None or args.queue < 0 else f" queue {args.queue}"
+ print(f"File: {output}", file=sys.stderr)
+ print(f"Capturing on '{name}'{qnote}", file=sys.stderr)
+
+ # Stop on Ctrl-C/SIGTERM; the handler flips a flag the loop checks.
+ stop = {"now": False}
+ for sig in (signal.SIGINT, signal.SIGTERM):
+ signal.signal(sig, lambda *_: stop.update(now=True))
+
+ interval = args.stats_interval
+ # Wake on a short tick so a signal stops us promptly; poll fast when -c must
+ # bound overshoot, otherwise just often enough to notice the signal. Stats
+ # are read only when -c is set or a heartbeat is due, so an idle capture
+ # barely touches the telemetry socket.
+ tick = COUNT_POLL_INTERVAL if args.count is not None else POLL_TICK
+ next_beat = time.monotonic() + interval if interval > 0 else None
+
+ stats = {}
+ try:
+ while not stop["now"]:
+ time.sleep(tick)
+ now = time.monotonic()
+ due = next_beat is not None and now >= next_beat
+ if args.count is not None or due:
+ # The stats round-trip is itself the liveness check: if the
+ # primary has exited, the telemetry socket errors or returns
+ # EOF, and the capture (and its half-written pcapng) is gone.
+ try:
+ latest = read_stats(tel, cap_id)
+ except OSError:
+ latest = None
+ if latest is None:
+ print(
+ "\nDPDK telemetry connection lost; capture stopped",
+ file=sys.stderr,
+ )
+ break
+ if "error" in latest:
+ break # capture instance no longer present
+ if latest:
+ stats = latest
+ if args.count is not None and stats.get("accepted", 0) >= args.count:
+ break
+ if due:
+ print_progress(stats)
+ next_beat = now + interval
+ # snapshot final counters while the instance is still alive, then stop:
+ # after stop the primary tears the instance down and stats disappear.
+ try:
+ latest = read_stats(tel, cap_id)
+ if latest and "error" not in latest:
+ stats = latest
+ except OSError:
+ pass
+ finally:
+ try:
+ tel.command(f"{CAPTURE_STOP_CMD},{cap_id}")
+ except OSError:
+ pass
+
+ print_summary(stats, name)
+ tel.close()
+
+
+# --- entry point ---------------------------------------------------------
+
+
+def main():
+ p = argparse.ArgumentParser(
+ prog="dpdk-dumpcap",
+ description="Capture packets from a running DPDK primary process.",
+ )
+ p.add_argument(
+ "-D",
+ "--list-interfaces",
+ action="store_true",
+ help="list available DPDK interfaces and exit",
+ )
+ p.add_argument(
+ "-i",
+ "--interface",
+ metavar="IFACE",
+ help="port id or device name to capture from "
+ "(default: first interface; see --list-interfaces)",
+ )
+ p.add_argument(
+ "--file-prefix",
+ help="EAL file-prefix of the target primary "
+ "(required only if several are running)",
+ )
+ p.add_argument(
+ "-w", "--write", metavar="FILE", help="output pcapng file (default: auto-named)"
+ )
+ p.add_argument(
+ "-s",
+ "--snapshot-length",
+ dest="snaplen",
+ type=int,
+ metavar="LEN",
+ help="bytes to capture per packet (0 = all)",
+ )
+ p.add_argument(
+ "--queue",
+ type=int,
+ metavar="ID",
+ help="capture a single hardware queue (default: all queues)",
+ )
+ p.add_argument(
+ "-c",
+ "--packet-count",
+ dest="count",
+ type=int,
+ metavar="N",
+ help="stop after roughly N packets",
+ )
+ p.add_argument(
+ "--stats-interval",
+ type=float,
+ metavar="SECS",
+ default=DEFAULT_STATS_INTERVAL,
+ help="seconds between liveness updates "
+ f"(0 to disable; default {DEFAULT_STATS_INTERVAL})",
+ )
+ p.add_argument(
+ "-f",
+ "--filter",
+ dest="filter",
+ metavar="EXPR",
+ help="capture filter in libpcap syntax",
+ )
+ p.add_argument(
+ "filter_args", nargs="*", help="capture filter (if not given with -f)"
+ )
+ args = p.parse_args()
+
+ if args.list_interfaces:
+ list_interfaces()
+ return
+
+ # tcpdump-style trailing filter expression, if -f was not used.
+ if not args.filter and args.filter_args:
+ args.filter = " ".join(args.filter_args)
+
+ capture(args)
+
+
+if __name__ == "__main__":
+ main()
--
2.53.0
prev parent reply other threads:[~2026-07-24 21:23 UTC|newest]
Thread overview: 6+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-24 21:11 [PATCH v6 0/5] Wireshark external capture for DPDK Stephen Hemminger
2026-07-24 21:11 ` [PATCH 1/5] pcapng: extend interface statistics Stephen Hemminger
2026-07-24 21:11 ` [PATCH 2/5] capture: infrastructure wireshark packet capture Stephen Hemminger
2026-07-24 21:11 ` [PATCH 3/5] test: add test for capture hooks Stephen Hemminger
2026-07-24 21:11 ` [PATCH 4/5] usertools/dpdk-wireshark-extcap.py: script for external capture Stephen Hemminger
2026-07-24 21:11 ` Stephen Hemminger [this message]
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260724212238.864798-6-stephen@networkplumber.org \
--to=stephen@networkplumber.org \
--cc=dev@dpdk.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox