From 6cd745ab4b3fc1a914bfc0eb4e48f6d61e4a22c7 Mon Sep 17 00:00:00 2001 From: Eugen Goebel Date: Fri, 17 Jul 2026 22:22:44 +0200 Subject: [PATCH 1/2] sniff: reap the tcpdump prefilter subprocess in offline mode sniff(offline=..., filter=...) prefilters packets through a tcpdump subprocess, but the code kept only its stdout pipe and discarded the Popen. The process was never waited on, so it lingered as a zombie and Python emitted "ResourceWarning: subprocess N is still running". Get the process with getproc=True, attach it to the PcapReader, and reap it in close(): the read pipe is closed first, a still-running tcpdump then gets a SIGTERM, and wait() collects it. This also handles early stops (count=, timeout=), where tcpdump has not finished on its own. Fixes #4512 AI-Assisted: yes (Claude Opus 4.8) --- scapy/sendrecv.py | 41 +++++++++++++++++++++++++---------------- scapy/utils.py | 11 +++++++++++ test/regression.uts | 31 +++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/scapy/sendrecv.py b/scapy/sendrecv.py index ae1d05b158e..3f98e727c60 100644 --- a/scapy/sendrecv.py +++ b/scapy/sendrecv.py @@ -1053,6 +1053,23 @@ def srp1flood(x, # type: _PacketIterable # SNIFF METHODS +def _offline_pcap_reader(fname, flt, quiet): + # type: (str, Optional[str], bool) -> PcapReader + """Build a PcapReader for one sniff() offline source. + + Without a filter the file is read directly. With a filter the packets are + prefiltered through a tcpdump subprocess; that process is attached to the + reader so its close() reaps it. Reading it through a bare file descriptor + used to leak the process as a zombie (#4512). + """ + if flt is None: + return PcapReader(fname) + proc = tcpdump(fname, args=["-w", "-"], flt=flt, getproc=True, quiet=quiet) + reader = PcapReader(proc.stdout) + reader.subproc = proc + return reader + + class AsyncSniffer(object): """ Sniff packets and return a list of packets. @@ -1194,24 +1211,16 @@ def _run(self, if isinstance(offline, list) and \ all(isinstance(elt, str) for elt in offline): # List of files - sniff_sockets.update((PcapReader( # type: ignore - fname if flt is None else - tcpdump(fname, - args=["-w", "-"], - flt=flt, - getfd=True, - quiet=quiet) - ), fname) for fname in offline) + sniff_sockets.update( + (_offline_pcap_reader(fname, flt, quiet), fname) # type: ignore + for fname in offline + ) elif isinstance(offline, dict): # Dict of files - sniff_sockets.update((PcapReader( # type: ignore - fname if flt is None else - tcpdump(fname, - args=["-w", "-"], - flt=flt, - getfd=True, - quiet=quiet) - ), label) for fname, label in offline.items()) + sniff_sockets.update( + (_offline_pcap_reader(fname, flt, quiet), label) # type: ignore + for fname, label in offline.items() + ) elif isinstance(offline, (Packet, PacketList, list)): # Iterables (list of packets, PacketList..) offline = IterSocket(offline) diff --git a/scapy/utils.py b/scapy/utils.py index facd90d8803..43c120d7c4a 100644 --- a/scapy/utils.py +++ b/scapy/utils.py @@ -1410,6 +1410,10 @@ class RawPcapReader(metaclass=PcapReader_metaclass): nonblocking_socket = True PacketMetadata = collections.namedtuple("PacketMetadata", ["sec", "usec", "wirelen", "caplen"]) # noqa: E501 + # A helper subprocess (e.g. the tcpdump prefilter that sniff() spawns for + # an offline capture with a filter) whose lifetime is bound to this reader. + # It is reaped in close() so it does not linger as a zombie (#4512). + subproc = None # type: Optional[subprocess.Popen[bytes]] def __init__(self, filename, fdesc=None, magic=None): # type: ignore # type: (str, _ByteStream, bytes) -> None @@ -1529,6 +1533,13 @@ def close(self): if isinstance(self.f, gzip.GzipFile): self.f.fileobj.close() # type: ignore self.f.close() + if self.subproc is not None: + # Reap the prefilter subprocess. The read pipe is already closed + # above, so a still-running tcpdump gets a SIGTERM and we then + # wait() to avoid a zombie; an already-finished one is just reaped. + self.subproc.terminate() + self.subproc.wait() + self.subproc = None def __exit__(self, exc_type, exc_value, tracback): # type: (Optional[Any], Optional[Any], Optional[Any]) -> None diff --git a/test/regression.uts b/test/regression.uts index af284e224a6..cb27ff904fa 100644 --- a/test/regression.uts +++ b/test/regression.uts @@ -2464,6 +2464,37 @@ assert all(UDP in p for p in l) l = sniff(offline=IP()/UDP(sport=(10000, 10001)), filter="tcp") assert len(l) == 0 += Offline sniff() with a filter reaps its tcpdump process (#4512) +~ tcpdump libpcap +import gc, warnings + +fdesc, filename = tempfile.mkstemp() +os.close(fdesc) +wrpcap(filename, [IP()/TCP()] * 20) + +def _leaked_subprocess_warnings(**kwargs): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + packets = sniff(offline=filename, filter="tcp", **kwargs) + gc.collect() + leaked = [str(w.message) for w in caught + if issubclass(w.category, ResourceWarning) + and "subprocess" in str(w.message)] + return packets, leaked + +# Reading the whole file: the prefilter process must be waited on in close(). +packets, leaked = _leaked_subprocess_warnings() +assert len(packets) == 20 +assert not leaked, leaked + +# Stopping early leaves tcpdump running; close() must terminate and reap it +# rather than block or leave a zombie behind. +packets, leaked = _leaked_subprocess_warnings(count=3) +assert len(packets) == 3 +assert not leaked, leaked + +os.unlink(filename) + = Check offline sniff() with Packets, tcpdump and a bad filter ~ tcpdump libpcap From d2b66e6eeae84242d7bff7793e465c6cb4287344 Mon Sep 17 00:00:00 2001 From: Eugen Goebel Date: Sat, 25 Jul 2026 01:35:18 +0200 Subject: [PATCH 2/2] sniff: reap the tcpdump prefilter subprocess on the remaining offline paths The previous commit only covered the offline sources given as filenames. The packet iterable branch (sniff(offline=IP()/UDP(...), filter=...)) and the file descriptor branch still built their PcapReader from a bare getfd=True pipe, so the tcpdump process was left unreaped there. Route both through the existing _offline_pcap_reader helper, generalized to take any source tcpdump() accepts (filename, IterSocket, open file descriptor), so every filtered offline path attaches the process to the reader and reaps it in close(). AI-Assisted: yes (Claude Opus 5) --- scapy/sendrecv.py | 40 +++++++++++++++++----------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/scapy/sendrecv.py b/scapy/sendrecv.py index 3f98e727c60..479060c9a87 100644 --- a/scapy/sendrecv.py +++ b/scapy/sendrecv.py @@ -1053,18 +1053,20 @@ def srp1flood(x, # type: _PacketIterable # SNIFF METHODS -def _offline_pcap_reader(fname, flt, quiet): - # type: (str, Optional[str], bool) -> PcapReader +def _offline_pcap_reader(source, flt, quiet): + # type: (Any, Optional[str], bool) -> PcapReader """Build a PcapReader for one sniff() offline source. - Without a filter the file is read directly. With a filter the packets are - prefiltered through a tcpdump subprocess; that process is attached to the - reader so its close() reaps it. Reading it through a bare file descriptor - used to leak the process as a zombie (#4512). + The source is whatever tcpdump() accepts: a filename, an IterSocket over + packets, or an open file descriptor. Without a filter it is read directly. + With a filter the packets are prefiltered through a tcpdump subprocess; + that process is attached to the reader so its close() reaps it. Reading it + through a bare file descriptor used to leak the process as a zombie + (#4512). """ if flt is None: - return PcapReader(fname) - proc = tcpdump(fname, args=["-w", "-"], flt=flt, getproc=True, quiet=quiet) + return PcapReader(source) + proc = tcpdump(source, args=["-w", "-"], flt=flt, getproc=True, quiet=quiet) reader = PcapReader(proc.stdout) reader.subproc = proc return reader @@ -1224,23 +1226,15 @@ def _run(self, elif isinstance(offline, (Packet, PacketList, list)): # Iterables (list of packets, PacketList..) offline = IterSocket(offline) - sniff_sockets[offline if flt is None else PcapReader( - tcpdump(offline, - args=["-w", "-"], - flt=flt, - getfd=True, - quiet=quiet) - )] = offline + sniff_sockets[ + offline if flt is None + else _offline_pcap_reader(offline, flt, quiet) + ] = offline else: # Other (file descriptors...) - sniff_sockets[PcapReader( # type: ignore - offline if flt is None else - tcpdump(offline, - args=["-w", "-"], - flt=flt, - getfd=True, - quiet=quiet) - )] = offline + sniff_sockets[ + _offline_pcap_reader(offline, flt, quiet) # type: ignore + ] = offline if not sniff_sockets or iface is not None: # The _RL2 function resolves the L2socket of an iface _RL2 = lambda i: L2socket or resolve_iface(i).l2listen() # type: Callable[[_GlobInterfaceType], Callable[..., SuperSocket]] # noqa: E501