Binary Protocol
Every frame on the wire is a 6-byte header followed by a variable-length payload. No framing ambiguity, no partial reads, no text encoding — parsing is a couple of pointer reads.
Participants & transport
Producer and Worker both connect to the same TCP port (server_start, server.c:233). There is no separate producer port or worker port — a connection starts as CONN_UNKNOWN and only becomes CONN_PRODUCER or CONN_WORKER once the daemon sees its first MSG_SUBMIT or MSG_READY.
The CLI (quicx status) is a different client on a different transport: it connects to a separate Unix socket (/tmp/quicx.sock) and only ever does one exchange — MSG_STATS → MSG_STATS_RESPONSE.
The daemon is the process in the middle running the event loop; every message below is either read by handle_readable (server.c) or written by daemon_handle_message (daemon.c).
Connections aren’t only producer or worker, either. As of v1.0.3 the TCP listener and the Unix listener get their own roles — CONN_SERVER and CONN_CLI — instead of being lumped under CONN_UNKNOWN like every other not-yet-registered socket.
CONN_WORKER) can still send MSG_SUBMIT, and the daemon will process it exactly like a producer would.Frame header
- version
- Protocol revision. Currently 0x01. The daemon rejects any other version with MSG_ERROR 0x02 so protocol evolution is additive and opt-in.
- type
- Message opcode — one of the 12 types below. The daemon routes on type alone; producers and workers speak the same header shape.
- length
- 32-bit big-endian unsigned integer: the payload size in bytes. Zero is legal for MSG_READY, MSG_WAIT, MSG_HEARTBEAT, MSG_PONG and MSG_STATS.
- payload
- Exactly length bytes, capped at
PROTOCOL_MAX_PAYLOAD— 1024 bytesas of v1.0.3 (previously 1 MiB). The cap matches the allocator’s largest size class, so every accepted payload fits in exactly one PMAD block. Oversized frames get a cleanMSG_ERROR(ERR_PAYLOAD_TOO_BIG) instead of being truncated or read past the buffer.
Message used to be one flat struct carrying every field for every type. It’s now type + version + a union of per-type bodies (SubmitBody, OkBody, TaskBody, ErrorBody, DoneBody, FailedBody). Field access moved from msg->task_id to msg->as.done.task_id.Message types
| Type | Name | Direction |
|---|---|---|
0x01 | MSG_SUBMIT | producer → daemon |
0x02 | MSG_OK | daemon → producer |
0x03 | MSG_ERROR | daemon → producer or worker |
0x04 | MSG_READY | worker → daemon |
0x05 | MSG_DONE | worker → daemon → producer |
0x06 | MSG_FAILED | worker → daemon → producer |
0x07 | MSG_TASK | daemon → worker |
0x08 | MSG_WAIT | reserved, unused |
0x09 | MSG_HEARTBEAT | worker → daemon |
0x0A | MSG_PONG | daemon → worker |
0x0B | MSG_STATS | CLI → daemon (unix socket) |
0x0C | MSG_STATS_RESPONSE | daemon → CLI (unix socket) |
Payload formats
[type_len : 1 byte][task_type : type_len bytes][payload : rest of bytes]type = "send_email"
payload = {"to":"user@gmail.com"}
bytes = [10][send_email][{"to":"user@gmail.com"}]Parsed specially in server.c — the daemon needs the length-prefix byte before it knows how much more to read. On accept, the daemon queues a Task, tags the connection CONN_PRODUCER, replies MSG_OK, and calls try_dispach.
[task_id : 4 bytes]task_id = 0x00000A42 → accepted task id = 2626Acknowledges that the task was accepted into the queue — not that it ran. Outbound only: the daemon never parses an inbound MSG_OK.
[error_code : 1 byte][message : rest of bytes]| Code | Meaning |
|---|---|
0x01 | queue full — PMAD pool exhausted |
0x02 | invalid message (bad version / length / type) |
0x03 | payload too large for the largest size class (1024 bytes, as of v1.0.3) |
0x04 | unknown task type |
Generic failure reply, sent to whichever peer tripped it. Outbound only — if a peer ever sent MSG_ERRORto the daemon it’d fail parsing (not a recognized inbound case) and the connection gets closed.
(no payload — length = 0)Sent once per connection, immediately after connect, to register
the socket as an idle worker.Registers the connection (worker_add) and tags it CONN_WORKER, then calls try_dispach in case work is already queued — if a task is waiting, MSG_TASKfollows immediately. There’s no ack on success; if nothing is queued, the worker just stays blocked reading until a task arrives later.
[task_id : 4 bytes]Worker reports success for task_id.The daemon marks the worker idle, clears current_fd, bumps stats_task_completed, redispatches, and forwards protocol_send_done(producer_fd, task_id) to the producer that submitted the task. Parsing is now strict — exactly 4 bytes, or the frame is rejected.
MSG_DONEonly updated the daemon’s internal state — the producer was never told and got silence on a completed task. It now receives the same completion signal a failure would have gotten all along.[task_id : 4 bytes][reason : rest of bytes, max 64]reason is a UTF-8 string propagated verbatim to the producer,
and logged by the daemon.Worker reports failure with a reason string. The daemon forwards it as-is to the producer via Worker.current_fd (protocol_send_failed, daemon.c:87), bumps stats_task_failed, and redispatches the worker. The reason is capped at PROTOCOL_MAX_FAIL_REASON_MSG (64 bytes), validated against the buffer bound, and NUL-terminated by the daemon before forwarding.
[task_id : 4 bytes][type_len : 1 byte][task_type : type_len bytes][payload : rest]Mirror of MSG_SUBMIT with the task id prepended. The worker dispatches
by type and replies with MSG_DONE or MSG_FAILED carrying the same id.Sent by try_dispach (dispacher.c:22) when a queued task is handed to an idle worker.
(no payload — length = 0)Defined and accepted by server.c's parser, but daemon_handle_message
has no case for it — falls through to default → ERR_UNKNOWN_TYPE.Nothing in production code calls protocol_send_wait either. Effectively dead in the current build.
MSG_TASK arrives later.(no payload — length = 0)Liveness probe sent by the worker. The daemon immediately replies
MSG_PONG on the same fd.PROTOCOL_TIMEOUT_MS and PROTOCOL_HEARTBEAT_MSare defined, but nothing actually tracks last-heartbeat time to drop stale connections — the daemon only responds to heartbeats, it doesn’t enforce the timeout itself.
(no payload — length = 0)The only valid reply to MSG_HEARTBEAT.Outbound only, like MSG_OK / MSG_ERROR — the daemon has no inbound case for it.
(no payload — length = 0)Request for pool / queue / PMAD stats.Sent over the Unix socket at /tmp/quicx.sock — not the TCP port producers and workers use. quicx status is the only sender.
[StatsHeader : 73 bytes, packed, network order][StatsClass : repeated class_count times]| Block | Contents |
|---|---|
| StatsHeader | idle / busy worker counts, workers_registered (ever registered, not just live), queue depth, submitted / completed / failed task counters, uptime, pool_size, usable_bytes, used_bytes, and class_count |
| StatsClass × class_count | one block per PMAD size class — that class's allocator stats |
_Static_assert(sizeof(StatsHeader) == 73) locks the layout. class_count is a uint8_t with no byte-swap — it used to be a uint32_t sent through htonl, with the daemon and the reader each hardcoding a different, mismatched cap (32 and 16) on how many classes they’d parse.
StatsHeader grew from 48 to 73 bytes in v1.0.3. A v1.0.3 quicx status talking to a v1.0.2 daemon (or the reverse) degrades gracefully — it detects the length mismatch and prints daemon speaks a different stats format — restart the daemon to match rather than misparsing the response. Keep the CLI and the daemon on the same release.End-to-end flow
Producer Daemon Worker
|--MSG_SUBMIT-------->| |
|<--MSG_OK------------| |
| |<--MSG_READY----------------|
| |--MSG_TASK------------------>|
| |<--MSG_DONE (or MSG_FAILED)--|
|<--MSG_FAILED---------| (only on failure; DONE is not forwarded)