The Startup Handshake
Speak PostgreSQL's protocol from the first byte: frame messages by their length, turn down encryption politely, and greet the client the way every driver expects
With no --wire, speak the PostgreSQL protocol version 3.0: cut the bytes of each connection into length-prefixed messages however they arrive, answer an SSLRequest or a GSSENCRequest with the byte N, greet a StartupMessage with AuthenticationOk, six ParameterStatus messages, BackendKeyData carrying the session number and ReadyForQuery, refuse other protocol versions and broken first messages without disturbing anyone else, and end the session on Terminate.
This part needs a network, which the platform's test sandbox does not have, so its tests run on your computer. Download the test kit (it needs only Python 3) and your project, build your program, and run:
python3 run.py --stage pg-startup --bin ./c-dbSign in to download the code you wrote here.
- Length-prefixed framing: a type byte, a 4-byte length that counts itself, then the body
- The first message is special: no type byte, and a code in place of a protocol version can turn it into a request for encryption
- Refusing TLS is part of the protocol: one byte, N, and the client carries on in plain text
- The greeting is a fixed sequence a driver waits for: authentication, parameters, a cancel key, then ready
- ReadyForQuery's status byte is how the client knows whose turn it is
- A broken client is that client's problem: close its connection, keep serving everyone else
Why this stage exists
Your server answers nc now. Nothing else can talk to it, because every database client speaks some database's own protocol. Here is psql, PostgreSQL's terminal client, pointed at your server from the last stage:
$psql -h 127.0.0.1 -p 5433 -U droid droidpsql: error: connection to server at "127.0.0.1", port 5433 failed: received invalid response to SSL negotiation: W
psql opened with eight bytes asking for TLS. Your server read them as the start of a line and sent back the welcome line, Welcome to droid!, whose first byte is W. From this stage on, your server speaks PostgreSQL's protocol, version 3.0, the one every PostgreSQL driver in every language speaks: libpq, JDBC, psycopg, node-postgres, pgx. Speaking it is a bigger payoff than it looks. Those drivers, and the tools built on them, can then talk to your database. This stage does the first exchange. The next one sends queries.
The line protocol stays behind --wire text. Every test of the last stage passes that flag and keeps passing.
Messages have lengths, not newlines
Text protocols end a message with a newline, which works until a value contains one. PostgreSQL's protocol instead puts the size of each message in front of it. Every message, both ways, is a type byte, then a 4-byte big-endian length, then a body. The length counts itself and the body, not the type byte:
- typebytes 0–0 (1 bytes) · 'R': an authentication message
- lengthbytes 1–4 (4 bytes) · 8: these 4 bytes and the 4 of the body
- codebytes 5–8 (4 bytes) · 0: authentication succeeded
Everything you learned about lines still holds. One read may hold half a message or several. The input buffer and the rule stay the same: handle a message once all of it has arrived, and keep the rest. Only "where does it end" changes. Before, the answer was "at the next \n". Now it is "length bytes after the type".
There is one more thing to check: the length comes from the client. A length of 3 cannot even hold itself, and a length of 10 MB for a startup message is either a bug or an attack. Check it before waiting for the body, then close that connection and nothing else.
The first message is different
The very first message from a client has no type byte, because the client cannot know yet what the server speaks. It is a length, then a 4-byte number, then a body:
- lengthbytes 0–3 (4 bytes) · 8: the whole request
- codebytes 4–7 (4 bytes) · 80877103: 1234 in the high 16 bits, 5679 in the low
If the number is one of the two special codes, the client asks for encryption: TLS (80877103) or GSS (80877104). This track does not encrypt, so the answer is a single byte, N, with no type and no length. The client then sends a new first message on the same connection, which is the StartupMessage. Otherwise the number is a protocol version: 3 in the high 16 bits and 0 in the low, so 196608. The body is a list of parameter names and values, ended by an empty name:
- lengthbytes 0–3 (4 bytes) · 35: the whole message
- protocolbytes 4–7 (4 bytes) · 196608 = 3.0
- "user"bytes 8–12 (5 bytes) · a name, with its zero byte
- "droid"bytes 13–18 (6 bytes) · its value
- "database"bytes 19–27 (9 bytes) · another name
- "droid"bytes 28–33 (6 bytes) · its value
- endbytes 34–34 (1 bytes) · the empty name that ends the list
The greeting
A real PostgreSQL would now ask for a password. Droid trusts every client, so it goes straight to the greeting. These are four kinds of message, in an order every driver waits for:
- ParameterStatus messages tell the driver how to talk to you.
client_encodingsays strings are UTF-8.standard_conforming_stringssays a backslash inside quotes is an ordinary character.server_versionlets a driver decide which features to use. - BackendKeyData gives the client a ticket for later. To cancel a long statement, a client opens a second connection and sends this process id and secret key. That is a later stage, but the key is handed out now. In PostgreSQL the process id is the backend process's real id. In droid it is the session number, which plays the same part.
- ReadyForQuery ends every exchange in this protocol, not just the greeting. Its last byte says the session's state:
Ifor idle,Tinside a transaction,Einside a failed one. The client sends nothing more until it sees one.
When the first message is wrong
A client that asks for protocol 2.0 or 4.0 gets an ErrorResponse and is disconnected. An ErrorResponse is a list of fields, each a one-letter code and a string, ended by a zero byte. The severity FATAL means the connection is ending. The SQLSTATE 0A000 is PostgreSQL's code for "feature not supported", and the tests read it. A startup message whose parameters do not end with the final zero byte, the empty name after the last pair, is 08P01, a protocol violation. Bad lengths get no reply at all, because nothing about the message can be trusted. In every case it is only that connection that ends. A server that crashes, or stops serving others, because of one client's bad bytes is broken whatever the client did.
The algorithm in words
Cutting the stream into messages
The same idea as lines in the last stage, with a length instead of a newline. It runs on each connection's input buffer after every read.
- 1.Before the startup is done, a message has no type byte: when the buffer holds at least 4 bytes, read the length from them. Afterwards a message is a type byte and then the length: wait for 5 bytes.
- 2.Check the length before waiting for the body: below 8 or above 10000 for a first message (below 4 for any other) means the client is broken. Close the connection.
- 3.When the buffer holds the whole message, take it out and handle it. Leave the rest in the buffer: it is the start of the next message.
- 4.Invariant: no message is handled before its last byte has arrived, and no byte is handled twice.
The first message
One 8-byte prefix decides what the client wants.
- 1.Read the 32-bit number after the length. 80877103 is an SSLRequest and 80877104 a GSSENCRequest: send the single byte N, with no type and no length, and wait for another first message.
- 2.Otherwise it is a protocol version: the major number is the high 16 bits. If it is not 3, send an ErrorResponse with severity FATAL and SQLSTATE 0A000, then close the connection once it is sent.
- 3.Read the parameters: name and value strings, one after the other, until an empty name, the single zero byte that ends the list. If the message ends before that byte, after a name with no value or after a complete pair, or if bytes are left after it, send FATAL 08P01 and close.
- 4.The parameters change nothing in droid: there is one database, and every user is trusted.
The greeting
Four kinds of message, in this order, all appended to the connection's output buffer at once.
- 1.AuthenticationOk: R, length 8, the integer 0.
- 2.One ParameterStatus (S, a name, a value) for each of server_version, server_encoding, client_encoding, DateStyle, integer_datetimes and standard_conforming_strings, with the values of the contract.
- 3.BackendKeyData: K, length 12, the session number as the process id, then a secret key you choose and remember for this connection.
- 4.ReadyForQuery: Z, length 5, the byte I: the session is idle and waits for the client.
- 5.From now on, the messages from this client have a type byte. In this stage only X, Terminate, is defined: close the connection and end the session as when a client leaves.
Output contract for this stage
These are the exact messages the tests check. The full definition is on the output contract page, section 94.
| Client sends | Server sends |
|---|---|
SSLRequest (00 00 00 08 04 d2 16 2f) | the byte N |
GSSENCRequest (00 00 00 08 04 d2 16 30) | the byte N |
| StartupMessage, protocol 3.0 | R 0; S server_version 16.0 (droid), server_encoding UTF8, client_encoding UTF8, DateStyle ISO, MDY, integer_datetimes on, standard_conforming_strings on (any order); K session number, secret key; Z I |
| StartupMessage, protocol 4.0 (any major but 3) | ErrorResponse S and V FATAL, C 0A000; the connection closes |
a StartupMessage whose parameters lack their final zero byte (user\0, user\0droid\0) | ErrorResponse FATAL, 08P01; the connection closes |
| a first message with a length below 8 or above 10000 | nothing; the connection closes |
Terminate (58 00 00 00 04) | nothing; the connection closes, the session ends |
What you should see when done
psql gets a step further than before. It connects, reads the greeting and shows its prompt. Answering what you type there is the next stage's work:
$psql -h 127.0.0.1 -p 5433 -U droid droidpsql (16.4, server 16.0)Type "help" for help.droid=>
And the kit:
$python3 run.py --stage pg-startup --bin ./c-dbPASS server/server-listen:listens-and-greets...PASS server/server-listen:shutdownPASS server/pg-startup:greeting-sequencePASS server/pg-startup:golden-bytesPASS server/pg-startup:ssl-request-refusedPASS server/pg-startup:gss-request-refusedPASS server/pg-startup:byte-at-a-timePASS server/pg-startup:unknown-protocol-rejectedPASS server/pg-startup:bad-startup-packetsPASS server/pg-startup:terminate-closesPASS server/pg-startup:wire-pg-flag================================================================================summary: passed=23 failed=0 total=23
Common bugs in this stage
- Little-endian lengths. Every integer in this protocol is big-endian: the most significant byte first. On x86 and ARM a plain
memcpyof anintgives the wrong order. - Counting the type byte in the length. The length counts itself and the body. AuthenticationOk is 9 bytes on the wire with a length of 8.
- Sending N with a type and a length. The answer to an SSLRequest is one bare byte.
- Waiting for a 10 MB body, or for 8 bytes that never come. Check the length as soon as its 4 bytes arrive: a length of 3 must close the connection before anything more is sent.
- Checking only the last byte.
user\0droid\0ends with a zero and is still broken: the list needs one more zero, the empty name, after its last pair. - Reading the first message as a typed message. Only the messages after the startup start with a type byte. A connection needs a flag saying whether its startup is done.
- A fixed process id. Every connection's BackendKeyData carries its own session number. Two clients with the same id could not cancel each other's statements later.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: read a big-endian length with
ntohlon four bytes copied out of the buffer (never by casting a pointer into the buffer: it may not be aligned). Write one withhtonl. A smallput_u32/put_cstrpair that appends to an output buffer keeps the message builders short. - C++: the same
ntohlandhtonl, or build the value from the bytes with shifts. Astd::stringoutput buffer and a helper that writes the type, leaves 4 bytes, appends the body, then fills in the length, makes every message builder three lines long. - Rust:
u32::from_be_bytes(buf[0..4].try_into()?)andn.to_be_bytes(). AVec<u8>output buffer, andextend_from_slice. - Zig:
std.mem.readInt(u32, buf[0..4], .big)andstd.mem.writeInt(u32, &bytes, n, .big).
Checklist
One line per thing the tests check. The tags name the test cases.
- A StartupMessage gets AuthenticationOk, the six parameters, one BackendKeyData whose process id is the session number, and ReadyForQuery Igreeting-sequence
- AuthenticationOk and idle ReadyForQuery are exactly 52 00 00 00 08 00 00 00 00 and 5A 00 00 00 05 49golden-bytes
- An SSLRequest gets the single byte N, and startup then works on the same connectionssl-request-refused
- A GSSENCRequest gets N toogss-request-refused
- A StartupMessage sent one byte per write gets the same greetingbyte-at-a-time
- A protocol other than 3.0 gets FATAL 0A000, and the connection closesunknown-protocol-rejected
- An impossible length closes that connection without a reply, a StartupMessage whose parameters lack their final zero byte gets FATAL 08P01, and the next client is served as usualbad-startup-packets
- Terminate closes the connection and ends the sessionterminate-closes
- --wire pg is the same protocol, said explicitlywire-pg-flag