SagerNet / SagerNet/sing-box

UDP sessions orphaned forever after QUIC connection close in hysteria2/hysteria/tuic clients

Open
#4,375 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

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

Description

Operating system

macOS

System version

macOS 26.5.2

Installation type

Original sing-box Command Line

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

No response

Version
sing-box version 1.13.15

Environment: go1.26.5 darwin/arm64
Tags: with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_naive_outbound,badlinkname,tfogo_checklinkname0
Revision: 3708fa18766cda1f11b77f6ed9c7bd61688f17df
CGO: enabled
Description

UDP sessions over hysteria2 (also hysteria and TUIC) are permanently orphaned when the
underlying QUIC connection is closed or replaced. The session is never torn down, reads
block forever, and — critically — inbound client traffic keeps refreshing the sing-box
UDP NAT entry, so the dead session is pinned alive indefinitely. The application never
recovers until it changes its source port. For VoIP (WhatsApp/etc. behind a tproxy
router) this manifests as permanent one-way audio after any transient tunnel disruption.

Root cause (sing-quic, identical on main and dev):

  1. hysteria2/client.go: (*clientQUICConnection).closeWithError() closes the QUIC
    connection and raw socket but does NOT close the sessions in udpConnMap — they
    are silently orphaned.
  2. newUDPPacketConn(c.ctx, ...) binds each UDP session to the Client-level context
    (lives as long as the outbound), not to the connection, so the orphan's ReadPacket
    blocks forever after loopMessages exits.
  3. A new connection created by offer() does not adopt existing sessions — they stay
    bound to the dead quicConn pointer.

The same pattern exists in hysteria/client.go and tuic/client.go.

Expected: on connection close, all UDP sessions of that connection should be closed
with the connection error, so the relay tears them down and the next packet from the
client re-creates the session over the new connection.

Reproduction

Everything runs locally on loopback with the original sing-box CLI: no remote server, no TUN, no GUI, no third-party build. Tested on macOS with sing-box 1.13.15.

Files

Certificate:

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
  -keyout key.pem -out cert.pem -days 7 -nodes -subj /CN=test

server.json:

{
  "log": {"level": "debug", "timestamp": true},
  "inbounds": [
    {
      "type": "hysteria2",
      "listen": "127.0.0.1",
      "listen_port": 8443,
      "users": [{"password": "test"}],
      "tls": {
        "enabled": true,
        "key_path": "key.pem",
        "certificate_path": "cert.pem"
      }
    }
  ]
}

client.json:

{
  "log": {"level": "debug", "timestamp": true},
  "inbounds": [
    {"type": "mixed", "listen": "127.0.0.1", "listen_port": 11080}
  ],
  "outbounds": [
    {
      "type": "hysteria2",
      "server": "127.0.0.1",
      "server_port": 8443,
      "password": "test",
      "tls": {"enabled": true, "insecure": true}
    }
  ]
}

probe.py — UDP endpoints and probes. A plain socat cannot be used here because the probes must go through SOCKS5 UDP ASSOCIATE with a fixed source port:

#!/usr/bin/env python3
import socket, struct, sys, time, threading, datetime

def now():
    return f"{datetime.datetime.now():%H:%M:%S}"

def echo(port):                        # replies to whatever it receives
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.bind(("127.0.0.1", port))
    while True:
        data, addr = s.recvfrom(2048)
        s.sendto(data, addr)

def streamer(port):                    # after one datagram, streams 10 pps forever
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.bind(("127.0.0.1", port))
    target = [None]
    def pump():
        n = 0
        while True:
            if target[0]:
                n += 1
                try:
                    s.sendto(b"stream-%d" % n, target[0])
                except OSError:
                    pass
            time.sleep(0.1)
    threading.Thread(target=pump, daemon=True).start()
    while True:
        data, addr = s.recvfrom(2048)
        target[0] = addr

def associate(socks_port):             # SOCKS5 UDP ASSOCIATE
    tcp = socket.create_connection(("127.0.0.1", socks_port))
    tcp.sendall(b"\x05\x01\x00")
    assert tcp.recv(2) == b"\x05\x00"
    tcp.sendall(b"\x05\x03\x00\x01\x00\x00\x00\x00\x00\x00")
    resp = tcp.recv(32)
    assert resp[1] == 0, resp
    return tcp, ("127.0.0.1", struct.unpack(">H", resp[8:10])[0])

def wrap(dst_port, payload):           # SOCKS5 UDP request header
    return b"\x00\x00\x00\x01\x7f\x00\x00\x01" + struct.pack(">H", dst_port) + payload

def updown(socks_port, src_port, dst_port, label):    # sends 10 pps, expects echoes
    tcp, relay = associate(socks_port)
    u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    u.bind(("127.0.0.1", src_port))
    u.settimeout(0.05)
    sent = recv = 0
    last = (0, 0)
    mark = time.time()
    while True:
        u.sendto(wrap(dst_port, b"ping"), relay)
        sent += 1
        try:
            u.recvfrom(2048)
            recv += 1
        except socket.timeout:
            pass
        if time.time() - mark >= 1:
            mark = time.time()
            print(f"[{now()}] {label}: sent={sent - last[0]}/s "
                  f"recv={recv - last[1]}/s total={sent}/{recv}", flush=True)
            last = (sent, recv)
        time.sleep(0.05)

