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:

  1. 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.
  2. 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.
  3. 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 Group22 (Beacon), proposed
Command Code2 (0x02)
Server functioncmd_beacon_wait in a new cmd_beacon.c (to be written)
Request packet32-byte plaintext RAIDA header + encrypted body fields + plaintext 3E 3E terminator
Request body62 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 response32-byte plaintext RAIDA response header + encrypted payload (16-byte array header + tell records) + plaintext 3E 3E terminator
No-mail responseWith 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 successNone. Tells are retained; see Ack (group 22, command 3) for per-device suppression.
EncryptionRequired — 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 offsetSizeEncrypted?FieldDescription
0–1112YesChallenge randomRandom bytes generated by the client.
12–154YesChallenge CRC32Big-endian CRC32 of body bytes 0–11.
16–238YesSession IDAll zeros.
24–252YesCoin Type00 06.
261YesMailbox denominationRecipient mailbox coin denomination.
27–304YesMailbox serial numberRecipient mailbox serial number, big-endian.
311YesDevice IDThe 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–4716YesAuthenticity NumberThe mailbox coin AN for this RAIDA, verified exactly as for Ping.
48–558YescursorOpaque 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–572Yesmax_hold_secondsBig-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.
581Yesmax_recordsMost 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.
591YesflagsBit 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–612NoTerminatorFixed 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 offsetSizeFieldDescription
01tell_countNumber of tell records following this header, 0–255.
1–22total_tellsBig-endian number of Tells newer than the request cursor (after hide_acked), saturating at 65535. Greater than tell_count means more remain.
31flagsBit 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–74server_timeBig-endian Unix seconds on the beacon when the reply was built. Lets the client measure its own clock skew without a separate call.
8–158next_cursorCursor 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...Variabletell recordstell_count records in ascending cursor order, each in the Ping record format.

Long-poll mechanics

  1. Validate body length (62), terminator, decryption, challenge CRC and AN exactly as Ping does.
  2. Reject unknown flags bits with ERROR_INVALID_PARAMETER.
  3. Query the retention index for Tells of this mailbox with cursor > request cursor, ascending; drop those acknowledged by this device when hide_acked is set; count them as total_tells.
  4. If at least one exists, copy records until max_records or the byte cap, set truncated if any were left, set next_cursor to the last included record, reply 250.
  5. If none exist and no_park is set, reply 250 with tell_count = 0 at once.
  6. 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.
  7. 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 = 0 and woken_empty.
  8. On deadline, reply 250 with tell_count = 0, expired and the unchanged cursor. Wait never uses status 245; a reply that carries a cursor is more useful to the client than a bare status.

Status codes

DecimalHexSymbolMeaning
2500xFASTATUS_SUCCESSEvery completed Wait, with or without records. Read flags and tell_count.
80x08ERROR_COIN_NOT_FOUNDMailbox (denom, SN) is not loaded on this RAIDA.
160x10ERROR_INVALID_PACKET_LENGTHBody is not exactly 62 bytes.
330x21ERROR_INVALID_EOFTerminator was not 3E 3E.
340x22ERROR_INVALID_ENCRYPTIONBody could not be decrypted or validated.
370x25ERROR_INVALID_CRCChallenge CRC mismatch.
1980xC6ERROR_INVALID_PARAMETERReserved flag bit set, or a cursor that does not belong to this server (wrong RAIDA or corrupted).
50x05ERROR_INVALID_COMMAND_GROUPThis server build has no group 22. A client falls back to Ping/Peek for the session.
1940xC2ERROR_FILESYSTEMRetention index or inbox unreadable, or the inotify watch could not be armed.
2000xC8ERROR_INVALID_ANBody AN does not match the stored AN.
2540xFEERROR_MEMORY_ALLOCResponse 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 its commands bit 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_cursor reported 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_expired so the client knows a gap may exist. It never rejects a stale cursor.
  • Index rebuild: publish_seq is 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.