Life of a connection
Trace one TCP connection from your app to the far server, stage by stage.
An Xray config looks declarative — inbounds, routing rules, outbounds — but debugging one requires knowing the pipeline a connection actually walks through. This page traces a single TCP connection through the source: accept, sniff, route, dial, then the steady-state copy loops.
Every connection gets its own goroutine and a context full of session state
Nothing downstream sees the listener — everything a stage needs travels in the context.Context. When internet.ListenTCP accepts a connection, tcpWorker spawns go w.callback(conn), which mints a session ID and stacks three values onto the context: a session.Inbound (source address, gateway, inbound tag, the raw conn), a session.Outbound slice (the routing target, filled in later), and a session.Content carrying the sniffing config. Then it hands off with w.proxy.Process(ctx, net.Network_TCP, conn, w.dispatcher). The dispatcher, router, and outbound all read these same context keys — session/context.go is the shared bus for the whole lifecycle.
The inbound proxy’s whole job is to produce a destination
The protocol handshake exists to answer one question: where does this connection want to go? A VMess inbound decodes the request header and immediately calls dispatcher.Dispatch(ctx, request.Destination()); Trojan, Shadowsocks, and HTTP CONNECT inbounds do the same. What comes back is one half of a transport.Link — the deceptively small type the entire core pivots on:
// Link is a utility for connecting between an inbound and an outbound proxy handler.
type Link struct {
Reader buf.Reader
Writer buf.Writer
} Dispatch builds two pipes and cross-wires them: the inbound link is {Reader: downlinkReader, Writer: uplinkWriter}, the outbound link is the mirror image {Reader: uplinkReader, Writer: downlinkWriter}. Whatever the inbound writes, the outbound reads, and vice versa — neither side ever holds a reference to the other.
Not every inbound takes this path. VLESS, SOCKS, dokodemo-door, and WireGuard call DispatchLink instead, handing the dispatcher a ready-made Link that wraps the client connection directly — no pipe pair in the middle, and sniffing plus routing run synchronously on the inbound’s own goroutine (the comment in dokodemo.go is explicit: unlike Dispatch, DispatchLink “will not return until the outbound finishes Process()”).
The sniffer borrows your first bytes, then puts them back
Sniffing is a destructive read made non-destructive by a cache. The dispatcher wraps the uplink pipe’s reader in a cachedReader and calls Cache under a 200 ms budget that each attempt’s elapsed time is deducted from — this pulls real client data out of the pipe and stashes it. The sniffer then pattern-matches HTTP, TLS, and BitTorrent against those bytes (sniffer.go; QUIC and uTP sniffers exist too, but only run for UDP flows). It gives up after two “no clue” attempts or when the budget runs out (errSniffingTimeout) — a sniffer that answers “need more data” doesn’t burn an attempt. Either way, cachedReader.ReadMultiBuffer re-serves the cached bytes first, so the outbound sees a byte-identical stream.
If a domain is sniffed and destOverride matches, destination.Address is rewritten from the IP your app dialed to the sniffed domain — this is why domain routing rules work even when the app resolved DNS itself. With routeOnly, the domain goes into ob.RouteTarget instead, influencing routing without changing what gets dialed.
Routing picks a handler, not an address
The router’s output is an outbound tag; the destination never changes here. routedDispatch checks a forced tag first, then router.PickRoute, then falls back to the default (first) outbound. One sharp edge from the source: if a rule names an outbound tag that doesn’t exist, the connection is killed rather than falling back — the code carries a DO NOT CHANGE comment because VLESS reverse proxies register their tags late. The chosen handler gets handler.Dispatch(ctx, link) with the outbound half of the link, resolves the target domain per its own strategy, and dials — freedom retries with retry.ExponentialBackoff(5, 100) before giving up.
Steady state is four copy loops meeting at two pipes
A proxied connection is not one io.Copy — it’s two independent copy pairs joined by pipes. The inbound proxy runs task.Run with two loops: client conn → uplink pipe, and downlink pipe → client conn. The outbound proxy (see freedom.Process) runs its own pair: uplink pipe → remote conn, and remote conn → downlink pipe. Each pair has its own inactivity timer, and when the uplink finishes cleanly, task.OnSuccess(..., task.Close(link.Writer)) closes the pipe so EOF propagates to the far side. (DispatchLink inbounds skip their half — the outbound’s pair reads and writes the client connection directly.)
The pipe itself is built for this: ReadMultiBuffer hands over the entire buffered MultiBuffer by ownership transfer — it sets p.data = nil and returns the old slice, no memcpy. Writers get backpressure for free: when the buffer exceeds its limit, WriteMultiBuffer blocks on writeSignal until a reader drains it, so a slow far server automatically throttles a fast client.
When either side errors, common.Interrupt on the pipe discards buffered data and unblocks both ends; a clean close drains remaining data first, then returns io.EOF. That distinction — interrupt versus close — is exactly the difference between a reset connection and a graceful shutdown reaching your app.