# KumoDB, Session 1: I Built a TCP Server That Can Read a Postgres Startup Message

> First session of KumoDB, a PostgreSQL-compatible proxy inspired by PlanetScale Neki. Process lifecycle, Docker port collisions, Postgres 18 volume layout, graceful TCP shutdown, SSLRequest, length caps, and parsing startup parameters — plus every bug that actually happened.

- URL: https://avikmukherjee.com/blog/kumodb-session-1-postgres-wire-protocol
- Author: Avik Mukherjee
- Published: 2026-09-13
- Updated: 2026-09-13
- Tags: go, postgresql, tcp, distributed-systems, systems, networking

I know SQL. I know enough system design to draw boxes. I do **not** know production Go yet.

So I started **KumoDB**: a PostgreSQL-compatible proxy that will eventually sit in front of multiple Postgres instances — connection pooling, query routing, sharding. The long-term inspiration is PlanetScale's [Neki](https://planetscale.com/blog/what-is-a-neki-router): a fleet of routers that speak the Postgres wire protocol to the app, plan which shards run a query, and make many servers look like one database.

This post is only **session 1**. No sharding. No pool. No `database/sql`. By the end of the day I had a process that accepts TCP, refuses TLS the way `psql` expects, reads a length-capped StartupMessage, and logs `user` / `database`. That sounds small. It was not.

The rule I used: if I cannot explain the concurrency and the failure mode without looking at the code, it is not done — even if `go test` is green.

## What KumoDB is (and is not)

Neki is **not** a transparent TCP forwarder and it is **not** "PgBouncer plus hashing."

A Neki router:

- Speaks the Postgres protocol to clients (so drivers and `psql` keep working)
- Parses SQL and builds a **routing plan** from a JSON data topology
- Sends work to shard sidecars; Postgres still plans *how* each shard runs
- Exists because Postgres is **process-per-connection** — thousands of clients would otherwise mean thousands of backends

KumoDB will earn those ideas. Session 1 does not copy Neki's gRPC sidecars, parser, or control plane. If I had started with `internal/shardmanager`, I would have been drawing architecture, not learning sockets.

```
psql / app
    │  TCP, Postgres wire
    ▼
 KumoDB  (:15432 on localhost)
    │  not connected yet
    ▼
 Postgres 17 in Docker  (host :5433 → container :5432)
```

The right-hand arrow is Compose only. Go does not open a database driver. On purpose.

## The Go I did not have

I went in as a beginner. A few things I got wrong immediately, because they show up in this codebase:

**Slices share a backing array.** `b := a[:2]; append(b, 9)` can **mutate `a`** if there is leftover capacity. `append` only allocates a new array when `len+needed > cap`. Buffering TCP reads will hit this.

**Channels have an owner.** Only the sender closes. Close once. Send on closed = panic. Receive on closed = zero value. Nobody closes + `range` = hang forever.

**A typed nil is not a nil interface.** `var err *os.PathError; return err` makes `err != nil` true. Return `nil` literally.

**`main` returning kills the process.** `listener.Close()` unblocks `Accept`. It does **not** close accepted connections or wait for `go handle(conn)`.

I am not going to pretend I already knew the Postgres wire format. I learned it by implementing eight bytes at a time.

## Milestone 0 — a process that can die cleanly

### 0.1 The binary

