Repository navigation
feat(relay): splice(2) on Linux for the none cipher and bypassed connections - #2199
Open
pylaterreur wants to merge 2 commits into
Open
pylaterreur wants to merge 2 commits into
pylaterreur wants to merge 2 commits into
Conversation
With the none cipher the bytes of the stream are the bytes of the application, but both sslocal and ssserver still read them into a userspace buffer and write them out again. Move them through a pipe in the kernel instead, as Go's net package does for io.Copy between TCP connections. New module relay::tcprelay::splice, Linux and Android only: - copy_bidirectional_splice(a, b) has the semantics of tokio's copy_bidirectional: EOF on one side shuts the other down for writing (a socket that the peer has reset counts as shut down, as in tokio), the opposite direction keeps going. - Each direction owns a pipe of 256 KiB when the limits allow, created when its source first becomes readable. Pipes of 1 MiB, as Go uses, save little CPU, hold four times as much while the destination is slow, and use up the pipe budget of an unprivileged user (64 MiB by default) at 32 connections instead of 128. Readiness goes through TcpStream::try_io, so EAGAIN clears tokio's readiness. - If the kernel refuses to splice (EINVAL, ENOSYS, EOPNOTSUPP) before the first splice into the destination succeeded, the direction goes on with a userspace copy. The bytes already in the pipe are written out first, so nothing is lost. A pipe smaller than 64 KiB (the per-user pipe page budget is used up) is not worth splicing and is copied through too. The copy closes the pipe first. - splice(2) from TCP stops in front of urgent data (MSG_OOB), and says EAGAIN there until something reads past it, which would stall the direction. When it says EAGAIN or EOF on a TCP socket, a one-byte MSG_PEEK checks whether recv(2) would return data. If it would and a second splice(2) still moves nothing (so the bytes didn't just come in), recv(2) reads past the urgent byte, which it drops, as the userspace copy does. - SPLICE_F_MORE is not set: it corks the last segment of a transfer. - splice(2) can't take MSG_NOSIGNAL, so a peer that is gone raises SIGPIPE. The Rust runtime ignores it in executables; where it would end the process (a host written in another language), the relay copies through userspace. - A SpliceMonitor reports the bytes read from and written to the socket `a`, which is how the flow statistics stay right: the monitored stream wrapper is bypassed. The relays use it only when it is safe: the none cipher, the address header already sent (client) or read (server), the outbound socket a plain tokio TcpStream (not a proxy chain, not TCP Fast Open), and the other end a plain TCP socket. ProxyClientStream and ProxyServerStream gain passthrough_ref() for that. Everything else takes the existing path, as do other platforms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwUvhmzgfkiZ6vo4uGyesN
…ections A connection that the ACL sends around the proxy is a relay between two plain TCP sockets whatever the cipher, so it can use the splice(2) path of the previous commit (without flow statistics, as before). Streams that are not a plain tokio TcpStream (TCP Fast Open, the TUN stack, hyper's upgraded connections) keep the userspace copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwUvhmzgfkiZ6vo4uGyesN
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
With the
nonemethod, a proxied TCP connection carries the application's bytes unchanged after the address header, and so does every connection that the ACL sends around the proxy. sslocal and ssserver still read these bytes into a userspace buffer and write them out again. On Linux and Android, this PR moves them from one socket to the other through a pipe withsplice(2)instead, so they never leave the kernel. Go'snetpackage does the same forio.Copybetween TCP connections, which is how shadowsocks-go relaysnone.Nothing changes on the wire, for proxied connections with other methods, or on other systems.
When the relay splices
none(first commit): after the first packet (address and first payload) is out, if the connection to the server is a plainTcpStream(not an outbound proxy chain, not TCP Fast Open) and the client side is one too (tunnel, SOCKS, redir). The TUN stack and hyper's upgraded HTTP connections keep the current path.none(first commit): after the handshake has read the address, which it does byte by byte and so leaves everything behind it in the socket, if the connection to the target is direct.TcpStreams. These are plain TCP relays today, through tokio'scopy_bidirectionalwith 8 KiB buffers.Everything else takes the existing path.
Numbers
sslocal (tunnel) → ssserver → target, 2 GB per transfer, every transfer checked byte for byte, on loopback. The leg between sslocal and ssserver was plain TCP, then MPTCP (
--tcp-multipath). Linux 6.17 on a Ryzen 9 5900X (8-vCPU VM), 5 rounds with the builds alternating within each round, medians. The machine was shared with other VMs (1-minute load 1.6 to 7.3, median 2.9), so throughput varies a lot between runs; the CPU per GB is steadier. CPU is the seconds per GB of each process, lower is better. Methodnone:On 6.17, splicing out of an MPTCP socket still copies inside the kernel, so the side that reads from MPTCP gains less (ssserver on upload, sslocal on download).
Compared with the 64 KiB buffers of #2198 alone (the userspace copy at its best), splice takes another 59 % (upload) and 64 % (download) off sslocal's CPU per GB, and 52 % and 54 % off ssserver's, with plain TCP.
Bypassed connections, measured with an sslocal tunnel that has no server configured, so it relays directly to the target through the same
establish_tcp_tunnel_bypassed:2022-blake3-aes-256-gcm, which keeps the existing path, measured the same within the noise: the medians of the per-round ratios to master were 0.88 to 1.06 for the CPU of either process.How it works
crates/shadowsocks/src/relay/tcprelay/splice.rs,#[cfg(any(target_os = "linux", target_os = "android"))], no new dependency:copy_bidirectional_splice(a, b)has the semantics oftokio::io::copy_bidirectional. EOF on one side shuts the other down for writing, and the opposite direction keeps going. The result is the byte count of each direction. A socket whose peer has reset counts as shut down (ENOTCONN), as in tokio.pipe2(O_NONBLOCK | O_CLOEXEC), resized withF_SETPIPE_SZon a best-effort basis (see the trade-offs below). Readiness goes throughreadable()/writable()andTcpStream::try_io, so anEAGAINfromspliceclears tokio's readiness.splicefails withEINVAL,ENOSYSorEOPNOTSUPPbefore a direction's first successful splice into the destination, that direction carries on with a userspace copy. The bytes already in the pipe are written out first, and a failedspliceconsumes nothing, so nothing is lost. The pipe is closed then. A failedpipe2, or a pipe smaller than 64 KiB (what the kernel gives an unprivileged user over their pipe budget), means copying as well.splicefrom TCP stops in front of urgent (MSG_OOB) data and keeps returningEAGAIN(or 0 once the FIN is in) for as long as nothing reads past it, which would stall the direction. When it returnsEAGAINor 0 on a TCP socket, a one-byterecv(MSG_PEEK)checks whetherrecvwould return data. If so, and a secondsplicestill moves nothing (so the bytes didn't simply arrive in between),recvreads past the urgent byte. It drops that byte, as the userspace copy does. In a C relay built the same way, the peek cost nothing measurable: 0.159 vs 0.160 CPU s/GB, 30 runs each.SPLICE_F_MOREis not set: it would cork the last segment of a transfer.SIGPIPE.splicecan't takeMSG_NOSIGNAL, so splicing into a socket whose peer is gone raisesSIGPIPE. Rust executables ignore it. If the library runs in a process whereSIGPIPEhas its default action (checked withsigaction), the relay copies through userspace instead of splicing.MonProxyStream, so they are reported to the connection'sFlowStatthrough a smallSpliceMonitortrait. Bypassed connections have no flow statistics, as before.ProxyClientStream::passthrough_ref()andProxyServerStream::passthrough_ref()return the inner stream only fornoneonce the handshake is done. In shadowsocks-service, aSpliceIotrait exposes the socket behind a stream; its default is no socket, which is always correct.TcpStream::as_tokio()returnsNonefor TCP Fast Open streams.Trade-offs
File descriptors. A spliced connection has two pipes, so four more file descriptors. A failed
pipe2(EMFILE, for example) means copying, as today.Pipe size: 256 KiB per direction, on a best-effort basis. A pipe holds what the destination hasn't taken yet, so this is the most a direction keeps in the kernel while the destination is slower than the source. Unprivileged processes, such as the units in
debian/(DynamicUser=yes), get 64 MiB of pipes in all by default (fs.pipe-user-pages-soft). Past that, new pipes get two pages, and those connections copy as today. That allows 128 connections spliced both ways at 256 KiB, against 32 at the 1 MiB that Go uses. Medians of the per-round ratios to 1 MiB, from the same runs:A pipe slot holds one fragment of the socket's data. On loopback the fragments are large, so these runs favour small pipes. Behind a real NIC a fragment is typically about one MTU of payload, so 64 slots would still move about 90 KiB per
splice, which is more than onereadof the 64 KiB userspace copy. The size is one constant (PIPE_CAPACITY) if you'd rather have another default or an option.Memory. While a connection is spliced, its userspace relay buffers aren't allocated. When the source is TCP, the pages in a pipe are the ones the socket received, not copies.
Tests
splice.rs, against real loopback sockets:EAGAINon the write side);splice, including bytes left in the pipe; the same errno after a success is an error; the pipe is released on fallback;EAGAINand the peek;ENOTCONNon shutdown after a reset, and a reset during the transfer;SIGPIPEwith its default action, checked in a forked child.tests/none_splice.rs, sslocal (tunnel) + ssserver in-process. They check every byte, check which path carried the bytes (from the relay's trace log), and check sslocal's flow statistics. The cases: 64 MiB each way with half-close; TCP Fast Open (sslocal copies, ssserver splices);aes-256-gcm(never spliced); MPTCP; full duplex; an idle client; a server that speaks first; payload sent in the same segment as the header; 64 parallel connections; a SOCKS5 connection bypassed by the ACL.cargo testof the workspace with default features (full) and with--no-default-features, and of both crates with default and no features;cargo buildofcrates/shadowsockswith all its cipher features; clippy with CI's flags, with and without--all-targets. No new warnings. Not run here: thefull-extratests and the all-featurescargo build(no disk space left), a build with the 1.91 MSRV (clippy'sincompatible_msrv, which readsmsrv = "1.91"fromclippy.toml, reports nothing), macOS and Windows.cfgconditions inverted, on the revision before the pipe size changed, which is the only difference. Android builds the same module (net/sys/unix/linux), and libc 0.2.190 defines every function and constant it uses for Android. I couldn't runcargo check --target aarch64-linux-androidhere: blake3's build script needs the NDK's clang.🤖 Generated with Claude Code
https://claude.ai/code/session_01GwUvhmzgfkiZ6vo4uGyesN