UDP sessions orphaned forever after QUIC connection close in hysteria2/hysteria/tuic clients
Nobody has claimed this yet.
- 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):
hysteria2/client.go: (*clientQUICConnection).closeWithError()closes the QUIC
connection and raw socket but does NOT close the sessions inudpConnMap— they
are silently orphaned.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 afterloopMessagesexits.- A new connection created by
offer()does not adopt existing sessions — they stay
bound to the deadquicConnpointer.
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
- I am a sponsor
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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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