Day 44 — net basics (TCP)

Updated

July 30, 2026

Day 44 — net basics (TCP)

Stage V · ~3h
Goal: Understand sockets under HTTP—listen, dial, read/write with deadlines, and ship a TCP echo server + client with tests.

Why this day exists

net/http will dominate Days 45–50, but HTTP rides on net.Conn. When debugging resets, half-open connections, or custom protocols, you need the TCP layer:

  • Listen / Accept
  • Dial / DialContext
  • Deadlines and buffer reads
  • Graceful close vs abort

Theory 1 — Listen and Accept

ln, err := net.Listen("tcp", "127.0.0.1:0") // :0 → ephemeral port
if err != nil {
    return err
}
defer ln.Close()

addr := ln.Addr().String()

for {
    conn, err := ln.Accept()
    if err != nil {
        return err // or continue on temporary errors
    }
    go serve(conn)
}

Address forms

Address Meaning
"127.0.0.1:8080" IPv4 loopback
"[::1]:8080" IPv6 loopback
":8080" All interfaces, port 8080
"localhost:8080" Resolver-dependent

Prefer loopback in tests.


Theory 2 — Dial

d := net.Dialer{Timeout: 3 * time.Second}
conn, err := d.DialContext(ctx, "tcp", addr)
// or: net.DialTimeout("tcp", addr, 3*time.Second)
defer conn.Close()

Always bound dial with timeout/context—hanging dials freeze clients.


Theory 3 — Conn I/O and deadlines

net.Conn implements io.ReadWriteCloser plus:

conn.SetDeadline(time.Now().Add(5 * time.Second))
conn.SetReadDeadline(...)
conn.SetWriteDeadline(...)

Without deadlines, a stuck peer can block a goroutine forever.

Line-oriented echo pattern

func serve(conn net.Conn) {
    defer conn.Close()
    _ = conn.SetDeadline(time.Now().Add(30 * time.Second))
    r := bufio.NewReader(conn)
    for {
        line, err := r.ReadString('\n')
        if err != nil {
            return
        }
        if _, err := io.WriteString(conn, line); err != nil {
            return
        }
        // extend deadline per message if desired
        _ = conn.SetDeadline(time.Now().Add(30 * time.Second))
    }
}

Theory 4 — Temporary errors & close

var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
    // deadline hit
}

Close unblocks pending I/O with an error. For half-close: CloseWrite on *net.TCPConn when you need FIN while still reading—advanced protocol design.


Theory 5 — net.Pipe for tests

In-memory full-duplex connection without sockets:

client, server := net.Pipe()
go func() {
    defer server.Close()
    _, _ = io.Copy(server, server) // silly echo; use your serve()
}()

Great for unit-testing protocol framing without ports.


Worked example — package echo

package echo

import (
    "bufio"
    "context"
    "fmt"
    "io"
    "net"
    "time"
)

type Server struct {
    ln net.Listener
}

func Listen(addr string) (*Server, error) {
    ln, err := net.Listen("tcp", addr)
    if err != nil {
        return nil, err
    }
    s := &Server{ln: ln}
    go s.loop()
    return s, nil
}

func (s *Server) Addr() string { return s.ln.Addr().String() }

func (s *Server) Close() error { return s.ln.Close() }

func (s *Server) loop() {
    for {
        conn, err := s.ln.Accept()
        if err != nil {
            return
        }
        go handle(conn)
    }
}

func handle(conn net.Conn) {
    defer conn.Close()
    _ = conn.SetDeadline(time.Now().Add(10 * time.Second))
    br := bufio.NewReader(conn)
    for {
        line, err := br.ReadBytes('\n')
        if len(line) > 0 {
            if _, werr := conn.Write(line); werr != nil {
                return
            }
        }
        if err != nil {
            return
        }
        _ = conn.SetDeadline(time.Now().Add(10 * time.Second))
    }
}

func Echo(ctx context.Context, addr, msg string) (string, error) {
    var d net.Dialer
    conn, err := d.DialContext(ctx, "tcp", addr)
    if err != nil {
        return "", err
    }
    defer conn.Close()
    if deadline, ok := ctx.Deadline(); ok {
        _ = conn.SetDeadline(deadline)
    }
    if _, err := io.WriteString(conn, msg); err != nil {
        return "", err
    }
    br := bufio.NewReader(conn)
    line, err := br.ReadString('\n')
    if err != nil {
        return "", fmt.Errorf("read: %w", err)
    }
    return line, nil
}

Lab 1 — Setup

mkdir -p ~/lab/90daysofx/01-go/day44
cd ~/lab/90daysofx/01-go/day44
go mod init example.com/day44

Lab 2 — Integration test on localhost

func TestEcho(t *testing.T) {
    s, err := Listen("127.0.0.1:0")
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { _ = s.Close() })

    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    got, err := Echo(ctx, s.Addr(), "ping\n")
    if err != nil {
        t.Fatal(err)
    }
    if got != "ping\n" {
        t.Fatalf("got %q", got)
    }
}
go test ./...

Lab 3 — Concurrent clients

Spawn 20 goroutines each sending unique lines. Use errgroup (from Stage III) or sync.WaitGroup + error channel. Assert all echoes match.


Lab 4 — Deadline failure

Set a very short read deadline on a client that dials but never receives a newline from a hung peer (write a “slow” server that sleeps). Assert timeout error.


Lab 5 — Manual demo

go run ./cmd/echod -addr 127.0.0.1:9000
# other terminal:
printf 'hello\n' | nc 127.0.0.1 9000

Theory note — from TCP to HTTP

HTTP/1.1 is text framed on a net.Conn: request line, headers, body. net/http manages connection pools, parsing, and TLS. After today you should be able to sketch where Accept sits under http.Server.Serve.


Common gotchas

Gotcha Fix
No dial timeout Dialer{Timeout} / DialContext
No read deadline SetDeadline / SetReadDeadline
Forgetting \n in line protocol Document framing
Accept loop dies on one error Log temporary; exit on close
Goroutine per conn without limits Bound concurrency later
Testing on :8080 fixed Use :0 + Addr()
IPv6/localhost surprises Prefer 127.0.0.1 in tests

Checkpoint

  • Echo server listens on ephemeral port
  • Client round-trip tested
  • Deadlines demonstrated
  • Concurrent clients succeed
  • Can explain net.Conn vs http.ResponseWriter relationship
  • Listener closed in t.Cleanup

Commit

git add .
git commit -m "day44: TCP echo server and client with deadlines"

Write three personal gotchas before continuing.


Tomorrow

Day 45 — net/http server: Handler, Go 1.22+ ServeMux patterns, middleware chains, and a small JSON API.