Contents

Buffers & pipes

The zero-copy buffer pool and the pipe that connects inbound to outbound.

A proxy is a byte pump: every payload enters on one socket and leaves on another. At 10K connections, allocating a fresh slice per read would drown the Go garbage collector — so Xray moves all traffic through pooled buffers whose ownership is handed from stage to stage, and the hand-off point between inbound and outbound is a pipe with a byte budget.

Every payload rides in a pooled 8 KB buffer

common/buf/buffer.go common/bytespool/pool.go

A buf.Buffer is never garbage — it is borrowed and returned. buf.New() pulls an 8192-byte slice (buf.Size) from a sync.Pool, and Release() puts it back; the pool lives in common/bytespool, which keeps four tiers of slices (2 KB, 8 KB, 32 KB, 128 KB) so odd-sized callers via NewWithSize reuse memory too.

The trick that makes protocol parsing cheap is the pair of cursors inside:

type Buffer struct {
	v         []byte // pooled 8K backing array
	start     int32  // read cursor
	end       int32  // write cursor
	ownership ownership
	UDP       *net.Destination // set for UDP packets
}

Stripping a VLESS or Shadowsocks header is just b.Advance(n) — the start cursor moves forward, no bytes are copied, and the same backing array continues down the chain. An ownership tag decides what Release() does: managed buffers return to the 8 K pool, bytespools ones (from NewWithSize) go back to their size tier, and unmanaged ones (FromBytes, wrapping someone else’s slice) are never recycled.

A MultiBuffer moves ownership, not bytes

common/buf/multi_buffer.go common/buf/readv_reader.go

MultiBuffer is just []*Buffer — and every operation on it transfers pointers, not payload. SplitFirst pops the head and nils the slot; MergeMulti appends src to dest then nils every src entry so exactly one owner can ever Release() a buffer. Getting this wrong is a use-after-free into the pool, which is why the API returns “the new address of” the MultiBuffer instead of mutating in place.

Reading from a socket fills MultiBuffers in batch: NewReader wraps any connection that exposes a raw fd in a ReadVReader (every platform except wasm and OpenBSD; readv(2) on Unix, WSARecv on Windows) that fills up to eight 8 K buffers — 64 KB — in one kernel round-trip. Its allocStrategy starts at a single buffer, doubles the batch each time a read comes back full, and shrinks back to whatever the last read actually used.

The relay loop pumps MultiBuffers — or gets out of the way entirely

common/buf/copy.go proxy/proxy.go

buf.Copy never touches payload bytes; it only moves ownership and fires hooks. The loop is four lines: ReadMultiBuffer, run the onData handlers (activity-timer updates, byte counters — that’s what UpdateActivity, CountSize, and AddToStatCounter register), WriteMultiBuffer, repeat. Errors are wrapped as readError/writeError so callers can tell which side died.

On Linux and Android, when the destination side is a raw *net.TCPConn and the protocol allows it (CanSpliceCopy == 1 on the inbound and every outbound in the chain — a value of 3 on any of them rules it out for good), proxy.CopyRawConnIfExist abandons buffers altogether: it calls tc.ReadFrom(readerConn) on the destination *net.TCPConn, which the Go runtime lowers to the splice(2) syscall — bytes travel socket-to-socket inside the kernel and never enter the Go heap. This is the fast path behind XTLS Vision’s “direct copy” mode.

The pipe backpressures with a byte budget, not a queue

transport/pipe/impl.go transport/pipe/pipe.go

Inbound and outbound never share a socket — the dispatcher connects them with two in-memory pipes (uplink and downlink), each capped at your policy’s bufferSize. A pipe is one mutex, one buf.MultiBuffer, and two condition signals. WriteMultiBuffer appends to the shared MultiBuffer; ReadMultiBuffer steals the entire accumulated MultiBuffer in one move (data := p.data; p.data = nil) — the reader never copies and never takes a partial batch.

Uplink path: the pipe decouples the two handlers; when buffered bytes exceed the limit, the writing side parks until the reader drains.

Backpressure is a byte count: getState rejects writes with errBufferFull while p.data.Len() > limit, and the writer then blocks on writeSignal until a read drains the pipe. The check runs before a write is admitted, so one oversized WriteMultiBuffer can overshoot the limit — it is a soft cap that throttles the next write, not a hard truncation. Pipes created with DiscardOverflow() drop the write instead of blocking — the UDP inbound worker builds its per-source pipes this way, with a 16 KB budget, because dropping a datagram beats stalling the socket loop.

The limit comes straight from the policy page’s bufferSize, via pipe.OptionsFromContextpolicy.BufferPolicyFromContext. Defaults are per-architecture: 0 on 32-bit arm/mips, 4 KB on arm64/mips64, and 512 KB everywhere else:

{
	"policy": {
		"levels": {
			"0": { "bufferSize": 512 }
		}
	}
}

bufferSize is in kilobytes, and its zero value is a trap:

Don't

Set bufferSize: 0 to 'save memory'

A 0 KB budget forces a context switch per write on every connection — throughput collapses while memory savings are trivial, because the buffers themselves are pooled anyway.

Do

Lower bufferSize only on tiny devices

On a 128 MB router, "bufferSize": 4 bounds each connection to one buffer in flight — matching what Xray already defaults to on arm64. Leave the 512 KB default everywhere else.

Closing behavior is asymmetric by design: Close() lets the reader drain what is buffered and then return io.EOF, while Interrupt() (fired when a connection is torn down abnormally) releases the buffered MultiBuffer immediately — in-flight data is dropped, not flushed.