gw-01 step 01 — A TCP proxy with backpressure and half-close
Goal
Build the smallest correct L4 TCP proxy: accept a client connection, dial an origin, copy bytes both ways with backpressure, and handle half-close so a FIN in one direction doesn't kill the other. This is the core of every proxy in the rest of the phase.
Code
src/go/proxy.go:
package main
import (
"context"
"io"
"log"
"net"
"sync"
"time"
)
// Proxy is a minimal L4 TCP proxy: it forwards every accepted client
// connection to a single origin address.
type Proxy struct {
ListenAddr string
OriginAddr string
wg sync.WaitGroup // tracks in-flight connections (used for draining in step 03)
}
func (p *Proxy) Run(ctx context.Context) error {
ln, err := net.Listen("tcp", p.ListenAddr)
if err != nil {
return err
}
log.Printf("listening on %s -> %s", p.ListenAddr, p.OriginAddr)
// Close the listener when ctx is cancelled so Accept() unblocks.
go func() { <-ctx.Done(); ln.Close() }()
for {
client, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil // clean shutdown
default:
log.Printf("accept error: %v", err)
continue
}
}
p.wg.Add(1)
go func() {
defer p.wg.Done()
p.handle(client)
}()
}
}
func (p *Proxy) handle(client net.Conn) {
defer client.Close()
// Dial the origin with a bounded timeout — never block forever.
origin, err := net.DialTimeout("tcp", p.OriginAddr, 2*time.Second)
if err != nil {
log.Printf("dial origin: %v", err)
return
}
defer origin.Close()
// TCP_NODELAY on both sides: disable Nagle so small request/response
// messages aren't delayed waiting to coalesce (the 40ms-stall trap).
setNoDelay(client)
setNoDelay(origin)
// Two coupled copies. io.Copy blocks on a slow Write, which stops the
// corresponding Read: that IS backpressure, with a bounded buffer.
var once sync.WaitGroup
once.Add(2)
go func() { defer once.Done(); pipe(origin, client) }() // client -> origin
go func() { defer once.Done(); pipe(client, origin) }() // origin -> client
once.Wait()
}
// pipe copies src -> dst, then half-closes dst's write side so the peer
// sees a FIN while the reverse direction stays open.
func pipe(dst, src net.Conn) {
io.Copy(dst, src) // bounded 32KiB internal buffer; blocks => backpressure
if cw, ok := dst.(interface{ CloseWrite() error }); ok {
cw.CloseWrite() // send FIN on dst, keep reverse direction alive
}
}
func setNoDelay(c net.Conn) {
if tc, ok := c.(*net.TCPConn); ok {
tc.SetNoDelay(true)
}
}
src/go/main.go:
package main
import (
"context"
"flag"
"os"
"os/signal"
"syscall"
)
func main() {
listen := flag.String("listen", ":8080", "listen address")
origin := flag.String("origin", "127.0.0.1:9000", "origin address")
flag.Parse()
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
p := &Proxy{ListenAddr: *listen, OriginAddr: *origin}
if err := p.Run(ctx); err != nil {
os.Exit(1)
}
}
Run it
cd gw-01-l4-data-plane/src/go
go mod init gw01 && go build -o /tmp/l4proxy .
# Terminal 1: a trivial origin (echoes uppercased lines, say) or just nc
nc -l 9000
# Terminal 2: the proxy
/tmp/l4proxy -listen :8080 -origin 127.0.0.1:9000
# Terminal 3: a client
nc 127.0.0.1 8080
# type a line; it shows up in terminal 1. Type in terminal 1; it shows
# up in terminal 3. Ctrl-D in one direction half-closes that direction
# only — the other still works.
Tasks
- Implement
Proxy.Runandhandleas above; confirm bidirectional forwarding withnc. - Prove half-close works: in the client
nc, press Ctrl-D. The origin should see EOF (FIN) but you can still type in the origin and see it at the client. A broken proxy kills both directions here. - Prove backpressure (the anti-pattern). Replace
io.Copywith a version that reads into an unboundedchan []byteand writes from a second goroutine. Point a fast producer (yes | nc) at a slow consumer (an origin youkill -STOP). Watch RSS climb without bound — that's the warehouse-not-brigade bug. Revert toio.Copy.
Acceptance
- Bidirectional copy works between two
ncsessions through the proxy. - Ctrl-D on one side half-closes only that direction.
- With
io.Copy, proxy RSS stays flat under a fast-producer/slow- consumer load; with the unbounded-channel version it grows. You can explain why.
Discussion prompts
- Where exactly is the bounded buffer in
io.Copy, and how big is it? (Hint:io.Copyuses a 32KiB internal buffer unlesssrc/dstimplementWriterTo/ReaderFrom.) - Why dial the origin per connection here, and why is that the exact thing gw-04 fixes with pooling?
- This proxy can't retry a failed origin mid-stream. Why is that a fundamental L4 limitation rather than a missing feature?