Layout follows [Organizing a Go module](https://go.dev/doc/modules/layout): one command, no `pkg/`, no framework.

```
cmd/kumodb/main.go
cmd/kumodb/main_test.go
go.mod          // module github.com/Avik-creator/kumodb
```

The first useful program is not "hello world." It is: log start, **block** until SIGINT/SIGTERM, log shutdown, exit 0.

```go
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
```

**WHAT:** `signal.NotifyContext` cancels a context when the OS sends a signal.  
**HOW:** `<-ctx.Done()` parks `main`.  
**WHY:** a server is a process that has not returned yet.  
**TRADEOFF:** `stop()` unregisters the signal handler. Skipping it looks fine because the process is about to exit. It is a leak if you ever create many of these in a long-lived program.

I commented out `<-ctx.Done()` to see what happened. The process printed start and shutdown in the same millisecond. That is not a server. I left it commented out. That is a regression, not an experiment.

### 0.2 Tests that do not press Ctrl+C

CI cannot send SIGINT. So `main` stays thin: flags, logger, signals. `run(ctx, log)` is what tests call.

Two tests:

1. Context already canceled → `run` returns immediately, `err == nil`. Weak: an empty `return nil` also passes.
2. Start `run` in a goroutine, assert it has **not** returned after 50ms, then `cancel()`, assert it **does** return. That first `select` is what proves this is a wait loop.

`go test -race` from the start. Shutdown is not a failure: `run` returns `nil` after cancel. `context.Canceled` is logged as `reason`, not `os.Exit(1)`.

### 0.3 Postgres in Docker — do not connect

Compose is a dependency, not architecture. Image `postgres:17`, healthcheck `pg_isready`, credentials local-only.

Two failures that were not Go:

**Port 5432 was already taken.** Another stack on the machine already owned the host port. I did not kill it. Host mapping became `5433:5432`. Inside the container Postgres still listens on 5432. `docker compose exec` does not use the published port.

**Postgres 18 Docker images changed `PGDATA`.** I first pulled `postgres:18` and mounted `/var/lib/postgresql/data` — the 17-era path. The 18 image stores data at `/var/lib/postgresql/18/docker` and **refuses to start** if it sees the old mount. Logs look like a lecture about `pg_ctlcluster`. Fix for session 1: pin **17**, wipe only KumoDB's volume (`docker compose down -v`), `up -d --wait`. 18 is a later problem.

`SELECT 1` via `compose exec` worked. The Go process still does not dial it.

## Milestone 1 — TCP

Default listen address: `127.0.0.1:15432`. Not 5432 (already taken). Not 5433 (Compose).

```
run(addr) → net.Listen → serve(ln)
```

Tests that need a port call `serve` with `net.Listen("tcp", "127.0.0.1:0")` and `ln.Addr()`. Production default is for `go run`. Tests must not fight over 15432.

### The Accept loop

```go
defer ln.Close()
go func() {
    <-ctx.Done()
    ln.Close() // unblocks Accept
}()

var wg sync.WaitGroup
for {
    conn, err := ln.Accept()
    if err != nil {
        if ctx.Err() != nil {
            break // shutdown, not a process failure
        }
        return err
    }
    wg.Add(1)
    go func(c net.Conn) {
        defer wg.Done()
        handleConn(ctx, log, c)
    }(conn)
}
wg.Wait()
return nil
```

**Pass `conn` in as `c`.** Closing over the loop variable is a classic bug: the next `Accept` overwrites `conn` and every handler shares the last socket.

**`Add` before `go`.** `Add` inside the goroutine races with `Wait`.

**`break`, then `Wait`, then `return nil`.** I first `return ctx.Err()` from inside the loop. That skips `WaitGroup`, treats shutdown as failure, and leaves the shutdown log unreachable. `break` means stop taking **new** clients. It does not close existing sockets.

### Closing an in-flight Read

Closing the **listener** does not unblock `Read` on connections you already accepted. Those stay in `io.Copy` until the peer leaves — Ctrl+C would hang.

Each handler:

```go
ctx, cancel := context.WithCancel(ctx)
defer cancel()
defer conn.Close()
go func() {
    <-ctx.Done()
    conn.Close()
}()
```

The child `cancel` on return so the closer goroutine does not wait on process shutdown after the client already left. `Close` twice is fine.

### Echo, then delete it

`io.Copy(conn, conn)` is a mirror. A test writes `ping\n` and `io.ReadFull`s the same bytes. `Read` can return short; `ReadFull` is the right tool for a known size.

Echo is **not** a proxy. A proxy is two sockets and two copies. I deleted echo the moment I needed the byte stream for Postgres. Leaving `Copy` in front of `ReadFull` eats the startup message until EOF — then there is nothing to parse. I left it in by accident. Tests for parse still passed because they never hit the wire. That is why a serve test that writes a header matters.

## Milestone 2 — the wire, eight bytes at a time

Docs: [Protocol overview](https://www.postgresql.org/docs/current/protocol-overview.html), [Message formats](https://www.postgresql.org/docs/current/protocol-message-formats.html).

Regular messages: 1-byte type, 4-byte big-endian length (includes the length, excludes the type). **StartupMessage and SSLRequest have no type byte.** First client packet is 8 bytes: `uint32 length` (includes itself) + `uint32` version or magic.

Protocol 3.0 is `196608` (`3<<16 | 0`).

TCP is a stream. `Read` can return 3 bytes of an 8-byte header. `io.ReadFull` loops until the buffer is full or an error.

If the client sends 3 bytes and **stays connected**, `ReadFull` waits forever. SIGINT already `Close`s the conn. A stuck client while the server is otherwise healthy does **not** cancel `ctx`. That is `conn.SetReadDeadline`. There is no `IoReadDeadline` — `io` has no clock. Deadlines live on `net.Conn`.

```go
length = binary.BigEndian.Uint32(b[0:4])
version = binary.BigEndian.Uint32(b[4:8])
```

Little-endian here is silent garbage. Check `ReadFull`'s error **before** parse. An 8-byte buffer always has `len == 8`; parse would succeed on zeros if you overwrite `err`.

### SSLRequest

`psql` almost always asks "TLS?" first. Same 8-byte layout: length `8`, code `80877103`.

The answer is **one byte**: `'S'` (TLS follows) or `'N'` (cleartext continues). Not a length-prefixed packet. I wrote 16 invented bytes that started with a length and ASCII `PSQL`. That is not in the protocol. Then I `return`ed, which **drops the connection** before StartupMessage.

Correct path:

1. Parse first header
2. If SSLRequest: `SetWriteDeadline`, `Write([]byte{'N'})`, refresh `SetReadDeadline`
3. `ReadFull` the **next** 8 bytes as startup
4. Stay in `handleConn`

`SetWriteDeadline` exists because `'N'` can sit in the send buffer until the client reads. `SetReadDeadline` does not unblock `Write`.

A unit test that only parses `80877103` does not prove you wrote `'N'`. The test that matters: Dial, write SSLRequest, `ReadFull` 1 byte, assert `'N'`, write a real startup header.

### Length caps

The length field says how big the **whole** message is. Body after the 8-byte header is `length-8`.

If you `make([]byte, length)` when the client lies with `4e9`, you try to allocate until the process dies. Validate first:

```go
if length < 8 { return error }
if length > 10_000 { return error } // learning cap, not a spec
return int(length) - 8, nil
```

`length == 8` means zero body. Legal. `ReadFull` of 0 bytes is a no-op.

Do not log `string(body)`. Later that is passwords. Log the count, then parsed `user` / `database` only.

### Startup parameters

After the header, the body is C strings:

```
user \0 avik \0 database \0 kumodb \0 \0
```

Pairs of `key NUL value NUL`. An **empty key** (extra `NUL`) ends the list.

`strings.Split` on `\0` invents extra empty fields. Walk with `bytes.IndexByte`.

Two different `len == 0` cases:

| When | Meaning |
|------|--------|
| `len(body)==0` before the loop | No param bytes (`length==8`). Empty map. Success. |
| `len(rest)==0` **inside** the loop | We already ate a pair and ran out. Missing terminator. Error. |

`user\0avik\0` is the second case. `nil` body is the first. I mixed them; `TestParseStartupParamsEmpty` failed with `missing terminator`. Guard empty body **before** the loop. Keep the in-loop check.

`rest[0]==0` with `len==1` is the terminator. Extra bytes after that: trailing garbage.

## Why not `database/sql`

`database/sql` (and pgx as a driver) hide this **from an application**. Your Go code calls `Query`. The driver speaks Startup, SSL, auth.

KumoDB **is** the server `psql` connects to. `psql` does not import `database/sql`. If I used `sql.Open` on the accept path, I would not be a Postgres-compatible endpoint.

Later, the **backend** hop might look like a client of `:5433`. A real proxy often still uses a raw `net.Conn` and forwards messages, because prepared statements, `COPY`, and session GUCs live in the protocol, not in `db.Query()`. Pooling in KumoDB will be `Acquire`/`Release` of those sockets — Phase 4 — not `database/sql`'s pool.

## What I shipped in this session

```
cmd/kumodb/
  main.go        listen, serve, SSL refuse, startup parse
  main_test.go   shutdown, header, SSL on the wire, caps, params
docker-compose.yml   postgres:17, :5433, healthcheck
```

Tests run with:

```bash
go test -race ./cmd/kumodb -v -count=1
```

`-count=1` because `(cached)` will happily hide a broken `handleConn` if you only changed logs.

## Bugs that actually happened (not hypothetical)

| Symptom | Cause |
|---------|--------|
| `Bind :5432 failed` | Another Compose stack already published 5432 |
| Container `Exited (1)` after looking "starting" | Postgres 18 + volume at `/var/lib/postgresql/data` |
| `exec`: service not running | Raced healthcheck **and** the process had already died |
| Shutdown test wanted `nil`, got `context canceled` | `return ctx.Err()` from `serve` |
| Parse tests green, wire dead | `io.Copy(conn, conn)` still above `ReadFull` |
| `ReadFull` error ignored | Second `:=` replaced `err`; parse of zeros succeeded |
| `psql` would never start up | Wrote 16 garbage bytes instead of `'N'`; then `return` |
| `TestParseStartupParamsEmpty` | Empty body used the "truncated list" error path |

## What I can explain without the file

1. A server is `main` that has not returned. Signals cancel a context.
2. `Accept` + `WaitGroup` + close accepted conns on cancel.
3. Startup/SSLRequest are 8 big-endian bytes, no type tag.
4. `'N'` is one byte, then Startup on the same TCP conn.
5. Cap `length` before `make`.
6. Param list: NUL pairs, extra NUL to end; empty body ≠ missing terminator.
7. `database/sql` is the wrong layer for the client-facing side of a proxy.

## What is not done

No `AuthenticationOk`. No `ReadyForQuery`. No query forwarding. No pool. No shards. `psql` still hangs or errors after startup because we close the socket.

Next session is 2.5: trust auth — write `AuthenticationOk` + `ReadyForQuery` so `psql` thinks it logged in. Still no `database/sql`. Still no dial to Docker until the frontend session is honest.

The destination is Neki-shaped. The path is one `ReadFull` at a time.
