SagerNet / SagerNet/sing-box

tproxy: UDP write-back connection not cached when netns is configured, causing excessive syscall overhead

Open
#3,876 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement
Dominant language
Go
Stars
38.2k
Forks
4.6k
Avg merge
19d 15h
Merged PRs (30d)
1

Description

Operating system

Linux

System version

dev-next

Installation type

Original sing-box Command Line

If you are using a graphical client, please provide the version of the client.

No response

Version

Description

Related

Follow-up to #3872. While #3872 addresses the leaked currentNs fd in ListenNetworkNamespace, this issue covers the underlying performance problem that makes the leak so severe.

Problem

In protocol/redirect/tproxy.go, tproxyPacketWriter.WritePacket explicitly skips connection caching when NetNs is configured:

// line 126: cache only used when NetNs == ""
if w.listener.ListenOptions().NetNs == "" {
    conn := w.conn
    if w.destination == destination && conn != nil {
        _, err := conn.WriteToUDPAddrPort(buffer.Bytes(), w.source)
        // ...
        return err
    }
}

// line 144: cache only stored when NetNs == ""
if w.listener.ListenOptions().NetNs == "" && w.destination == destination {
    w.conn = udpConn
} else {
    defer udpConn.Close()
}

This means every single UDP response packet goes through the full netns switch cycle:

runtime.LockOSThread()
→ netns.Get() // syscall: open("/proc/.../ns/net")
→ netns.Set(targetNs) // syscall: setns()
→ net.ListenPacket() // syscall: socket() + bind()
→ netns.Set(currentNs) // syscall: setns()
runtime.UnlockOSThread()
→ WriteToUDPAddrPort() // actual send
→ udpConn.Close() // syscall: close()

That is 6+ syscalls per UDP response packet, compared to 1 syscall (WriteToUDPAddrPort) when NetNs is empty and the conn is cached.

For high-throughput UDP flows (e.g., QUIC), this results in thousands of unnecessary netns switches per second, significant CPU overhead, and was the amplifier that made the fd leak
in #3872 so quickly fatal.

Expected Behavior

A socket created inside a target netns via ListenNetworkNamespace remains bound to that netns after the thread switches back — the fd is valid and usable from any thread. There is no
reason to skip caching.

The write-back conn should be cached and reused regardless of whether NetNs is configured, the same way it already works for the NetNs == "" case.

Suggested Fix

Remove the two NetNs == "" guards in WritePacket:

  func (w *tproxyPacketWriter) WritePacket(buffer *buf.Buffer, destination M.Socksaddr) error {
      defer buffer.Release()
      // Use cached conn (regardless of NetNs)
      conn := w.conn
      if w.destination == destination && conn != nil {
          _, err := conn.WriteToUDPAddrPort(buffer.Bytes(), w.source)
          if err != nil {
              w.conn = nil
          }
          return err
      }
      // ... create new conn via ListenNetworkNamespace ...
      // Cache conn (regardless of NetNs)
      if w.destination == destination {
          w.conn = udpConn
      } else {
          defer udpConn.Close()
      }
      return common.Error(udpConn.WriteToUDPAddrPort(buffer.Bytes(), w.source))
  }

Cleanup is already handled: preparePacketConnection closes writer.conn when the udpnat2 entry is evicted on timeout.

Impact

  • Before: Every UDP response packet = 6+ syscalls + thread lock (with netns)
  • After: First packet per flow = 1 netns switch; all subsequent packets = 1 syscall
Reproduction
Logs

Supporter
Integrity requirements
  • I confirm that I have read the documentation, understand the meaning of all the configuration items I wrote, and did not pile up seemingly useful options or default values.
  • I confirm that I have provided the server and client configuration files and process that can be reproduced locally, instead of a complicated client configuration file that has been stripped of sensitive data.
  • I confirm that I have provided the simplest configuration that can be used to reproduce the error I reported, instead of depending on remote servers, TUN, graphical interface clients, or other closed-source software.
  • I confirm that I have provided the complete configuration files and logs, rather than just providing parts I think are useful out of confidence in my own intelligence.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in protocol/redirect/tproxy.go at tproxyPacketWriter.WritePacket and inspect how ListenNetworkNamespace creates the UDP connection. Verify the cached connection is reused when NetNs is configured, while preparePacketConnection still closes writer.conn after udpnat2 eviction; completion should eliminate repeated netns setup and close operations for packets in the same flow.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, linux
Domain
networking
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.