8.10 net — Tasks¶
Hands-on exercises with acceptance criteria. Each task targets a specific layer of the package — dialing, listening, deadlines, framing, graceful shutdown, observability. Code in the solution hints; expand to a full program with tests.
1. Echo server with line framing¶
Write a TCP server on port 8080 that reads lines from each client and echoes them back, prefixed with "echo: ".
Acceptance:
nc localhost 8080works interactively.- A second client can connect simultaneously without blocking.
Ctrl+Cshuts down within 5 seconds, logging the conn count.
Hints:
ln, _ := net.Listen("tcp", ":8080")
for {
c, err := ln.Accept()
if err != nil { return }
go func() {
defer c.Close()
s := bufio.NewScanner(c)
for s.Scan() {
fmt.Fprintf(c, "echo: %s\n", s.Text())
}
}()
}
Add the SIGINT handler with signal.NotifyContext and a wait group around the conn-handling goroutine.
2. UDP DNS-style request/response¶
Write a UDP server on port 9000 that receives a 4-byte big-endian request id, replies with the same 4 bytes prefixed by 0x01.
Acceptance:
- Server uses
net.ListenPacket("udp", ":9000"). - Reply uses
pc.WriteTowith the source address fromReadFrom. - A test client sends 100 requests in parallel and verifies all responses match their requests.
Hints:
buf := make([]byte, 1500)
for {
n, src, _ := pc.ReadFrom(buf)
if n != 4 { continue }
resp := append([]byte{0x01}, buf[:n]...)
pc.WriteTo(resp, src)
}
Don't forget to copy buf[:n] if you fan out to a worker.
3. Length-prefixed binary protocol with cap¶
Build a server that reads frames in this format: [4-byte BE length][payload]. Reject frames over 1 MiB. Echo the payload back, framed identically.
Acceptance:
- A 0-byte payload round-trips.
- A 1 MiB payload round-trips.
- A 2 MiB payload causes the server to log "frame too large" and close the conn (with no panic).
- A truncated header (peer closes mid-length-prefix) is logged as
io.ErrUnexpectedEOF, no panic.
Hints: Use io.ReadFull for the header and the body. Cap with an explicit if n > maxFrame check before make.
4. Deadlines that don't accumulate¶
A buggy server uses c.SetReadDeadline(time.Now().Add(d)) once and never resets. After the first deadline fires, the conn becomes unusable. Fix it: read a request, process, write response, with the deadline reset before each phase.
Acceptance:
- Test: open a conn, send a request slowly (1 byte/sec), verify the deadline fires.
- Send a normal request immediately after, verify it succeeds — i.e., the conn isn't permanently broken.
Hints: A new SetReadDeadline(time.Now().Add(d)) overrides the old one. After a timeout error, the conn is still usable; just reset.
5. Dialer with context¶
Write a function Dial(ctx context.Context, addr string) (net.Conn, error) that uses net.Dialer.DialContext, sets a 3-second per-attempt timeout, and respects the context's deadline.
Acceptance:
- Test: pass a context with 100ms deadline, dial a slow address — the call returns the deadline error.
- Test: pass a context with 10s deadline, dial an unreachable address — returns
connect: connection refusedor similar within 3 seconds (the dialer timeout).
Hints:
Use errors.Is(err, context.DeadlineExceeded) in tests.
6. Graceful shutdown for a TCP server¶
Take task 1's echo server and add proper shutdown. On SIGTERM:
- Stop accepting new conns.
- Wait up to 30 seconds for in-flight conns to finish (a client in the middle of a request gets to finish).
- Force-close any conns still open after the deadline.
- Exit with code 0.
Acceptance:
- Test: open a conn, send a partial line, send SIGTERM, send the rest of the line, verify the response arrives.
- Test: open a conn, do nothing, send SIGTERM, verify the server exits within 30s and the conn is closed by the server.
Hints: Wrap Accept in a goroutine; use sync.WaitGroup for handlers; close the listener on signal; track open conns in a sync.Map so you can force-close on timeout.
7. UDP server with worker pool¶
Build a UDP server where the read goroutine fans out received datagrams to a pool of N workers (N = runtime.NumCPU()). Each worker sleeps 10ms and replies.
Acceptance:
- Throughput scales with CPU count (test with 1, 2, 4 workers).
- The read goroutine never blocks waiting for a worker — drop packets if the work queue is full.
- Drop count is logged every 10 seconds.
Hints:
A buffered channel + non-blocking send is the simplest backpressure.
8. Unix socket with file-mode lock¶
Write a server that listens on /tmp/yourapp.sock, sets the file mode to 0o660, and removes the file on shutdown.
Acceptance:
- A user not in the right group cannot connect.
- Restarting the server doesn't fail with "address already in use."
- A test that creates a stale socket file (e.g., kill -9) and restarts succeeds (your code should
os.Removethe path beforeListenon second start, or detect and clean up).
Hints:
os.Remove(path) // pre-clean
ln, _ := net.Listen("unix", path)
os.Chmod(path, 0o660)
defer os.Remove(path)
defer ln.Close()
9. Random-port listener for tests¶
Write a test helper func newServer(t *testing.T) (addr string, shutdown func()) that starts an echo server on a kernel-chosen port and returns the address and a cleanup function.
Acceptance:
- Multiple tests can run in parallel without port collision.
t.Cleanuprunsshutdownautomatically.- The address is
127.0.0.1:PORT(not[::]:PORT).
Hints:
10. Dialer with custom resolver¶
Build a *net.Dialer whose resolver always uses 1.1.1.1:53 over TCP (avoiding any local DNS). Use it to dial example.com:80.
Acceptance:
- Verify with
tcpdumpor by settingGODEBUG=netdns=go+1that the lookup goes to 1.1.1.1. - Lookup fails fast (within 2 seconds) if 1.1.1.1 is unreachable.
Hints:
r := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
d := net.Dialer{Timeout: 2 * time.Second}
return d.DialContext(ctx, "tcp", "1.1.1.1:53")
},
}
d := net.Dialer{Resolver: r, Timeout: 5 * time.Second}
c, err := d.DialContext(ctx, "tcp", "example.com:80")
11. Half-close a TCP connection¶
Write a client that sends a multi-line request, calls CloseWrite, then reads the response until EOF.
Acceptance:
- The peer (a simple cat-style server) reads to EOF, processes, and replies; the client sees the response.
- Without
CloseWrite, the test hangs (verify by removing the call and observing).
Hints:
tc := c.(*net.TCPConn)
io.WriteString(tc, "line1\nline2\nline3\n")
tc.CloseWrite()
io.Copy(os.Stdout, tc)
12. Connection pool with idle timeout¶
Write a TCP connection pool with: max idle 100, idle timeout 90s, health check on Get (zero-byte read with 1ms deadline; evict if it returns anything other than a deadline error).
Acceptance:
- Stress test with 1000 goroutines doing short requests reuses conns —
lsofshows ~100 open, not 1000. - Conns idle past 90s are evicted on the next maintenance tick.
- A killed server (peer side) doesn't poison the pool — broken conns are detected on Get.
Hints: Background goroutine sweeping the pool every 30s. The zero-byte deadline trick:
c.SetReadDeadline(time.Now().Add(time.Millisecond))
var b [1]byte
_, err := c.Read(b[:])
if err == nil || !errors.Is(err, os.ErrDeadlineExceeded) {
// peer sent something or closed; not safe to reuse
return false
}
c.SetReadDeadline(time.Time{}) // clear
return true
13. Wrap net.Listener for accept-rate limiting¶
Write a wrapper RateLimited(ln, perSecond int) net.Listener that limits how many Accepts succeed per second; over the cap, sleep until the next bucket.
Acceptance:
- Test: 10 conns/sec cap; bench shows ~10/sec accept rate regardless of how many clients try.
- Excess clients see their conn delayed but eventually accepted (no rejection).
Hints: Token bucket on Accept; the wait happens before Accept returns to the caller, so the listener appears slow but correct.
14. UDP request/reply with timeout and retry¶
Build a UDP client that sends a request, waits 500ms for a reply, retries up to 3 times with exponential backoff.
Acceptance:
- Server replies on first try → client returns immediately.
- Server replies on third try → client returns the third reply.
- Server never replies → client returns an error after ~3.5s total.
Hints: *UDPConn.SetReadDeadline per attempt; retry on os.ErrDeadlineExceeded. Use a fresh deadline each retry.
15. Detect a goroutine leak¶
Take task 1's echo server, deliberately remove defer c.Close(), and write a test that detects the leak using runtime.NumGoroutine() before and after a simulated client disconnection.
Acceptance:
- Test fails (red) when
Closeis missing. - Test passes when
Closeis restored. - The test has a settle delay (e.g., 100ms) so transient goroutines finish before the count.
Hints:
n0 := runtime.NumGoroutine()
// run requests, close clients
time.Sleep(200 * time.Millisecond)
runtime.GC()
n1 := runtime.NumGoroutine()
if n1-n0 > tolerance { t.Fail() }
16. Stretch: TCP proxy¶
Write proxy(local, remote string): listens on local, for each incoming conn dials remote and copies bytes in both directions. Close both sides when either closes.
Acceptance:
nc localhost LOCALto a proxy ofexample.com:80succeeds at HTTP.- Half-close from one side propagates as
CloseWriteto the other. - Both directions terminate cleanly when either side closes.
Hints:
go func() { io.Copy(remote, local); remote.(*net.TCPConn).CloseWrite() }()
io.Copy(local, remote)
local.Close(); remote.Close()
The trick is making the two io.Copy goroutines tear down both conns; cancelling one means the other will read EOF.
17. Stretch: file-descriptor handover¶
Build two binaries: parent listens on port 8080; on signal SIGUSR2, it exec's child with the listener's fd in ExtraFiles, then exits after draining. child wraps the inherited fd as a listener and continues.
Acceptance:
nc -k localhost 8080keeps working through the handover (no "connection refused" from new clients).- In-flight requests on
parentfinish with the response (test with a slow handler).
Hints: See professional.md §6. Use os.Getenv and os.NewFile(uintptr(3), "lis") in the child.
18. Stretch: SO_REUSEPORT scaling¶
Start N processes (or N goroutines with N listeners), all bound to the same port via SO_REUSEPORT. Verify load is spread across them.
Acceptance:
- Each listener accepts roughly the same number of conns under uniform load.
- Killing one process redistributes load to the others within a few seconds.
Hints:
lc := net.ListenConfig{
Control: func(_, _ string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
})
},
}
ln, _ := lc.Listen(ctx, "tcp", ":8080")
Linux only; SO_REUSEPORT semantics differ on BSD.
19. Stretch: rate-limited writer per-conn¶
Wrap net.Conn.Write with a token bucket so each conn writes at most N bytes/second. Apply to the echo server.
Acceptance:
- Bench: a single client sees its echoes streamed at the configured rate.
- Multiple clients each get the full per-conn allowance (rate is per-conn, not global).
Hints: Reuse the rate-limited writer pattern from ../01-io-and-file-handling/middle.md, or use golang.org/x/time/rate.Limiter.
20. Stretch: TLS-wrapped server¶
Take task 1 and wrap the listener in tls.Listen with a self-signed cert. Test with openssl s_client.
Acceptance:
openssl s_client -connect localhost:8080completes the handshake.- After the handshake, sending a line still echoes.
- Setting
SetReadDeadlineon the*tls.Connafter the handshake works as before.
Hints:
cert, _ := tls.LoadX509KeyPair("cert.pem", "key.pem")
cfg := &tls.Config{Certificates: []tls.Certificate{cert}}
ln, _ := tls.Listen("tcp", ":8080", cfg)
For cert generation, see ../13-crypto/.
Cross-references¶
../01-io-and-file-handling/tasks.md— exercises that combine well with these.- find-bug.md — bugs you'll spot in your solutions.
- optimize.md — performance follow-ups for tasks 7, 13, 18.