Skip to content

Reading end-of-statement-OK packets with info throws trilogy_read_row: TRILOGY_TRUNCATED_PACKET. #270

@jdelStrother

Description

@jdelStrother

Hi there - I'm interested in using Trilogy to talk to manticore, which publishes a mysql-compatible interface.

It works ok for trivial queries (SELECT 1), but longer result-sets throw trilogy_read_row: TRILOGY_TRUNCATED_PACKET.

From what I can tell (heavily aided by Claude, so take this with a grain of salt), it's because Manticore's end-of-statement OK packet includes szMeta in the info field, which is a human-readable string containing, eg, "3 out of >=24 results in 0ms". This is > 8 bytes, so trilogy_read_row doesn't think it's an EOF marker.

With regular mysql-server, end-of-statement OK packets are sent with nullptr in the info field, which is why trilogy handles them fine.

Claude thinks this should be fixed in manticore (by changing SendMysqlEofPacket to send NULL info in place of szMeta), I'm not so sure. The regular mysql client handles this fine, and the mysql docs seem to imply that any OK packets might include an info field with arbitrary length, even if they're being used for an end-of-statement marker.

WDYT? I can file this in manticore if you think it's a bug on their end.

Claude's writeup, with some rambling, packet captures, and possible fixes for manticore and/or trilogy

Bug: Manticore sends oversized EOF packet, breaking trilogy

Symptom

trilogy_read_row: TRILOGY_TRUNCATED_PACKET (Trilogy::QueryError)

Raised by trilogy when reading query results from Manticore Search (port 9306). The same query succeeds with mysql2.

Root cause

The bug is in Manticore. Both clients receive identical bytes; mysql2 tolerates the protocol violation, trilogy does not.

Background: CLIENT_DEPRECATE_EOF

Both Manticore and trilogy advertise and negotiate CLIENT_DEPRECATE_EOF (capability flag 0x01000000) during the handshake. Under this protocol:

  • No EOF packet is sent between the column definitions and the row data.
  • The final end-of-rows marker still starts with 0xFE — the header byte does not change. What changes is the body format: OK layout (affected_rows, last_insert_id, status flags, warnings) rather than legacy EOF layout (warnings, status flags only). MySQL server achieves this by calling net_send_ok(..., eof_identifier=true), which selects 0xFE as the header even though it's sending an OK-format body (mysql-server/sql/protocol_classic.cc lines 887–893, 1323–1327).
  • Since 0xFE is also the length-encoded prefix for an 8-byte integer in a result row, the packet must stay under 9 bytes total so clients can tell it apart from row data.

What Manticore actually sends

Manticore correctly omits the intermediate EOF between columns and rows, but then appends a query-stats info string to the final 0xFE end-of-rows packet, inflating it well past the 9-byte limit:

Packet seq=16, len=44:
fe 00 00 02 00 00 00  24 2d 2d 2d 20 33 20 6f 75 74 20 6f 66 ...
│  ├─────┤ ├─────┤   └─ "$--- 3 out of >=24 results in 0ms ---" (39 bytes)
│  warn=0  status=2
└─ 0xFE (EOF marker)

A valid EOF packet (with CLIENT_PROTOCOL_41) is exactly 5 bytes. Manticore's is 44 bytes.

Why trilogy fails

The MySQL protocol allows result rows to start with 0xFE — it means a length-encoded 8-byte integer follows. The MySQL server source documents this directly:

2^24 | 2^64 | 0xFE + 8-byte integer

Warning: If the first byte of a packet is a length-encoded integer and its byte value is 0xFE, you must check the length of the packet to verify that it has enough space for a 8-byte integer. If not, it may be an EOF_Packet instead.

mysql-server/sql/protocol_classic.cc lines 98, 103–107

To distinguish a real EOF packet from such a row, trilogy (like most MySQL clients) uses the heuristic:

// trilogy/src/client.c, trilogy_read_row()
if (current_packet_type(conn) == TRILOGY_PACKET_EOF && conn->packet_buffer.len < 9) {
    // treat as EOF / end of results
}