def downonly(socks_port, src_port, dst_port, label):  # one datagram, then only listens
    tcp, relay = associate(socks_port)
    u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    u.bind(("127.0.0.1", src_port))
    u.settimeout(1.0)
    u.sendto(wrap(dst_port, b"start"), relay)
    recv = last = 0
    mark = time.time()
    while True:
        try:
            u.recvfrom(2048)
            recv += 1
        except socket.timeout:
            pass
        if time.time() - mark >= 1:
            mark = time.time()
            print(f"[{now()}] {label}: recv={recv - last}/s total={recv}", flush=True)
            last = recv

mode = sys.argv[1]
if mode == "echo":
    echo(int(sys.argv[2]))
elif mode == "streamer":
    streamer(int(sys.argv[2]))
elif mode == "updown":
    updown(int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4]), sys.argv[5])
elif mode == "downonly":
    downonly(int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4]), sys.argv[5])
Steps

1. Start the UDP endpoints and both sing-box instances:

python3 probe.py echo 9999 &
python3 probe.py streamer 9998 &
sing-box run -c server.json &
sing-box run -c client.json &

2. Start two probes with FIXED source ports through the mixed inbound:

python3 probe.py updown   11080 45001 9999 A-updown   &
python3 probe.py downonly 11080 45002 9998 B-downonly &

Both work:

[22:43:42] A-updown: sent=18/s recv=18/s total=555/555
[22:43:43] A-updown: sent=18/s recv=18/s total=573/573
[22:43:17] B-downonly: recv=10/s total=61
[22:43:18] B-downonly: recv=10/s total=71

3. Kill the QUIC connection by freezing the server for longer than the 30 s MaxIdleTimeout (blocking the client→server datagrams with a firewall rule for ~40 s has the same effect):

kill -STOP <server pid>
sleep 45
kill -CONT <server pid>

4. Both pre-existing sessions are dead — permanently. A keeps sending, and its own traffic keeps the dead session pinned alive; B's counter is frozen at the value it had when the connection died:

[22:43:43] A-updown: sent=18/s recv=18/s total=573/573   <- last working second
[22:43:44] A-updown: sent=11/s recv=3/s  total=584/576   <- connection dies
[22:43:45] A-updown: sent=10/s recv=0/s  total=594/576
...                                                       server resumed at 22:44:28
[22:44:43] A-updown: sent=10/s recv=0/s  total=1144/576
[22:46:59] A-updown: sent=10/s recv=0/s  total=2444/576  <- 1868 packets into the void

[22:44:43] B-downonly: recv=0/s total=308
[22:46:59] B-downonly: recv=0/s total=308

(the send rate halves from 18/s to 10/s only because each loop iteration now waits out the 50 ms receive timeout)

5. A probe from a NEW source port works immediately, proving the tunnel, the server and the new QUIC connection are all healthy — only the sessions that existed across the connection loss are dead:

python3 probe.py updown 11080 45003 9999 C-fresh &
[22:45:08] C-fresh: sent=18/s recv=18/s total=110/110
[22:46:58] C-fresh: sent=19/s recv=19/s total=2090/2090

The two dead sessions never recover, no matter how long they are left running, and nothing is logged.

Logs
Nothing is logged when the sessions are orphaned — the failure is completely silent.
The client log for the entire run below is 12 lines with zero errors or warnings.

The relay tears a UDP session down on the first write error (see
sing/common/bufio.CopyPacketWithPool, which returns as soon as
destination.WritePacket fails). The orphaned session survived for 3.5 minutes
while the client kept writing 10 packets/s into it, so those writes never
returned an error — nothing ever signals the relay that the session is dead.

Full client log of the run:

+0300 2026-08-02 22:43:09 INFO network: updated default interface en0, index 11
+0300 2026-08-02 22:43:09 INFO inbound/mixed[0]: tcp server started at 127.0.0.1:11080
+0300 2026-08-02 22:43:09 INFO sing-box started (0.00s)
+0300 2026-08-02 22:43:11 INFO [686449366  0ms] inbound/mixed[0]: inbound connection from 127.0.0.1:50029
+0300 2026-08-02 22:43:11 INFO [2604625923 0ms] inbound/mixed[0]: inbound connection from 127.0.0.1:50030
+0300 2026-08-02 22:43:11 INFO [2604625923 1ms] inbound/mixed[0]: inbound packet connection to 127.0.0.1:9998
+0300 2026-08-02 22:43:11 INFO [686449366  1ms] inbound/mixed[0]: inbound packet connection to 127.0.0.1:9999
+0300 2026-08-02 22:43:11 INFO [2604625923 1ms] outbound/hysteria2[0]: outbound packet connection to 127.0.0.1:9998
+0300 2026-08-02 22:43:11 INFO [686449366  1ms] outbound/hysteria2[0]: outbound packet connection to 127.0.0.1:9999
+0300 2026-08-02 22:45:02 INFO [308538020  0ms] inbound/mixed[0]: inbound connection from 127.0.0.1:50073
+0300 2026-08-02 22:45:02 INFO [308538020  0ms] inbound/mixed[0]: inbound packet connection to 127.0.0.1:9999
+0300 2026-08-02 22:45:02 INFO [308538020  0ms] outbound/hysteria2[0]: outbound packet connection to 127.0.0.1:9999

(the last three lines are probe C, started after the outage — the two dead sessions
from 22:43:11 never log anything again)
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 by tracing clientQUICConnection.closeWithError and newUDPPacketConn in hysteria2/client.go, then compare the corresponding session handling in hysteria/client.go and tuic/client.go. Review sing/common/bufio.CopyPacketWithPool to understand relay teardown. Done means UDP sessions close with the connection error and a later packet re-creates them over the new connection.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
networking
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.