Quicx
§ 02.03Core Concepts

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_STATSMSG_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.

HEADS UPRole isn't enforced
Nothing ties a connection’s registered role to which message types it’s allowed to send afterward. A connection that already registered as a worker (CONN_WORKER) can still send MSG_SUBMIT, and the daemon will process it exactly like a producer would.

Frame header

total header = 6 bytes fixedtotal message = 6 + length bytes
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 clean MSG_ERROR (ERR_PAYLOAD_TOO_BIG) instead of being truncated or read past the buffer.
NOTEMessage is a tagged union on the daemon side
This only affects the daemon’s C code, not the bytes on the wire: 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

TypeNameDirection
0x01MSG_SUBMITproducer → daemon
0x02MSG_OKdaemon → producer
0x03MSG_ERRORdaemon → producer or worker
0x04MSG_READYworker → daemon
0x05MSG_DONEworker → daemon → producer
0x06MSG_FAILEDworker → daemon → producer
0x07MSG_TASKdaemon → worker
0x08MSG_WAITreserved, unused
0x09MSG_HEARTBEATworker → daemon
0x0AMSG_PONGdaemon → worker
0x0BMSG_STATSCLI → daemon (unix socket)
0x0CMSG_STATS_RESPONSEdaemon → CLI (unix socket)

Payload formats

0x01MSG_SUBMITproducer → daemon
[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.

0x02MSG_OKdaemon → producer
[task_id : 4 bytes]
task_id = 0x00000A42  →  accepted task id = 2626

Acknowledges that the task was accepted into the queue — not that it ran. Outbound only: the daemon never parses an inbound MSG_OK.

0x03MSG_ERRORdaemon → producer or worker
[error_code : 1 byte][message : rest of bytes]
CodeMeaning
0x01queue full — PMAD pool exhausted
0x02invalid message (bad version / length / type)
0x03payload too large for the largest size class (1024 bytes, as of v1.0.3)
0x04unknown 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.

0x04MSG_READYworker → daemon
(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.

0x05MSG_DONEworker → daemon → producer
[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.

TIPFixed in v1.0.3 — producers now hear about success
Until this release, 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.
0x06MSG_FAILEDworker → daemon → producer
[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.

0x07MSG_TASKdaemon → worker
[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.

0x08MSG_WAITreserved, unused
(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.

HEADS UPMSG_WAIT was never wired up
This looks like a planned “no work right now, hold on” signal that was never finished. A worker that registers with no task queued doesn’t get an explicit wait reply — it just stays blocked until MSG_TASK arrives later.
0x09MSG_HEARTBEATworker → daemon
(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.

0x0AMSG_PONGdaemon → worker
(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.

0x0BMSG_STATSCLI → daemon (unix socket)
(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.

0x0CMSG_STATS_RESPONSEdaemon → CLI (unix socket)
[StatsHeader : 73 bytes, packed, network order][StatsClass : repeated class_count times]
BlockContents
StatsHeaderidle / 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_countone 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.

HEADS UPStats wire format is incompatible with v1.0.2
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

one task, start to finish
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)