A genuine EOF packet is at most 5 bytes, so < 9 is a reliable test. This is both documented and implemented in the MySQL server source:

These rules distinguish whether the packet represents OK or EOF:

  • OK: header = 0 and length of packet > 7
  • EOF: header = 0xfe and length of packet < 9

mysql-server/sql/protocol_classic.cc lines 717–719

Warning: The EOF_Packet packet may appear in places where a Protocol::LengthEncodedInteger may appear. You must check whether the packet length is less than 9 to make sure that it is a EOF_Packet packet.

mysql-server/sql/protocol_classic.cc lines 993–997

The 5-byte maximum comes from the EOF packet's fixed structure (CLIENT_PROTOCOL_41): 1 header byte + 2 warnings + 2 status flags = 5 bytes, implemented in MySQL server as a literal uchar buff[5]:

// mysql-server/sql/protocol_classic.cc, write_eof_packet(), lines 1084–1106
uchar buff[5];
buff[0] = 254;           // 0xFE header
int2store(buff + 1, tmp);         // 2-byte warning count
int2store(buff + 3, server_status); // 2-byte status flags
error = my_net_write(net, buff, 5);

Manticore's packet is 44 bytes, so trilogy falls through and tries to parse it as a row:

  1. Reads byte 0xFE → length-encoded integer, next 8 bytes are the length
    (trilogy's lenenc parser: trilogy/src/reader.c lines 176–177: case 0xfe: return trilogy_reader_get_uint64(reader, out))
  2. Reads 00 00 02 00 00 00 00 24 as a uint64 → 3,252,724,830,868,471,808
  3. That length vastly exceeds the remaining bytes → TRILOGY_TRUNCATED_PACKET

Why mysql2 succeeds

mysql2 is backed by libmysqlclient, which uses a completely different heuristic split by whether CLIENT_DEPRECATE_EOF is negotiated (sql-common/client.cc, cli_safe_read, lines 1279–1293):

// DEPRECATE_EOF path: 0xFE is treated as an OK/EOF marker unless the packet
// exceeds MAX_PACKET_LENGTH (16MB), which would mean it's a genuine multi-packet row
if ((mysql->server_capabilities & CLIENT_DEPRECATE_EOF) &&
    (net->read_pos[0] == 254)) {
    if (len > MAX_PACKET_LENGTH) return len;  // huge data packet, keep reading
    if (is_data_packet) *is_data_packet = false;  // it's an EOF/OK marker
    if (parse_ok) read_ok_ex(mysql, len);
    return len;
}

// Legacy path (no DEPRECATE_EOF): 0xFE is EOF only if < 8 bytes
if (!(mysql->server_capabilities & CLIENT_DEPRECATE_EOF) &&
    (net->read_pos[0] == 254) && (len < 8)) {
    if (is_data_packet) *is_data_packet = false;
}

When is_data_packet is set to false, read_one_row_complete (line 3115) exits with end-of-data and parses the packet body as OK:

if (net->read_pos[0] != 0x00 && !is_data_packet) {
    if (mysql->server_capabilities & CLIENT_DEPRECATE_EOF)
        read_ok_ex(mysql, pkt_len);  // parse 0xFE packet body as OK
    return 1;  /* End of data */
}

With CLIENT_DEPRECATE_EOF active, libmysqlclient treats any 0xFE packet under 16MB as the end-of-rows marker — so Manticore's 44-byte packet is handled without error. Trilogy applies the stricter < 9 check from the MySQL spec regardless of whether CLIENT_DEPRECATE_EOF is set.

Client CLIENT_DEPRECATE_EOF threshold Legacy threshold
libmysqlclient / mysql2 len ≤ MAX_PACKET_LENGTH (16MB) len < 8
trilogy len < 9 len < 9

Evidence

Packets captured via TCP proxy (trilogy and mysql2 receive identical bytes from Manticore):

Seq Len Type Notes
0 92 Handshake Server version "0.0.0", capabilities 0x011a8228 incl. CLIENT_DEPRECATE_EOF
1 60 Auth (client→server) Client capabilities 0x018aa200 incl. CLIENT_DEPRECATE_EOF
2 7 OK Auth success
1 1 Result Column count = 11 (0x0b)
2–12 35–59 Column defs 11 column definition packets
(no intermediate EOF) Correct per CLIENT_DEPRECATE_EOF
13 99 Row Row 1
14 98 Row Row 2
15 102 Row Row 3
16 44 0xFE Correct marker byte, but 44 bytes due to appended stats string — must be < 9

Fix

In Manticore (src/netreceive_ql.cpp, SendMysqlEofPacket): when CLIENT_DEPRECATE_EOF is negotiated, the end-of-rows packet body must use OK format but must not include the meta/info string, so the total stays under 9 bytes (7 bytes: 0xFE + affected_rows + last_insert_id + status + warnings). The stats string --- N out of M results in Xms --- must be dropped from this packet.

MySQL server's own Protocol_classic::send_eof confirms this: when CLIENT_DEPRECATE_EOF is set it calls net_send_ok(..., nullptr, true) — explicitly passing nullptr for the message so the info field is never written (mysql-server/sql/protocol_classic.cc lines 1323–1327). The info field in the OK packet spec is intended for DML responses (INSERT/UPDATE/DELETE), not for end-of-rows markers.

Without the meta string the final packet is 7 bytes — under the 9-byte threshold — and clients recognise it correctly as end-of-rows:

fe 00 00 02 00 00 00
│  │     │
│  │     └─ status_flags (2 bytes)
│  └─ affected_rows=0, last_insert_id=0 (1 byte each, lenenc)
└─ 0xFE marker

Possible workaround in trilogy: replace the < 9 threshold with <= TRILOGY_MAX_PACKET_LEN (i.e. <= 0xFFFFFF = 16,777,215 bytes). This matches libmysqlclient's CLIENT_DEPRECATE_EOF behaviour and tolerates Manticore's non-standard 44-byte packet without introducing any false positives.

Why <= TRILOGY_MAX_PACKET_LEN is safe

The concern with raising the threshold is accidentally treating a genuine 0xFE-prefixed row as an EOF. But this cannot happen:

A text-protocol result row whose first column begins with 0xFE is encoding a length-encoded integer for a value ≥ 2²⁴ (= 16,777,216). The encoded form is 0xFE (1 byte) + 8-byte uint64. The column value itself must therefore be at least 2²⁴ bytes long. Minimum total row size:

1 byte (0xFE prefix) + 8 bytes (length) + 2²⁴ bytes (value) = 16,777,225 bytes

TRILOGY_MAX_PACKET_LEN is 0xFFFFFF = 16,777,215. A single MySQL wire packet cannot exceed this — larger payloads are split across multiple fragment packets and reassembled. After trilogy's fragment reassembly (packet_parser.c, on_packet_begin is skipped for continuation packets), conn->packet_buffer.len for such a row is the reassembled length of at least 16,777,225 bytes, which always exceeds TRILOGY_MAX_PACKET_LEN.

Therefore:

packet_buffer.len Meaning
TRILOGY_MAX_PACKET_LEN (16,777,215) Cannot be a 0xFE-prefixed row — safe to treat as EOF
> TRILOGY_MAX_PACKET_LEN Must be a large data row

The three call sites in trilogy/src/client.c (trilogy_read_row line 985, trilogy_drain_results line 1020, trilogy_stmt_read_row line 1262) would change from:

if (current_packet_type(conn) == TRILOGY_PACKET_EOF && conn->packet_buffer.len < 9) {

to:

if (current_packet_type(conn) == TRILOGY_PACKET_EOF && conn->packet_buffer.len <= TRILOGY_MAX_PACKET_LEN) {

This is exactly how libmysqlclient arrives at the same guarantee from the other direction: it checks the raw (pre-reassembly) packet length against MAX_PACKET_LENGTH (16MB) before any fragment reassembly occurs. Both approaches exploit the same mathematical constraint — a 0xFE-prefixed row can never fit in a single 16MB packet.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions