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
next prev parent reply other threads:[~2026-07-24 21:23 UTC|newest]
Thread overview: 12+ 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]
2026-07-27 22:42 ` [PATCH v2 0/4] Wireshark external capture interface Stephen Hemminger
2026-07-27 22:42 ` [PATCH v2 1/4] pcapng: extend interface statistics Stephen Hemminger
2026-07-27 22:42 ` [PATCH v2 2/4] capture: infrastructure wireshark packet capture Stephen Hemminger
2026-07-27 22:42 ` [PATCH v2 3/4] test: add test for capture hooks Stephen Hemminger
2026-07-27 22:42 ` [PATCH v2 4/4] usertools: add script for wireshark capture Stephen Hemminger
2026-07-29 23:28 ` Stephen Hemminger
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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.