Contents

Policy & limits

Per-user and per-system limits: timeouts, buffer sizes, connection caps.

Every connection in Xray runs under a session policy: four timers, one buffer size, and stats switches. Proxies fetch it from the policy manager with ForLevel(level) — the level being the number you put on a user in your inbound config.

Levels merge field-by-field over built-in defaults

A policy level is a partial override, not a replacement. When the manager builds a level it starts from defaultPolicy() and copies only the fields you actually set (overrideWith in app/policy/config.go checks each field for nil). Any level you never declared falls back to policy.SessionDefault() entirely.

app/policy/manager.go app/policy/config.go
FieldDefaultMeaning
handshake60sRead deadline before the protocol header is accepted
connIdle300sMax inactivity while both directions are open
uplinkOnly1sGrace inactivity after the downlink closed
downlinkOnly1sGrace inactivity after the uplink closed
bufferSizearch-dependentInternal pipe cap per connection, config unit is KiB
{
	"policy": {
		"levels": {
			"0": { "handshake": 8, "connIdle": 300 },
			"1": { "connIdle": 600, "downlinkOnly": 5, "bufferSize": 512 }
		},
		"system": { "statsInboundUplink": true, "statsInboundDownlink": true }
	}
}

bufferSize is multiplied by 1024 in infra/conf/policy.go; a negative value means unlimited. Note there is no connection-count cap anywhere in the policy manager — the only per-connection resource limit it knows is the buffer.

A connection dies in phases, each with its own timer

The four timeouts are not four independent knobs — they are one activity timer whose budget shrinks as the connection winds down. After the handshake, the proxy starts signal.CancelAfterInactivity(ctx, cancel, connIdle) and every copied chunk calls buf.UpdateActivity(timer). When one copy direction finishes, a deferred timer.SetTimeout(...) swaps the 300s budget for the 1s uplinkOnly/downlinkOnly grace (proxy/freedom/freedom.go:177,213, same pattern in the VLESS fallback path).

One ActivityTimer per connection; its inactivity budget shrinks at each phase transition

Where this bites: a backend that half-closes its side and then thinks for three seconds gets its response cut off, because after the EOF the whole connection lives on a 1-second inactivity budget. Long-idle tunnels (chat apps, MQTT) instead hit connIdle — 300 seconds with no bytes in either direction cancels the context. One escape hatch exists: XUDP sub-streams (Mux UDP sessions keyed by a global ID) are dispatched with the timeoutOnly context key set (common/mux/server.go), which makes the outbound run its copy tasks on a detached context — only their own inactivity timer can kill them. Ordinary Mux sub-streams don’t get this.

common/session/context.go proxy/freedom/freedom.go

The handshake timeout is fingerprint-aware — and read before the user’s level is known

Inbounds that authenticate users must pick a policy before they know who is connecting, so their handshake deadline always comes from level 0. VLESS calls h.policyManager.ForLevel(0) and sets the read deadline before decoding the request header (proxy/vless/inbound/inbound.go:281); Trojan, VMess and Shadowsocks do the same and only re-fetch ForLevel(request.User.Level) after authentication succeeds. The exception is the local-style inbounds — SOCKS, HTTP, dokodemo — which know their level up front: they read it from the userLevel field on the inbound settings, not from any user.

proxy/vless/inbound/inbound.go features/policy/policy.go
Don't

Raise handshake on your users' level

"1": { "handshake": 30 } does nothing: by the time the user’s level is known, the handshake deadline has already been applied — from level 0.

Do

Put handshake (and fallback timers) on level 0

Level 0 governs everything that happens before authentication, including the connIdle and buffer policy used for the VLESS fallback connection.

bufferSize throttles exactly one thing: the internal pipe

The buffer policy has a single consumer — Xray’s in-memory pipes, chiefly the pair the dispatcher lays between inbound and outbound. pipe.OptionsFromContext reads the policy and applies WithSizeLimit(bp.PerConnection); when the pipe holds more than the limit, writers block until the reader drains it. bufferSize: 0 therefore means lock-step forwarding: a write is admitted only while the pipe is empty (the full check is curSize > limit), so at most one multi-buffer sits in memory per direction. It does not resize socket buffers, and splice (which bypasses the pipe entirely) is governed by CanSpliceCopy, not by this policy.

The default is chosen per CPU architecture in features/policy/policy.go: arm, mips and mipsle get 0 (routers forward in lock-step), arm64, mips64 and mips64le get 4 KiB, everything else 512 KiB.

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

Stats flags do nothing without an email

Per-user counters require both the policy flag and a non-empty email on the user. The dispatcher only registers user>>><email>>>>traffic>>>uplink counters when len(user.Email) > 0 and the user’s level sets statsUserUplink (app/dispatcher/default.go:162). The system policy is stats-only — SystemPolicy.ToCorePolicy() carries nothing but the four inbound/outbound counter switches.

app/dispatcher/default.go