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

  1. Implement Proxy.Run and handle as above; confirm bidirectional forwarding with nc.
  2. 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.
  3. Prove backpressure (the anti-pattern). Replace io.Copy with a version that reads into an unbounded chan []byte and writes from a second goroutine. Point a fast producer (yes | nc) at a slow consumer (an origin you kill -STOP). Watch RSS climb without bound — that's the warehouse-not-brigade bug. Revert to io.Copy.

Acceptance

  • Bidirectional copy works between two nc sessions 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.Copy uses a 32KiB internal buffer unless src/dst implement WriterTo/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?