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.
| Field | Default | Meaning |
|---|---|---|
handshake | 60s | Read deadline before the protocol header is accepted |
connIdle | 300s | Max inactivity while both directions are open |
uplinkOnly | 1s | Grace inactivity after the downlink closed |
downlinkOnly | 1s | Grace inactivity after the uplink closed |
bufferSize | arch-dependent | Internal 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).
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.
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.
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.
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.
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.