Day 44 — net basics (TCP)
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/day44Lab 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 9000Theory 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.Connvshttp.ResponseWriterrelationship
- 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.