From 61041ebe7d4fc2186658efa9461d1e7a9b9dfcac Mon Sep 17 00:00:00 2001 From: Vladimir Dubrovin <3proxy@3proxy.ru> Date: Wed, 14 Oct 2026 19:02:23 +0300 Subject: [PATCH] Make the automatic splice() mode's heuristics a service option -s[N]:MIN,STREAK,SMALL sets where the automatic mode splices, as -g does for grace delay: a direction is spliced after STREAK reads in a row of MIN bytes or more (SMALL from a TLS socket), copied again after more than STREAK splices in a row of less than SMALL, and a body of known length is spliced if MIN or more of it is left. The defaults, 16384,2,4096, are what was built in; a value left out or 0 keeps its default. The man page describes it, and its HTML copy, which still said splice() was not built by default, now has the current text. A test case relays answers of every size, chunked ones and a 3 MB upload through every mode, two of them with heuristics set, and compares them byte for byte. Co-Authored-By: Claude Opus 5.5 --- doc/html/man5/3proxy.cfg.5.html | 33 +++++++-- man/3proxy.cfg.5 | 17 +++-- src/proxy.h | 5 ++ src/proxymain.c | 23 +++++- src/sockmap.c | 38 +++++----- src/structures.h | 2 + tests/cases/splice.py | 121 ++++++++++++++++++++++++++++++++ 7 files changed, 207 insertions(+), 32 deletions(-) create mode 100644 tests/cases/splice.py diff --git a/doc/html/man5/3proxy.cfg.5.html b/doc/html/man5/3proxy.cfg.5.html index c6955f6..7cd5d7a 100644 --- a/doc/html/man5/3proxy.cfg.5.html +++ b/doc/html/man5/3proxy.cfg.5.html @@ -218,12 +218,33 @@ second. Useful to minimize polling
-s

(for admin) secure, allow only secure operations, currently only traffic counters view without ability to reset.
-(for TCP services, Linux) enable splice(). splice() is not -built by default and is disabled even when built, because -current Linux does not implement SPLICE_F_MOVE, so no real -zero-copy takes place and the read/write path is faster for -most traffic. Rebuild with -DWITHSPLICE to make -s -available, -s0 disables it explicitly.
+(for TCP services, Linux) use splice() to pass data between +the client and the server without copying it through 3proxy. +-s alone splices both directions where it pays: a body +of known length if at least 16K of it is left to read, a +stream once it carries large reads, back to copying when they +turn small. -s1 always splices what the server sends +to the client (downloads), -s2 what the client sends +(uploads), -s3 both; adding 4 (-s5, -s6, +-s7, the last the same as -s, and the default) +splices those directions only where it pays. -s0 turns +it off. Where it pays can be tuned with +:MIN,STREAK,SMALL +after the mode, e.g. -s:16384,2,4096 (the default) or +-s6:65536,3,8192: a direction is spliced after +STREAK reads in a row of MIN bytes or more +(SMALL from a TLS socket, which hands out a record at a +time) and copied again after more than STREAK splices +in a row of less than SMALL; a body of known length is +spliced if at least MIN of it is left to read. splice() +suits large transfers: they go faster and use less CPU. A +spliced direction needs a pipe (two file descriptors), made +when first needed and kept for the connection, and more kernel +memory. A service with data filters does not splice, so that +the filters see all of the data, nor does one ending or +beginning TLS, unless the kernel does TLS for it both ways +(ssl_ktls). Available in builds with WITHSPLICE, the +default on Linux.
-C

