Beacon Wait — Group 22, Code 2 (proposed)
Long-poll inbox wait, second generation. The client hands the beacon RAIDA an opaque cursor it received from the server, says how long it is willing to wait, and gets back every retained Tell published after that cursor, in server order, with a new cursor and an explicit “more remain” flag. It replaces the sender-timestamp filter of 72 Ping and 73 Peek for clients that opt in. Those two commands stay unchanged.
Status of this proposal
Not implemented
Group 22 does not exist in raidax (its COMMAND_GROUP enum ends at DRD = 16) or in rest_core. This page is the design to review before either side writes code; see the Beacon Service overview for why these commands live in their own group rather than in QMail (6). Nothing here changes the behaviour of any existing command.
Why it is needed
Ping and Peek filter Tells by the timestamp the sender wrote into the Tell header, and the client advances its cursor to the newest such timestamp it has seen. Three defects follow from that design, and none can be fixed inside the existing packets without changing what a zero byte means to a deployed client:
- Sender clock skew hides mail. If sender A’s clock runs ten minutes fast, a recipient who receives A’s Tell moves its cursor ten minutes into the future. Everything sender B sends in the next ten minutes carries a timestamp at or below that cursor and is never returned. The server cannot correct this because the filter value is the sender’s, not its own.
- Capped batches strand Tells. The scan stops at 1 MiB in directory order and sets no flag; its one-byte count is a plain cast of an
int, so 256 records advertise as 0 and 257 as 1. A client that advances to the newest timestamp in a cut batch never asks for the Tells that were left out. Retention does not help, because the cursor has moved past them. Re-sending the same cursor returns the same prefix, so no timestamp-only client strategy can page through a large backlog. - The hold time is the server’s choice. The server parks a Ping for 15–16 minutes. rest_core’s socket budget is 10 minutes with a 15-minute ceiling, so every idle Ping expires on the client side and is logged as a failure. Neither side can fix this alone: raising the client limit races the server’s sweep, lowering the server hold changes behaviour for every existing client.
Wait fixes all three at the root: the cursor is assigned by the server from its own publication order (so skew and same-second ordering do not matter), the response says how many Tells matched and returns the cursor of the last one included (so a cut batch is resumed, not skipped), and the client states its own maximum hold (so the poll always completes inside the client’s socket budget). It also folds Peek in: a flag asks the server not to park, so one command serves both cases and the client keeps one code path.
Quick reference
| Command Group | 22 (Beacon), proposed |
| Command Code | 2 (0x02) |
| Server function | cmd_beacon_wait in a new cmd_beacon.c (to be written) |
| Request packet | 32-byte plaintext RAIDA header + encrypted body fields + plaintext 3E 3E terminator |
| Request body | 62 bytes total: encrypted challenge(16) + qmail identity/auth(32) + cursor(8) + max_hold_seconds(2) + max_records(1) + flags(1), then plaintext terminator(2) |
| Success response | 32-byte plaintext RAIDA response header + encrypted payload (16-byte array header + tell records) + plaintext 3E 3E terminator |
| No-mail response | With flags.no_park = 0: parked up to max_hold_seconds, then status 250 with tell_count = 0 and the unchanged cursor. With no_park = 1: the same reply at once. |
| Side effect on success | None. Tells are retained; see Ack (group 22, command 3) for per-device suppression. |
| Encryption | Required — AES-128-CTR, type 1, keyed by the caller’s selected coin AN for this RAIDA |
Wire packet
The RAIDA packet header is the 32 plaintext bytes at the front of the TCP packet. The 16-byte challenge/CRC is encrypted with the rest of the request body. raidax groups that challenge with the QMail identity block and calls those first 48 decrypted body bytes qmail_preamble_t; Wait uses the preamble unchanged so the existing decrypt, CRC and AN checks apply as they are.
QMail Wait request packet, bytes on TCP
+-------------------------------+-----------------------------------------+----------------------+
| RAIDA request header | encrypted request body | plaintext terminator |
| 32 bytes | 60 bytes | 2 bytes |
+-------------------------------+-----------------------------------------+----------------------+
| command group=22, command=2 | challenge/CRC(16) | 3E 3E |
| encryption type=1 | QMail identity/auth block(32) | |
| body_size=62 | cursor(8) | |
| nonce(8) | max_hold_seconds(2) | |
| | max_records(1) flags(1) | |
+-------------------------------+-----------------------------------------+----------------------+
The 32-byte request header is identical to Ping’s except for the command group (0x16), the command code (0x02) and the body size (00 3E, 62).
Request body (62 bytes)
Decrypted request body layout
+-------------------+--------------------------+-----------+-------------+-----------+-------+------------+
| challenge/CRC | QMail identity/auth | cursor | max_hold_s | max_recs | flags | terminator |
| 16 bytes | 32 bytes | 8 bytes | 2 bytes | 1 byte | 1 | 2 bytes |
| encrypted | encrypted | encrypted | encrypted | encrypted | enc. | plaintext |
+-------------------+--------------------------+-----------+-------------+-----------+-------+------------+
| body[0..15] | body[16..47] | [48..55] | [56..57] | [58] | [59] | [60..61] |
+-------------------+--------------------------+-----------+-------------+-----------+-------+------------+
| Body offset | Size | Encrypted? | Field | Description |
|---|---|---|---|---|
| 0–11 | 12 | Yes | Challenge random | Random bytes generated by the client. |
| 12–15 | 4 | Yes | Challenge CRC32 | Big-endian CRC32 of body bytes 0–11. |
| 16–23 | 8 | Yes | Session ID | All zeros. |
| 24–25 | 2 | Yes | Coin Type | 00 06. |
| 26 | 1 | Yes | Mailbox denomination | Recipient mailbox coin denomination. |
| 27–30 | 4 | Yes | Mailbox serial number | Recipient mailbox serial number, big-endian. |
| 31 | 1 | Yes | Device ID | The polling device, 1–255. Ping and Peek ignore this byte; Wait uses it together with flags.hide_acked so that a Tell acknowledged by this device through Ack (group 22, command 3) is no longer returned to it, while other devices still receive it. Zero is treated as 1. |
| 32–47 | 16 | Yes | Authenticity Number | The mailbox coin AN for this RAIDA, verified exactly as for Ping. |
| 48–55 | 8 | Yes | cursor | Opaque value copied verbatim from the next_cursor of a previous Wait reply. All zeros means “from the beginning of retention”. The client must not construct, compare or arithmetically advance it; see Cursor. |
| 56–57 | 2 | Yes | max_hold_seconds | Big-endian. Longest time the server may park this connection. The server uses min(max_hold_seconds, server_max_hold); the server maximum is reported by Beacon Status (group 22, command 1) and is 900 in the current design. A client should send a value comfortably below its own socket read timeout, for example 540 with a 600 s timeout. Ignored when flags.no_park is set. |
| 58 | 1 | Yes | max_records | Most Tell records the client wants in one reply, 1–255. Zero means the server maximum (255). The server also stops at its byte cap and reports truncation either way. |
| 59 | 1 | Yes | flags | Bit 0 no_park: return at once even when nothing matches (Peek behaviour). Bit 1 hide_acked: omit Tells this device has acknowledged with Ack (group 22, command 3). Bits 2–7 reserved, must be zero; the server rejects unknown bits with ERROR_INVALID_PARAMETER so that future bits cannot be silently ignored. |
| 60–61 | 2 | No | Terminator | Fixed 3E 3E. Included in body_size but not encrypted. |
Cursor
The cursor is assigned by the beacon from its retention index, which already records when each Tell was published on this server. The recommended encoding, opaque to the client, is:
cursor (8 bytes, big-endian fields)
+-------------+----------+-------------------------------+---------------------------+
| raida_id | index_gen| publish_time | publish_seq |
| 1 byte | 1 byte | 4 bytes, server Unix seconds | 2 bytes, per-second seq |
+-------------+----------+-------------------------------+---------------------------+
The RAIDA tag stops a cursor from one beacon being replayed at another; the index generation lets the server invalidate cursors after an index rebuild. Two Tells published in the same second get different sequence numbers, so ordering is total and a page boundary inside one second is representable. A Tell republished under the same GUID (an edit) gets a new cursor and is returned again; the client deduplicates by GUID and version. Because the value is server time, sender clock skew has no effect, and because the server sorts by cursor before filling the reply, a cut batch always drops the newest Tells and the returned next_cursor lets the client resume exactly where the cut happened.
Two servers do not share cursors. A client keeps one cursor per beacon RAIDA, as rest_core already keeps one timestamp per beacon in qmail_beacon_ts.dat.
Response packet
QMail Wait success response, bytes on TCP
+-------------------------------+-----------------------------------------+----------------------+
| RAIDA response header | encrypted payload | plaintext terminator |
| 32 bytes | 16 + records | 2 bytes |
+-------------------------------+-----------------------------------------+----------------------+
| status=250, group=6 | array header(16) | 3E 3E |
| body_size=payload_len+2 | record 1 | |
| challenge signature | record 2 ... | |
+-------------------------------+-----------------------------------------+----------------------+
The response header is the standard 32-byte legacy header described on the Ping page. The payload array header grows from 8 to 16 bytes; the Tell records that follow are byte-for-byte the records Ping and Peek return, so the existing record parser is reused.
| Payload offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | tell_count | Number of tell records following this header, 0–255. |
| 1–2 | 2 | total_tells | Big-endian number of Tells newer than the request cursor (after hide_acked), saturating at 65535. Greater than tell_count means more remain. |
| 3 | 1 | flags | Bit 0 truncated: the reply was cut by max_records or the byte cap; call again with next_cursor immediately. Bit 1 expired: the hold elapsed with no new Tell (only with tell_count = 0). Bit 2 woken_empty: a file arrived but nothing matched after filtering. Bit 3 cursor_expired: the request cursor predated the oldest retained Tell or an index rebuild; results start from the oldest retained record and a gap may exist. Bits 4–7 reserved, zero. |
| 4–7 | 4 | server_time | Big-endian Unix seconds on the beacon when the reply was built. Lets the client measure its own clock skew without a separate call. |
| 8–15 | 8 | next_cursor | Cursor of the last record included, or the request cursor unchanged when tell_count = 0. The client persists every record, then stores this value, then sends it on the next Wait. |
| 16... | Variable | tell records | tell_count records in ascending cursor order, each in the Ping record format. |
Long-poll mechanics
- Validate body length (62), terminator, decryption, challenge CRC and AN exactly as Ping does.
- Reject unknown
flagsbits withERROR_INVALID_PARAMETER. - Query the retention index for Tells of this mailbox with cursor > request cursor, ascending; drop those acknowledged by this device when
hide_ackedis set; count them astotal_tells. - If at least one exists, copy records until
max_recordsor the byte cap, settruncatedif any were left, setnext_cursorto the last included record, reply250. - If none exist and
no_parkis set, reply250withtell_count = 0at once. - Otherwise arm the inotify watcher before the final scan (arm, rescan once, then park) so a Tell published between the scan and the watch registration cannot be missed; give the watcher a per-connection monotonic deadline of
min(max_hold_seconds, server_max_hold)and expire it at that deadline, not on a once-a-minute sweep with a 60 s slop. - On wake, every parked connection watching that inbox (one per device) is completed: each re-runs step 3 with its own cursor and replies as in step 4 or, if nothing matched, with
tell_count = 0andwoken_empty. - On deadline, reply
250withtell_count = 0,expiredand the unchanged cursor. Wait never uses status245; a reply that carries a cursor is more useful to the client than a bare status.
Status codes
| Decimal | Hex | Symbol | Meaning |
|---|---|---|---|
| 250 | 0xFA | STATUS_SUCCESS | Every completed Wait, with or without records. Read flags and tell_count. |
| 8 | 0x08 | ERROR_COIN_NOT_FOUND | Mailbox (denom, SN) is not loaded on this RAIDA. |
| 16 | 0x10 | ERROR_INVALID_PACKET_LENGTH | Body is not exactly 62 bytes. |
| 33 | 0x21 | ERROR_INVALID_EOF | Terminator was not 3E 3E. |
| 34 | 0x22 | ERROR_INVALID_ENCRYPTION | Body could not be decrypted or validated. |
| 37 | 0x25 | ERROR_INVALID_CRC | Challenge CRC mismatch. |
| 198 | 0xC6 | ERROR_INVALID_PARAMETER | Reserved flag bit set, or a cursor that does not belong to this server (wrong RAIDA or corrupted). |
| 5 | 0x05 | ERROR_INVALID_COMMAND_GROUP | This server build has no group 22. A client falls back to Ping/Peek for the session. |
| 194 | 0xC2 | ERROR_FILESYSTEM | Retention index or inbox unreadable, or the inotify watch could not be armed. |
| 200 | 0xC8 | ERROR_INVALID_AN | Body AN does not match the stored AN. |
| 254 | 0xFE | ERROR_MEMORY_ALLOC | Response buffer allocation failed. |
A server that predates group 22 answers ERROR_INVALID_COMMAND_GROUP (5) from the dispatcher before any handler runs. A server that has the group but not this command answers ERROR_INVALID_COMMAND (6). Treat both as “unsupported here”.
Compatibility
- Existing clients: unaffected. Ping and Peek are not modified. A client that never sends group 22 sees no change.
- Existing servers: a server without group 22 answers
ERROR_INVALID_COMMAND_GROUP(5). A client should send Beacon Status once per beacon per session, read itscommandsbit set, and fall back to Ping/Peek when Wait is absent. - Cursor scope: a cursor is valid only on the RAIDA that issued it. A client keeps one per beacon and never sends RAIDA 11’s cursor to RAIDA 14. The server rejects a cursor whose embedded RAIDA tag does not match itself with
ERROR_INVALID_PARAMETER. - Cursor migration: a client switching from Ping to Wait sends an all-zero cursor once and deduplicates the retained backlog by GUID, or starts from
newest_cursorreported by Beacon Status when it only wants mail from now on. - Expired cursor: a cursor older than the oldest retained Tell is still valid: the server returns from the oldest retained record and sets
flags.cursor_expiredso the client knows a gap may exist. It never rejects a stale cursor. - Index rebuild:
publish_seqis persisted in the retention index. If the index is rebuilt from files, sequence numbers are reassigned in file order within each second, and the server bumps an index generation embedded in the cursor so that pre-rebuild cursors are treated as expired (full replay from oldest retained) rather than mis-ordered. - Retention semantics: Wait never deletes. Per-device suppression through Ack is optional and does not shorten retention.
Common mistakes
Advancing the cursor before persisting
Store every record in the local database, then store next_cursor. A crash between the two replays the batch, which is harmless; the reverse order loses mail.
Sleeping after an empty reply
An empty reply with expired or woken_empty means the server is no longer listening for you. Send the next Wait immediately; the server does the waiting.
Choosing max_hold_seconds above the socket timeout
The value must leave room for network delay and for how the server schedules expiry. If the server expires on a per-watcher deadline, 540 with a 600 s read timeout is safe; if it still sweeps once a minute, the reply can arrive up to 60 s after the hold, so send at most 480. Beacon Status reports which it does through sweep_seconds (0 means per-watcher deadlines).
Ignoring truncated
When truncated is set there is more mail right now. Call again at once with next_cursor; do not park.