QMail Object Transfer Status — Group 6, Code 78
Lists the received or missing byte ranges of an in-progress transfer, paginated by an opaque cursor. Used to resume an upload after a dropped connection without re-sending bytes the server already has.
Quick reference
| Command Group | 6 |
| Command Code | 78 |
| Server function | cmd_qmail_object_transfer_status in qmail_object_transfer.c |
| Wire structures | qmail_object_transfer_status_req_t, qmail_object_transfer_status_resp_t, qmail_object_transfer_range_entry_t |
| Body layout | Preamble (48) + Common prefix (16) + Status request (32) = 96 bytes request header |
| Transport | TCP only |
| Encryption | Required — AES-128 |
| Optional | Yes — only needed to resume; a fresh upload that completes without interruption never needs it |
Purpose
After a client reconnects following a dropped connection, it does not know which of its earlier object_put_range calls actually landed. object_transfer_status answers that by returning a list of range entries in one of two modes, selected by the request's range_mode:
- range_mode = 1 (received) — entries describing bytes the server already has. The client can skip re-sending these.
- range_mode = 0 (missing) — entries describing gaps still needed to reach
total_size. The client uploads exactly these ranges to complete the transfer.
The server returns at most max_ranges entries per call (capped at 256) and never constructs an unbounded response. A transfer with more entries than fit in one response is paginated via cursor/next_cursor; see Cursor pagination below.
Request body
Preamble (48 bytes, offsets 0–47)
Standard QMail preamble. See QMail Overview — Universal preamble.
Common prefix (16 bytes, offsets 48–63)
See object_begin for the shared common-prefix field table. command_header_length must equal 32 for this command.
Status request payload (32 bytes, offsets 64–95)
| Offset | Size | Field | Description |
|---|---|---|---|
| 64–79 | 16 | transfer_id | Must match the transfer_id returned by object_begin for this transfer. |
| 80–87 | 8 | cursor | Unsigned big-endian. 0 requests the first page of results. On later calls, pass the previous response's next_cursor unchanged. |
| 88 | 1 | range_mode | 0 = list missing ranges, 1 = list received ranges. |
| 89 | 1 | reserved | Must be zero. |
| 90–91 | 2 | max_ranges | Unsigned big-endian, 1..256. Server returns at most this many entries per call. |
| 92–95 | 4 | reserved | Must be zero. |
| last 2 | 2 | Terminator | Fixed clear-text terminator. |
Response body
Successful response fixed header length: 72 bytes (16-byte common prefix + 56-byte status response), followed by range_count 16-byte range entries.
| Field | Size | Description |
|---|---|---|
| common_prefix | 16 | Echoes request_id. |
| transfer_id | 16 | Echo of the request's transfer_id. |
| transfer_state | 1 | 0 receiving, 1 ready (all bytes received, not yet committed), 2 committed, 3 aborted, 4 expired. |
| range_mode | 1 | Echo of the request's range_mode. |
| response_flags | 2 | Bit 0 = more entries remain (must agree with next_cursor != 0). All other bits zero in v1. |
| target_generation | 8 | The target_generation this transfer will publish at commit. |
| total_size | 8 | Echo of the size declared at begin. |
| received_unique | 8 | Total unique bytes received so far across the whole transfer. |
| next_cursor | 8 | Opaque. 0 means no more entries; otherwise pass this value back unchanged on the next call. |
| range_count | 2 | Number of 16-byte range entries that follow in this response, ≤ the request's max_ranges. |
| reserved | 2 | Zero. |
| range entries | range_count × 16 | Each entry: offset (8 bytes, BE) + length (8 bytes, BE). |
Status codes
| Code | Symbol | Meaning |
|---|---|---|
| 250 | SUCCESS | Status returned; see response body. |
| 218 | ERROR_TCP_REQUIRED | Retry using TCP. |
| 219 | ERROR_UNSUPPORTED_PROTOCOL | Unsupported protocol_version, framing version, or nonzero flags. |
| 222 | ERROR_TRANSFER_NOT_FOUND | transfer_id is unknown for this authenticated owner. |
| 223 | ERROR_TRANSFER_EXPIRED | The transfer existed but has expired. |
| 229 | ERROR_INVALID_RANGE | An invalid or stale cursor was supplied (e.g. from a different call sequence). |
Cursor pagination
The cursor is opaque despite its integer encoding — clients must not interpret or construct it, only return the value the server most recently gave them. A typical drain loop:
cursor = 0
ranges = []
while True:
resp = transfer_status(transfer_id, cursor, range_mode=0, max_ranges=256)
ranges.extend(resp.range_entries)
if resp.next_cursor == 0:
break
cursor = resp.next_cursor
The server returns at most 256 entries per call regardless of the requested max_ranges, so a transfer with many small, non-contiguous ranges may require several calls to fully enumerate.
Common mistakes
Polling status before every put_range call
Status is for resuming after an interruption, not for tracking normal upload progress — the response of each object_put_range call already reports received_unique. Calling status on every chunk adds a redundant round trip with no additional information during an uninterrupted upload.
Mutating the cursor value
The cursor is not a byte offset even though it is transmitted as an integer. Passing anything other than the exact value returned as next_cursor can return ERROR_INVALID_RANGE or produce an inconsistent page of results.