(for TCP services) a connection closed by any of the sides terminates the session, buffered data is delivered before diff --git a/man/3proxy.cfg.5 b/man/3proxy.cfg.5 index b0bddbd..a3f0285 100644 --- a/man/3proxy.cfg.5 +++ b/man/3proxy.cfg.5 @@ -209,11 +209,18 @@ once it carries large reads, back to copying when they turn small. \fB-s1\fR alw splices what the server sends to the client (downloads), \fB-s2\fR what the client sends (uploads), \fB-s3\fR both; adding 4 (\fB-s5\fR, \fB-s6\fR, \fB-s7\fR, the last the same as \fB-s\fR, and the default) splices those directions only where it pays. \fB-s0\fR turns it -off. splice() suits large transfers: they go faster and use less CPU; small -requests go slower with it. A spliced direction needs a pipe (two file -descriptors), made when first needed and kept for the connection, and more -kernel memory. A service with data filters does not splice, so that the filters -see all of the data. Available in builds with WITHSPLICE, the default on Linux. +off. Where it pays can be tuned with \fB:\fIMIN\fB,\fISTREAK\fB,\fISMALL\fR after the +mode, e.g. \fB-s:16384,2,4096\fR (the default) or \fB-s6:65536,3,8192\fR: a direction is +spliced after \fISTREAK\fR reads in a row of \fIMIN\fR bytes or more (\fISMALL\fR from a +TLS socket, which hands out a record at a time) and copied again after more +than \fISTREAK\fR splices in a row of less than \fISMALL\fR; a body of known length is +spliced if at least \fIMIN\fR of it is left to read. splice() suits large +transfers: they go faster and use less CPU. A spliced direction needs a pipe +(two file descriptors), made when first needed and kept for the connection, +and more kernel memory. A service with data filters does not splice, so that +the filters see all of the data, nor does one ending or beginning TLS, unless +the kernel does TLS for it both ways (\fBssl_ktls\fR). Available in builds with +WITHSPLICE, the default on Linux. .br .B -C (for TCP services) a connection closed by any of the sides terminates the diff --git a/src/proxy.h b/src/proxy.h index bcfd602..710604a 100644 --- a/src/proxy.h +++ b/src/proxy.h @@ -235,6 +235,11 @@ extern struct extparam confboot; #define SPLICE_DOWNLOAD 1 #define SPLICE_UPLOAD 2 #define SPLICE_AUTO 4 +/* where SPLICE_AUTO splices, by default (-s[N]:MIN,STREAK,SMALL): see + sockmap.c */ +#define SPLICE_MIN 16384 +#define SPLICE_STREAK 2 +#define SPLICE_SMALL 4096 int sockmap(struct clientparam * param, int timeo, int usesplice); int udpsockmap(struct clientparam * param, int timeo); int udpbind(struct clientparam * param); diff --git a/src/proxymain.c b/src/proxymain.c index 0251dcf..1db0220 100644 --- a/src/proxymain.c +++ b/src/proxymain.c @@ -436,6 +436,10 @@ static int srvprepare(struct srvstart *s, int argc, char **argv){ " -s[N] Use splice(): -s1 server to client, -s2 client to server, -s3 both\n" " directions; -s (-s7, the default) both, -s5 or -s6 one, only where it\n" " pays (large transfers); -s0 never. Not with data filters\n" + " :MIN,STREAK,SMALL tune where it pays (-s:16384,2,4096): splice after\n" + " STREAK reads in a row of MIN bytes or more (SMALL from a TLS socket),\n" + " copy again after more than STREAK splices of less than SMALL; a body of\n" + " known length is spliced if MIN or more of it is left\n" " -C connection closed by any of the sides terminates the session, by default\n" " the session is kept until both sides close it (TCP half-close)\n" " -U(0-3) (for socks) what to do when the destination changes within an UDP\n" @@ -716,10 +720,22 @@ static int srvprepare(struct srvstart *s, int argc, char **argv){ #endif srv->s_option = 1 + atoi(argv[i]+2); #ifdef WITHSPLICE - else - srv->usesplice = (*(argv[i]+2)? atoi(argv[i]+2) : + else { + /* -s[N][:MIN,STREAK,SMALL] */ + char *heur = strchr(argv[i]+2, ':'); + + srv->usesplice = ((argv[i][2] && argv[i][2] != ':')? atoi(argv[i]+2) : SPLICE_DOWNLOAD|SPLICE_UPLOAD|SPLICE_AUTO) & (SPLICE_DOWNLOAD|SPLICE_UPLOAD|SPLICE_AUTO); + if(heur){ + int min = 0, streak = 0, small = 0; + + sscanf(heur+1, "%d,%d,%d", &min, &streak, &small); + if(min > 0) srv->splicemin = min; + if(streak > 0) srv->splicestreak = streak; + if(small > 0) srv->splicesmall = small; + } + } #endif break; case 'o': @@ -1498,6 +1514,9 @@ void srvinit(struct srvparam * srv, struct clientparam *param){ #ifdef WITHSPLICE /* where it pays: large transfers, -s0 to never */ srv->usesplice = SPLICE_DOWNLOAD|SPLICE_UPLOAD|SPLICE_AUTO; + srv->splicemin = SPLICE_MIN; + srv->splicestreak = SPLICE_STREAK; + srv->splicesmall = SPLICE_SMALL; #endif srv->halfclose = 1; srv->udpauth = 3; diff --git a/src/sockmap.c b/src/sockmap.c index 390ecf3..c5e297c 100644 --- a/src/sockmap.c +++ b/src/sockmap.c @@ -44,18 +44,16 @@ ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t le /* With SPLICE_AUTO, when a direction allowed to splice actually does (without it, an allowed direction always does). - A body of known length is spliced if at least SPLICE_MIN of it is left + A body of known length is spliced if at least splicemin of it is left to read once what is already buffered is counted: a smaller one, or one read whole with its headers, is cheaper to copy. A stream of unknown length starts copied and is spliced after - SPLICE_STREAK reads in a row of at least SPLICE_MIN each, and goes back to - copying after SPLICE_STREAK+1 splices in a row of less than SPLICE_SMALL. */ -#define SPLICE_MIN 16384 -#define SPLICE_SMALL 4096 -#define SPLICE_STREAK 2 -/* a large read from a TLS socket: TLS hands out a record at a time, at most - 16K and often less */ -#define SPLICE_TLSMIN SPLICE_SMALL + splicestreak reads in a row of at least splicemin each, and goes back to + copying after splicestreak+1 splices in a row of less than splicesmall. + From a TLS socket a read of splicesmall counts as large: TLS hands out a + record at a time, at most 16K and often less. The service sets the three + (-s[N]:MIN,STREAK,SMALL), SPLICE_MIN, SPLICE_STREAK and SPLICE_SMALL by + default. */ #endif @@ -149,6 +147,8 @@ int sockmap(struct clientparam * param, int timeo, int usesplice){ into the buffer first (drain), once the pipe is empty, so as to keep the order; tail: the last read left some, the next only collects it */ int draincli = 0, drainsrv = 0, tailcli = 0, tailsrv = 0; + int splicemin = param->srv->splicemin, splicestreak = param->srv->splicestreak, + splicesmall = param->srv->splicesmall; if((!param->nolongdatfilter && (param->ndatfilterscli > 0 || param->ndatfilterssrv)) || param->nosplice) usesplice = 0; allowcli = (usesplice & SPLICE_UPLOAD) != 0; @@ -190,8 +190,8 @@ int sockmap(struct clientparam * param, int timeo, int usesplice){ if(inclientbuf >= fromclient) FROMCLIENT = 0; #ifdef WITHSPLICE /* a body of known length large enough is spliced from the start */ - if(autosplice && allowsrv && param->waitserver64 && fromserver > inserverbuf && fromserver - inserverbuf >= SPLICE_MIN) splicesrv = 1; - if(autosplice && allowcli && param->waitclient64 && fromclient > inclientbuf && fromclient - inclientbuf >= SPLICE_MIN) splicecli = 1; + if(autosplice && allowsrv && param->waitserver64 && fromserver > inserverbuf && fromserver - inserverbuf >= (uint64_t)splicemin) splicesrv = 1; + if(autosplice && allowcli && param->waitclient64 && fromclient > inclientbuf && fromclient - inclientbuf >= (uint64_t)splicemin) splicecli = 1; #endif if(param->srvinbuf == param->srvoffset) param->srvinbuf =param->srvoffset = 0; if(param->cliinbuf == param->clioffset) param->cliinbuf =param->clioffset = 0; @@ -504,8 +504,8 @@ log("done read from client to pipe"); graceclitraf += res; gracesrvnum = gracesrvtraf = 0; if(autosplice && !param->waitclient64){ - if(res >= SPLICE_SMALL) streakcli = 0; - else if(++streakcli > SPLICE_STREAK) wantbufcli = 1; + if(res >= splicesmall) streakcli = 0; + else if(++streakcli > splicestreak) wantbufcli = 1; } inclientpipe += res; if(inclientpipe >= MAXSPLICE) TOCLIENTPIPE = 0; @@ -567,8 +567,8 @@ log("done read from server to pipe\n"); gracesrvtraf += res; graceclinum = graceclitraf = 0; if(autosplice && !param->waitserver64){ - if(res >= SPLICE_SMALL) streaksrv = 0; - else if(++streaksrv > SPLICE_STREAK) wantbufsrv = 1; + if(res >= splicesmall) streaksrv = 0; + else if(++streaksrv > splicestreak) wantbufsrv = 1; } param->nreads++; param->statssrv64 += res; @@ -620,8 +620,8 @@ log("done read from client to buf"); else if(autosplice && allowcli && !param->waitclient64){ int tls = splicetls? splicetls(param, param->clisock) : 0; - if(res >= (tls? SPLICE_TLSMIN : SPLICE_MIN)){ - if(++streakcli >= SPLICE_STREAK){ + if(res >= (tls? splicesmall : splicemin)){ + if(++streakcli >= splicestreak){ /* a stream: splice from now on, after what is buffered has been sent */ splicecli = TOCLIENTPIPE = FROMCLIENTPIPE = 1; @@ -675,8 +675,8 @@ log("done read from server to buf"); else if(autosplice && allowsrv && !param->waitserver64){ int tls = splicetls? splicetls(param, param->remsock) : 0; - if(res >= (tls? SPLICE_TLSMIN : SPLICE_MIN)){ - if(++streaksrv >= SPLICE_STREAK){ + if(res >= (tls? splicesmall : splicemin)){ + if(++streaksrv >= splicestreak){ splicesrv = TOSERVERPIPE = FROMSERVERPIPE = 1; streaksrv = 0; } diff --git a/src/structures.h b/src/structures.h index 6d1f2d6..17e82b0 100644 --- a/src/structures.h +++ b/src/structures.h @@ -653,6 +653,8 @@ struct srvparam { unsigned char fakeip6[16]; #ifdef WITHSPLICE int usesplice; + /* the automatic mode's heuristics, -s[N]:MIN,STREAK,SMALL */ + int splicemin, splicestreak, splicesmall; #endif int halfclose; int udpauth; diff --git a/tests/cases/splice.py b/tests/cases/splice.py new file mode 100644 index 0000000..2f90e3b --- /dev/null +++ b/tests/cases/splice.py @@ -0,0 +1,121 @@ +"""splice(): every mode relays what it is given unchanged, small and large, +both ways, the automatic mode's heuristics set or not (-s[N]:MIN,STREAK,SMALL). + +Where splice() is not built (anything but Linux) -s is accepted and ignored, +so the same checks then cover copying. +""" + +import os +import socket +import threading + +MODES = ["-s0", "-s", "-s3", "-s:65536,3,8192", "-s2:32768,2,4096"] + + +def echo_origin(listener): + """Sends back whatever each connection sends, until it shuts down its + side.""" + def serve(c): + try: + while True: + b = c.recv(65536) + if not b: + break + c.sendall(b) + c.shutdown(socket.SHUT_WR) + except OSError: + pass + c.close() + + while True: + try: + c, _ = listener.accept() + except OSError: + return + threading.Thread(target=serve, args=(c,), daemon=True).start() + + +def echo_through(port, data): + """data sent through the mapped port while what comes back is read; the + sending side is shut down once all is sent.""" + s = socket.create_connection(("127.0.0.1", port), timeout=20) + got = [] + + def reader(): + while True: + try: + b = s.recv(65536) + except OSError: + return + if not b: + return + got.append(b) + + th = threading.Thread(target=reader, daemon=True) + th.start() + s.sendall(data) + s.shutdown(socket.SHUT_WR) + th.join(30) + s.close() + return b"".join(got) + + +def run(t): + origin = t.free_port() + listener = socket.socket() + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(64) + echo = listener.getsockname()[1] + threading.Thread(target=echo_origin, args=(listener,), daemon=True).start() + + proxies, mapped = [], [] + conf = f""" + log + auth iponly + allow * + http data * /data + httpsrv -p{origin} + + flush + auth iponly + allow * +""" + for mode in MODES: + p, m = t.free_port(), t.free_port() + proxies.append(p) + mapped.append(m) + conf += f" proxy {mode} -p{p}\n tcppm {mode} {m} 127.0.0.1 {echo}\n" + t.start("splice", conf, ports=[origin] + proxies + mapped) + + direct = {size: t.http(f"http://127.0.0.1:{origin}/data?size={size}").body + for size in (1000, 100000, 5000000)} + chunked = t.http(f"http://127.0.0.1:{origin}/data?size=1000000&chunked=1").body + up = os.urandom(3000000) + + try: + for mode, p, m in zip(MODES, proxies, mapped): + proxy = f"127.0.0.1:{p}" + for size, body in direct.items(): + got = t.http(f"http://127.0.0.1:{origin}/data?size={size}", proxy=proxy) + if got.body == body: + t.ok(f"{mode}: a {size} byte answer arrives unchanged") + else: + t.fail(f"{mode}: a {size} byte answer arrives unchanged", + f"{size} bytes", f"{got.length} different bytes") + got = t.http(f"http://127.0.0.1:{origin}/data?size=1000000&chunked=1", proxy=proxy) + if got.body == chunked: + t.ok(f"{mode}: a chunked answer arrives unchanged") + else: + t.fail(f"{mode}: a chunked answer arrives unchanged", + f"{len(chunked)} bytes", f"{got.length} different bytes") + back = echo_through(m, up) + if back == up: + t.ok(f"{mode}: 3 MB sent through tcppm come back unchanged") + else: + t.fail(f"{mode}: 3 MB sent through tcppm come back unchanged", + f"{len(up)} bytes", f"{len(back)} bytes, different") + small = echo_through(m, b"hello") + t.eq(b"hello", small, f"{mode}: a few bytes through tcppm come back") + finally: + listener.close()