Refining TCP Proxy Connection Handling in Go
The Problem with Naive Proxies
Many basic implementations of TCP proxies handle data relaying by creating two goroutines to copy data between the client and the backend using io.Copy. A common pitfall arises when one of these copy operations finishes (usually due to an EOF). The standard reaction is to immediately close both connections entirely. While this works for simple request-response cycles, it fails to support half-closed connections, violating the principle that a proxy should act as a transparent relay without interfering with the connecsion state.
Consider a scenario where a client sends a request and then closes its write side using shutdown(SHUT_WR), expecting to receive a streaming response that takes several seconds. If the proxy detects the EOF on the read side and immediately closes both sockets, the backend's attempt to write the response will fail. The proxy should instead forward the "close write" signal to the backend while keeping the read direction open for the resposne.
Understanding TCP Connection Closure
To correctly implement this, we must distinguish between closing a connection for reading and closing it for writing.
- Full Close: Terminates both directions and releases the file descriptor.
- Half-Close (Shutdown): Allows an application to signal that it has finished sending data (FIN) while still being able to receive data.
In Go, this distinction is handled by the CloseRead() and CloseWrite() methods available on *net.TCPConn. These methods map to the underlying system calls, allowing the proxy to propagate the correct signals.
Correct Implementation Strategy
The goal is to decouple the two directions of the data stream. The logic should be:
- When
io.Copyreturns (indicating the remote peer sent a FIN), we should stop reading from that socket. - Crucially, we should then call
CloseWrite()on the outbound socket to forward the EOF signal to the other peer. - The other half of the proxy (the reverse direction) remains active until its corresponding coppy operation finishes.
This ensures that if the client finishes sending, the server knows the client is done writing, but the server can still stream data back to the client until the server decides to close its connection.
Refactored Code
Below is a revised implementation of a TCP relay function. Instead of brute-forcing a close on error or EOF, it utilizes CloseRead and CloseWrite to maintain protocol compliance.
func relayData(source, destination net.Conn) {
var wg sync.WaitGroup
wg.Add(2)
// Stream data from Source to Destination
go func() {
defer wg.Done()
// Copy blocks until EOF or error
io.Copy(destination, source)
// Source is done sending, stop reading from it
source.CloseRead()
// Signal Destination that we are done writing
destination.CloseWrite()
}()
// Stream data from Destination to Source
go func() {
defer wg.Done()
io.Copy(source, destination)
// Destination is done sending, stop reading from it
destination.CloseRead()
// Signal Source that we are done writing
source.CloseWrite()
}()
// Wait for both directional streams to finish
wg.Wait()
// Cleanup resources
source.Close()
destination.Close()
}
In this function, io.Copy handles the data transfer. Once it returns, we know the input side has been shut down by the remote peer. By calling CloseWrite() on the output connection, we effectively pass the "FIN" packet through the proxy. This approach preserves the half-close semantics necessary for long-lived or streaming connections.