TUIC/BBR: upload throughput drops after a download on a reused connection (local CLI reproducer)
Operating system
Linux
System version
Debian GNU/Linux forky/sid, x86_64; Linux 6.18.35-x64v1-xanmod1.
Installation type
Original sing-box Command Line
If you are using a graphical client, please provide the version of the client.
Not applicable to the reproduction; no graphical client is used.
Version
sing-box version 1.14.1
Environment: go1.26.8 linux/amd64
Tags: with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_usbip,with_openvpn,with_openconnect,badlinkname,tfogo_checklinkname0,with_musl
Revision: 1ac1a339cb1223e9c70eae14c44411c75033c02d
CGO: enabledDescription
On a local, isolated, rate-limited link, a TUIC client using BBR has substantially lower upload throughput after a sequence containing downloads than a fresh client performing the same final upload. The client process and its TUIC connection stay alive throughout each sequence. Restarting the client for the fresh comparison restores the higher throughput in this test.
Expected: with unchanged link capacity, no configured loss, no competing application traffic and identical final upload size/concurrency, previous downloads should not cause this degree of subsequent upload degradation.
Observed with the original 1.14.1 command-line binary, two rounds with reversed case order:
| Round | Fresh connection: upload Mbps | After download sequence: upload Mbps | Reduction |
|---|---|---|---|
| 1 | 87.109 | 54.112 | 37.9% |
| 2 | 88.290 | 51.299 | 41.9% |
The final upload is 16 MiB total across four concurrent TCP streams, not 16 MiB per stream. Throughput is total verified payload bytes × 8 / wall-clock completion time, in decimal Mbps. Timing includes SOCKS connection setup and the receiver's final byte-count acknowledgement. Both variants use exactly the same final upload workload and a 1 MiB warm-up upload. All payload bytes are verified; there are no failed or omitted phases in the reported final run.
The link is shaped to 100 Mbps per direction, with 5 ms added delay per direction (approximately 10 ms propagation RTT). No packet loss is configured. Both namespaces' HTB/netem end counters report zero drops and zero remaining backlog. HTB overlimits reflect rate shaping, not dropped packets. GSO is disabled for these isolated sing-box processes only, to make the artificial link test more controlled. These numbers are specific to this short workload and environment, not a universal throughput limit.
This investigation began with much more severe Windows/Android upload symptoms. In one Windows comparison, sing-box 1.14.1 reported 290.36 Mbps download / 4.64 Mbps upload, whereas FlClash reported 426.22 / 362.37 Mbps through the same public exit. However, those GUI measurements are background only: a CUBIC trial did not resolve the device symptom, and this Linux BBR reproduction does not establish that both problems have the same root cause. This report is deliberately scoped to the independently reproducible local throughput regression.
Possible investigation area, not a confirmed diagnosis from these logs: application-limited accounting when receive-heavy traffic generates outbound control frames. The relevant dependency is SagerNet/quic-go v0.61.0-sing-box-mod.7, via sing-quic v0.7.0. Earlier diagnostic experiments suggested inspecting packerEmptyTime and the injected congestion controller's OnAppLimited call. The reported measurements below use the unmodified binary; no candidate patch is required to reproduce them.
Source references:
- sing-box 1.14.1 dependencies
- TUIC congestion controller selection
- QUIC sent-packet/application-limited accounting
Reproduction
Everything runs on one Linux machine using the original sing-box CLI. No external proxy, Internet test endpoint, DNS resolver, TUN, GUI or closed-source program is needed during the test.
Requirements: root/CAP_NET_ADMIN access with Linux network namespaces, ip and tc from iproute2, Python 3, OpenSSL, and the original sing-box 1.14.1 binary. The recorded host uses the kernel listed above; portability to other kernels has not yet been established.
Save the complete script below as
repro.py(or extract it from the attachedtuic-upload-evidence.zip).Run it with the absolute path to the original sing-box binary. Choose a new output directory for each invocation:
sudo python3 repro.py --binary /usr/bin/sing-box --output ./repro-outputCompare the final phase of each
freshandafter-downloadcase inrepro-output/results.json. The script prints the same measurements and records complete client/server logs.
Topology:
Python workload -> SOCKS 127.0.0.1:18890 -> sing-box TUIC client
isolated client namespace, 192.0.2.1
<veth; HTB 100 Mbps + netem 5 ms each way>
isolated server namespace, 192.0.2.2
sing-box TUIC server :14443 -> Python receiver 127.0.0.1:18080The script creates random temporary namespace names and lab-only credentials, generates and verifies a self-signed test certificate, checks both configs with sing-box check, then starts original CLI server/client processes. It does not read production configs or alter host-interface routes/qdiscs. Both namespaces have an isolated default route only to satisfy interface monitoring. Temporary processes, namespaces and the private test key are removed on exit.
Case sequences (sizes are total payload per phase):
- fresh: restart client → UP 1 MiB / 1 stream (warm-up) → UP 16 MiB / 4 streams (measurement).
- after-download: restart client → UP 1 MiB / 1 → UP 4 MiB / 1 → DOWN 4 MiB / 1 → UP 16 MiB / 1 → DOWN 16 MiB / 1 → UP 16 MiB / 4 (measurement).
- Round 1 runs fresh then after-download; round 2 reverses that order. The server remains running. TCP streams are recreated between phases; TUIC client state is retained within each case. This compares the complete specified histories; it does not isolate a single preceding packet or prove permanence of the degradation.
BBR is explicitly selected on both ends. initial_packet_size: 1200 plus disabled PMTU discovery hold QUIC packet sizing constant. udp_relay_mode: native records the existing baseline but no application UDP relay is exercised: all workload streams are TCP. h3 is the pinned ALPN. Other features such as TUN, sniffing, DNS filtering, selector/URLTest groups, multiplexing extensions and Tailscale are absent.
Complete reproduction script
repro.py#!/usr/bin/env python3
"""Local TUIC connection-history benchmark. Requires root, Linux ip/tc, openssl.
Only creates disposable network namespaces; no production config or host qdisc changes.
Usage: sudo python3 repro.py --binary /absolute/path/sing-box --output ./repro-output
"""
import argparse, concurrent.futures, hashlib, json, os, pathlib, shutil, socket, socketserver, struct, subprocess, sys, tempfile, threading, time, uuid
def exact(s,n):
b=b''
while len(b)<n:
x=s.recv(n-len(b))
if not x:raise EOFError('short transfer')
b+=x
return b
class Receiver(socketserver.BaseRequestHandler):
def handle(self):
s=self.request;s.settimeout(60);s.setsockopt(socket.IPPROTO_TCP,socket.TCP_NODELAY,1)
line=b''
while not line.endswith(b'\n'):
line+=exact(s,1)
if len(line)>100:raise ValueError('long header')
op,n=line.decode().split();n=int(n)
if op=='UP':
count=0
while count<n:
data=s.recv(min(65536,n-count))
if not data:raise EOFError('short upload')
if data!=b'x'*len(data):raise ValueError('wrong upload data')
count+=len(data)
s.sendall((str(count)+'\n').encode())
elif op=='DOWN':
remaining=n
while remaining:
data=b'x'*min(65536,remaining);s.sendall(data);remaining-=len(data)
def transfer(op,size,barrier):
with socket.create_connection(('127.0.0.1',18890),timeout=60) as s:
s.setsockopt(socket.IPPROTO_TCP,socket.TCP_NODELAY,1)
s.sendall(b'\5\1\0');assert exact(s,2)==b'\5\0'
s.sendall(b'\5\1\0\1'+socket.inet_aton('127.0.0.1')+struct.pack('!H',18080))
h=exact(s,4);assert h[1]==0
if h[3]==1:exact(s,6)
elif h[3]==4:exact(s,18)
else:exact(s,exact(s,1)[0]+2)
barrier.wait(60);s.sendall(f'{op} {size}\n'.encode());count=0
while count<size:
if op=='UP':
data=b'x'*min(65536,size-count);s.sendall(data)
else:
data=s.recv(min(65536,size-count))
if not data:raise EOFError('short download')
if data!=b'x'*len(data):raise ValueError('wrong download data')
count+=len(data)
if op=='UP':
ack=b''
while not ack.endswith(b'\n'):ack+=exact(s,1)
assert int(ack)==size
return count
def worker(phases):
results=[]
for op,mib,streams in phases:
size=mib*1048576;barrier=threading.Barrier(streams);start=time.monotonic()
with concurrent.futures.ThreadPoolExecutor(max_workers=streams) as pool:
jobs=[pool.submit(transfer,op,size//streams,barrier) for _ in range(streams)]
total=sum(j.result() for j in jobs)
elapsed=time.monotonic()-start;assert total==size
results.append({'direction':op,'MiB':mib,'streams':streams,'bytes':total,'seconds':round(elapsed,6),'Mbps':round(size*8/elapsed/1e6,3)})
print(json.dumps(results),flush=True)
def main():
ap=argparse.ArgumentParser();ap.add_argument('--binary',default='/usr/bin/sing-box');ap.add_argument('--output',required=True);ap.add_argument('--delay-ms',type=float,default=5);ap.add_argument('--rate-mbps',type=int,default=100);ap.add_argument('--rounds',type=int,default=2);a=ap.parse_args()
if os.geteuid()!=0:raise SystemExit('Run as root: disposable network namespaces require it.')
binary=str(pathlib.Path(a.binary).resolve());script=str(pathlib.Path(__file__).resolve());out=pathlib.Path(a.output).resolve()
if out.exists():raise SystemExit('Output directory already exists; choose a new path.')
out.mkdir(mode=0o700,parents=True)
for tool in ['ip','tc','openssl']:
if not shutil.which(tool):raise SystemExit('Missing required tool: '+tool)
def run(args,**kw):return subprocess.run(args,check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,**kw)
suffix=uuid.uuid4().hex[:8];cn='tuic-c-'+suffix;sn='tuic-s-'+suffix;names=[];processes=[];logs=[]
def ns(n,*args):return ['ip','netns','exec',n,*args]
def launch(args,name):
f=(out/name).open('w');logs.append(f);env=os.environ.copy();env['QUIC_GO_DISABLE_GSO']='true'
p=subprocess.Popen(args,stdout=f,stderr=f,env=env);processes.append(p);return p
def stop(p):
if p.poll() is None:
p.terminate()
try:p.wait(5)
except subprocess.TimeoutExpired:p.kill();p.wait()
result={'environment':{'uname':run(['uname','-srmo']).stdout.decode().strip(),'os_release':pathlib.Path('/etc/os-release').read_text(),'version':run([binary,'version']).stdout.decode(),'binary_sha256':hashlib.sha256(pathlib.Path(binary).read_bytes()).hexdigest()},'link':{'rate_mbps_each_direction':a.rate_mbps,'delay_ms_each_direction':a.delay_ms,'loss_percent':0,'netem_limit_packets':3000,'quic_gso_disabled_in_lab':True},'cases':[]}
try:
for n in [cn,sn]:run(['ip','netns','add',n]);names.append(n);run(ns(n,'ip','link','set','lo','up'))
run(ns(cn,'ip','link','add','eth0','type','veth','peer','name','peer0'))
run(ns(cn,'ip','link','set','peer0','netns',sn));run(ns(sn,'ip','link','set','peer0','name','eth0'))
for n,addr in [(cn,'192.0.2.1/30'),(sn,'192.0.2.2/30')]:
run(ns(n,'ip','addr','add',addr,'dev','eth0'));run(ns(n,'ip','link','set','eth0','up'))
# Both namespaces remain isolated; this route only satisfies the
# core's default-interface monitor and has no path to the Internet.
run(ns(n,'ip','route','add','default','dev','eth0'))
run(ns(n,'tc','qdisc','add','dev','eth0','root','handle','1:','htb','default','10'))
run(ns(n,'tc','class','add','dev','eth0','parent','1:','classid','1:10','htb','rate',f'{a.rate_mbps}mbit','ceil',f'{a.rate_mbps}mbit','quantum','1514'))
run(ns(n,'tc','qdisc','add','dev','eth0','parent','1:10','handle','10:','netem','limit','3000','delay',f'{a.delay_ms}ms'))
with tempfile.TemporaryDirectory(prefix='tuic-repro-key-') as td:
key=pathlib.Path(td)/'key.pem';cert=out/'certificate.pem'
run(['openssl','req','-x509','-newkey','rsa:2048','-nodes','-keyout',str(key),'-out',str(cert),'-days','1','-subj','/CN=tuic-repro.test','-addext','subjectAltName=DNS:tuic-repro.test'])
user=str(uuid.uuid4());password=uuid.uuid4().hex
server={'log':{'level':'debug','timestamp':True},'inbounds':[{'type':'tuic','tag':'tuic-in','listen':'192.0.2.2','listen_port':14443,'users':[{'uuid':user,'password':password}],'congestion_control':'bbr','initial_packet_size':1200,'disable_path_mtu_discovery':True,'tls':{'enabled':True,'alpn':['h3'],'certificate_path':str(cert),'key_path':str(key)}}],'outbounds':[{'type':'direct','tag':'direct'}]}
client={'log':{'level':'debug','timestamp':True},'inbounds':[{'type':'mixed','listen':'127.0.0.1','listen_port':18890}],'outbounds':[{'type':'tuic','tag':'tuic-out','server':'192.0.2.2','server_port':14443,'uuid':user,'password':password,'congestion_control':'bbr','udp_relay_mode':'native','initial_packet_size':1200,'disable_path_mtu_discovery':True,'tls':{'enabled':True,'server_name':'tuic-repro.test','alpn':['h3'],'certificate_path':str(cert)}}],'route':{'final':'tuic-out'}}
for name,cfg in [('server.json',server),('client.json',client)]:
p=out/name;p.write_text(json.dumps(cfg,indent=2)+'\n');p.chmod(0o600);run([binary,'check','-c',str(p)])
sp=launch(ns(sn,binary,'run','-c',str(out/'server.json')),'server.log')
rp=launch(ns(sn,sys.executable,script,'receiver'),'receiver.log');time.sleep(.5)
if sp.poll() is not None or rp.poll() is not None:raise RuntimeError('receiver/server did not start; inspect logs')
for rnd in range(1,a.rounds+1):
variants=['fresh','after-download'] if rnd%2 else ['after-download','fresh']
for variant in variants:
cp=launch(ns(cn,binary,'run','-c',str(out/'client.json')),f'client-r{rnd}-{variant}.log');time.sleep(.5)
if cp.poll() is not None:raise RuntimeError('client did not start; inspect logs')
phases=[['UP',1,1]]
if variant=='after-download':phases += [['UP',4,1],['DOWN',4,1],['UP',16,1],['DOWN',16,1]]
phases += [['UP',16,4]]
try:
data=run(ns(cn,sys.executable,script,'worker',json.dumps(phases)),timeout=240)
row={'round':rnd,'variant':variant,'phases':json.loads(data.stdout)};result['cases'].append(row);print(json.dumps(row),flush=True)
finally:stop(cp)
result['qdisc_end']={n:json.loads(run(ns(n,'tc','-s','-j','qdisc','show','dev','eth0')).stdout) for n in [cn,sn]}
except Exception as e:
result['error']=type(e).__name__+': '+str(e);raise
finally:
for p in reversed(processes):stop(p)
for f in logs:f.close()
for n in reversed(names):run(['ip','netns','del',n])
(out/'results.json').write_text(json.dumps(result,indent=2)+'\n')
print('Results/logs saved to '+str(out)+'; temporary namespaces and key removed.',flush=True)
if __name__=='__main__':
if len(sys.argv)>1 and sys.argv[1]=='receiver':
class S(socketserver.ThreadingTCPServer):
allow_reuse_address=True;daemon_threads=True
S(('127.0.0.1',18080),Receiver).serve_forever()
elif len(sys.argv)>1 and sys.argv[1]=='worker':worker(json.loads(sys.argv[2]))
else:main()Complete generated configurations from the reported run
These are the exact files used. All UUID/password values are disposable lab credentials, not redactions or production credentials. Absolute certificate/key paths refer to that run. Run the script to generate fresh files and a fresh certificate/key pair; the recorded private key was intentionally deleted at cleanup and is not attached. The certificate expires after one day, so the old captured files are evidence, not a reusable deployment bundle.
server.json
{
"log": {
"level": "debug",
"timestamp": true
},
"inbounds": [
{
"type": "tuic",
"tag": "tuic-in",
"listen": "192.0.2.2",
"listen_port": 14443,
"users": [
{
"uuid": "d1f38bf5-4568-40a1-9c0f-0ed78b26267b",
"password": "c1984724332e4972b4c3a709a7a7a6b3"
}
],
"congestion_control": "bbr",
"initial_packet_size": 1200,
"disable_path_mtu_discovery": true,
"tls": {
"enabled": true,
"alpn": [
"h3"
],
"certificate_path": "/tmp/tuic-issue-work/run-final/certificate.pem",
"key_path": "/tmp/tuic-repro-key-_kj6gbye/key.pem"
}
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct"
}
]
}client.json
{
"log": {
"level": "debug",
"timestamp": true
},
"inbounds": [
{
"type": "mixed",
"listen": "127.0.0.1",
"listen_port": 18890
}
],
"outbounds": [
{
"type": "tuic",
"tag": "tuic-out",
"server": "192.0.2.2",
"server_port": 14443,
"uuid": "d1f38bf5-4568-40a1-9c0f-0ed78b26267b",
"password": "c1984724332e4972b4c3a709a7a7a6b3",
"congestion_control": "bbr",
"udp_relay_mode": "native",
"initial_packet_size": 1200,
"disable_path_mtu_discovery": true,
"tls": {
"enabled": true,
"server_name": "tuic-repro.test",
"alpn": [
"h3"
],
"certificate_path": "/tmp/tuic-issue-work/run-final/certificate.pem"
}
}
],
"route": {
"final": "tuic-out"
}
}Complete measured phases and link counters
results.json{
"environment": {
"uname": "Linux 6.18.35-x64v1-xanmod1 x86_64 GNU/Linux",
"os_release": "PRETTY_NAME=\"Debian GNU/Linux forky/sid\"\nNAME=\"Debian GNU/Linux\"\nVERSION_CODENAME=forky\nID=debian\nHOME_URL=\"https://www.debian.org/\"\nSUPPORT_URL=\"https://www.debian.org/support\"\nBUG_REPORT_URL=\"https://bugs.debian.org/\"\n",
"version": "sing-box version 1.14.1\n\nEnvironment: go1.26.8 linux/amd64\nTags: with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_usbip,with_openvpn,with_openconnect,badlinkname,tfogo_checklinkname0,with_musl\nRevision: 1ac1a339cb1223e9c70eae14c44411c75033c02d\nCGO: enabled\n",
"binary_sha256": "f766eb1d1733940b332cc65f38f899c6b5392c3f9e4a35d424da2d469510236e"
},
"link": {
"rate_mbps_each_direction": 100,
"delay_ms_each_direction": 5,
"loss_percent": 0,
"netem_limit_packets": 3000,
"quic_gso_disabled_in_lab": true
},
"cases": [
{
"round": 1,
"variant": "fresh",
"phases": [
{
"direction": "UP",
"MiB": 1,
"streams": 1Source: SagerNet/sing-box