Skip to content

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

Open
mitmproxy/mitmproxy_rs
#326

Description

@georgezlei

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)

Activity

  1. MrBeldum commented on Aug 31, 2026

    @MrBeldum

    I'd like to take this.

    Confirmed on current mitmproxy/mitmproxy_rs src/packet_sources/macos.rs: handle_udp binds Some(packet) = stream.next(), so EOF (None) disables that arm. If the flow dies before the first datagram, ConnectionEstablished never fires, command_tx stays in first_packet, and the UnixStream leaks until process exit. Same class as #7675 / mitmproxy_rs#260, UDP-only.

    Plan: treat stream EOF like CloseConnection — state.close() and break so the task drops the socket. PR would land on mitmproxy/mitmproxy_rs.

    Happy to wait if you'd rather handle it yourself.

  2. MrBeldum commented on Aug 31, 2026

    @MrBeldum

    I am not going to be able to follow this through. Please do not hold the issue for me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions