#8412·mitmproxy

macOS local mode: UDP flows closed by the redirector before their first datagram leak a file descriptor forever

Author: georgezleiCreated Aug 31, 2026Updated Aug 31, 2026

Problem Description

In macOS local redirect mode, every UDP flow that the network extension closes before its first datagram reaches mitmproxy leaks one file descriptor in the mitmproxy process for the life of the process. Under normal DNS-heavy traffic (Tailscale exit node + browsers) this is 0.8–3.7 leaked fds per second. With the kern.maxfilesperproc cap of 10240 the process fails after ~3 h:

Error accepting connection from macos-redirector: Too many open files (os error 24)

mitmproxy keeps running; every new flow from the redirector is refused, so intercepted apps lose connectivity. This is the UDP counterpart of #7675 / mitmproxy_rs#260, which fixed the TCP copiers in the extension.

Root cause

src/packet_sources/macos.rs, ConnectionTask::handle_udp (same on main and v0.12.9):

loop {
    tokio::select! {
        _ = self.shutdown.recv() => break,
        Some(packet) = stream.next(), if state.packet_queue_len() < 10 => {
            ...
            // ConnectionEstablished is only sent once the first packet arrives
        },
        Some(command) = command_rx.recv() => { ... CloseConnection => break ... }
    }
}

When the extension ends the flow (FlowExtensions.swift UDP outboundCopier: conn.send(content: nil, isComplete: true) then conn.cancel()), stream.next() yields None. The Some(packet) = ... pattern does not match, so tokio::select! disables that arm and the loop waits for a command from Python. The UnixStream is only dropped when Python sends CloseConnection.

  • If at least one datagram was delivered, Python knows the connection and its UDP_TIMEOUT (20 s) eventually closes it. Delayed, but bounded.
  • If the flow ends before the first datagram, ConnectionEstablished was never sent, no Python handler exists, command_tx is still held in first_packet so command_rx never closes, and the task waits forever with the socket open.

handle_tcp is not affected: read_buf returns 0 on EOF and Python sees the close.

Evidence

  • lsof -p <mitmdump>: 9,870 fds of type unix on /tmp/mitmproxy-<pid> at failure; netstat -f unix shows 98 % of them with Conn = 0 (peer gone).
  • Survival sampling by kernel socket address: 98 % of the sockets present at t0 still present after 15 min; fd numbers fill 13…5350 contiguously (lowest-free reuse ⇒ long-lived).
  • Python is not tracking them: upstream sockets stay at 50–200 while the unix count climbs to thousands.
  • Timeline: start 09:11, 4,600 fds at 10:47, 7,045 at 11:52, 9,162 at 12:04, EMFILE at 12:11.

Steps to reproduce the behavior

  1. mitmdump --mode local:<some process that does UDP/DNS> on macOS (e.g. local:io.tailscale.ipn.macsys.network-extension, or a browser).
  2. Generate DNS traffic from that process.
  3. Watch lsof -p $(pgrep -f mitmdump) | grep -c /tmp/mitmproxy- grow without bound; it never decreases below the leaked baseline.

Suggested fix

Treat EOF from the extension as a close, in the same way as CloseConnection:

packet = stream.next(), if state.packet_queue_len() < 10 => {
    let Some(packet) = packet else {
        // extension closed the flow
        state.close();
        break;
    };
    let packet = ipc::UdpPacket::decode(packet.context("IPC read error")?)...

If a Python connection was already established, the existing Drop on the Python side will send CloseConnection afterwards; that arrives on a closed command_rx and can be ignored.

System Information

Mitmproxy: 12.2.3 binary (Homebrew cask)
Python:    3.14.4
OpenSSL:   OpenSSL 3.5.5 27 Jan 2026
Platform:  macOS-26.5.2-arm64-arm-64bit-Mach-O
mitmproxy_rs: 0.12.x (bundled)