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 Group6
Command Code78
Server functioncmd_qmail_object_transfer_status in qmail_object_transfer.c
Wire structuresqmail_object_transfer_status_req_t, qmail_object_transfer_status_resp_t, qmail_object_transfer_range_entry_t
Body layoutPreamble (48) + Common prefix (16) + Status request (32) = 96 bytes request header
TransportTCP only
EncryptionRequired — AES-128
OptionalYes — 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)

transfer_id (16 bytes) 64 79 cursor (8, BE) 80 87 RM 88 rsv 89 max_ranges (2) 90 91 reserved (4) 92 95
OffsetSizeFieldDescription
64–7916transfer_idMust match the transfer_id returned by object_begin for this transfer.
80–878cursorUnsigned big-endian. 0 requests the first page of results. On later calls, pass the previous response's next_cursor unchanged.
881range_mode0 = list missing ranges, 1 = list received ranges.
891reservedMust be zero.
90–912max_rangesUnsigned big-endian, 1..256. Server returns at most this many entries per call.
92–954reservedMust be zero.
last 22TerminatorFixed 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.

FieldSizeDescription
common_prefix16Echoes request_id.
transfer_id16Echo of the request's transfer_id.
transfer_state10 receiving, 1 ready (all bytes received, not yet committed), 2 committed, 3 aborted, 4 expired.
range_mode1Echo of the request's range_mode.
response_flags2Bit 0 = more entries remain (must agree with next_cursor != 0). All other bits zero in v1.
target_generation8The target_generation this transfer will publish at commit.
total_size8Echo of the size declared at begin.
received_unique8Total unique bytes received so far across the whole transfer.
next_cursor8Opaque. 0 means no more entries; otherwise pass this value back unchanged on the next call.
range_count2Number of 16-byte range entries that follow in this response, ≤ the request's max_ranges.
reserved2Zero.
range entriesrange_count × 16Each entry: offset (8 bytes, BE) + length (8 bytes, BE).

Status codes

CodeSymbolMeaning
250SUCCESSStatus returned; see response body.
218ERROR_TCP_REQUIREDRetry using TCP.
219ERROR_UNSUPPORTED_PROTOCOLUnsupported protocol_version, framing version, or nonzero flags.
222ERROR_TRANSFER_NOT_FOUNDtransfer_id is unknown for this authenticated owner.
223ERROR_TRANSFER_EXPIREDThe transfer existed but has expired.
229ERROR_INVALID_RANGEAn 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.