Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Chaneged
### Changed

- rtc-client - upgrade libstm(#33)
- rtc-client — upgrade libstm(#33).
- rtc-client — frames under `TAI_FRAME_COALESCE_LIMIT` (default 512 B) now coalesce into a
single transport write: Ping, control packets and small audio chunks drop from 2–3 TLS
records to 1(#32).

### Fixed

- iot-client — US-East (`UEAZ`) fell back to an ATOP host that does not resolve(#32).
- iot-client — US-East (`UEAZ`) fell back to an ATOP host that does not resolve(#31).
`IOT_UEAZ_HOST` is now `a1-ueaz.tuyaus.com`.

## [0.4.0] - 2026-08-27
Expand Down
13 changes: 11 additions & 2 deletions modules/rtc-tcp-client/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,17 @@ no large contiguous frame buffer:

`send_one_frame_sg` signs the logical `[frame header || app header || payload]` via
`tai_frame_hmac_sg` (byte-identical to the contiguous HMAC — the receiver is unchanged), then
writes the merged `[frame header || app header]`, the **zero-copy payload**, and the signature
(2–3 TLS records per Frame). A logical packet over 32 KB is fragmented across the concat, with the
emits the frame in one of two shapes, split by `TAI_FRAME_COALESCE_LIMIT` (default 512 B,
counting the whole frame: header, app header, payload, signature):

- **Below the limit**: the frame is coalesced into `tx_ctrl_buf` and sent as ONE TLS record.
A control packet's payload already lives in `tx_ctrl_buf`, so it is only shifted in place
(memmove — payload and scratch are the same buffer, which is why the relocation happens
before the frame header overwrites its front).
- **At or above the limit**: zero-copy, 2–3 TLS records — the merged
`[frame header || app header]`, the **payload from the caller's buffer**, and the signature.

A logical packet over 32 KB is fragmented across the concat, with the
app header only in the first Frame. (ClientHello is the one exception: it is sent *unsigned* and
one-shot, so `tai_connect` frames it inline on the stack rather than through the signing sender.)

Expand Down
25 changes: 22 additions & 3 deletions modules/rtc-tcp-client/src/tai_client.c
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,28 @@ static int send_one_frame_sg(tai_ctx_t *ctx, uint8_t frag_flag, uint16_t seq,
}
}

/* From here on, any failure has committed bytes to the wire and desyncs the
* stream: return TAI_ERR_NET (distinct from the pre-wire errors above so the
* caller knows the sequence number was consumed). */
size_t wire_len = head_len + pay_len + ctx->sig_len;

/* Small-frame fast path: coalesce the whole frame into ONE transport write.
* A control packet's payload IS tx_ctrl_buf (send_app), so it must be
* relocated to its final offset FIRST — memmove is overlap-safe — before
* the frame header overwrites its front; the HMAC above sampled the
* original bytes, which the in-place shift preserves. Capped by the smaller
* of the coalesce limit and tx_ctrl_buf so shrinking either knob stays safe. */
if (wire_len < TAI_FRAME_COALESCE_LIMIT &&
wire_len <= sizeof(ctx->tx_ctrl_buf)) {
uint8_t *buf = ctx->tx_ctrl_buf;
if (pay_len) /* pay is NULL when 0 */
memmove(buf + head_len, pay, pay_len); /* pay may == buf (control) */
memcpy(buf, head, head_len);
memcpy(buf + head_len + pay_len, ctx->tx_sig, ctx->sig_len);
return (ctx_io_send(ctx, buf, wire_len) == TAI_OK) ? TAI_OK : TAI_ERR_NET;
}

/* Large-frame path: stay zero-copy, 2-3 writes. From here on, any failure
* has committed bytes to the wire and desyncs the stream: return TAI_ERR_NET
* (distinct from the pre-wire errors above so the caller knows the sequence
* number was consumed). */
int rc = ctx_io_send(ctx, head, head_len);
if (rc != TAI_OK) return TAI_ERR_NET;
if (pay_len) {
Expand Down
33 changes: 25 additions & 8 deletions modules/rtc-tcp-client/src/tai_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -103,22 +103,36 @@
#ifndef TAI_FRAG_BUF_SIZE
# define TAI_FRAG_BUF_SIZE 32000U
#endif

/* Scatter-gather header buffer: [5-byte frame header][app header] for one
* frame. Bounds the application header (pkt byte + attr block + media/text
* header); the streamed payload is never copied here. */
#ifndef TAI_TX_HDR_BUF_SIZE
# define TAI_TX_HDR_BUF_SIZE 256U
#endif

/* Small-frame coalesce threshold: a whole frame (frame hdr + app hdr + payload
* + signature) STRICTLY smaller than this is copied into tx_ctrl_buf and sent
* as one transport write (one TLS record instead of 2-3); at or above it the
* frame keeps the zero-copy scatter-gather path. The send path also caps
* coalescing at TAI_TX_CTRL_BUF_SIZE, so shrinking either knob is safe — it
* only narrows the size window that gets coalesced. */
#ifndef TAI_FRAME_COALESCE_LIMIT
# define TAI_FRAME_COALESCE_LIMIT 512U
#endif

/* Control-packet assembly buffer. Must hold the largest control application
* packet — dominated by the session/event JSON escaped into attr 111. The
* SessionNew / EventStart packet is roughly 2*strlen(JSON) + ~115 bytes of
* framing/attrs, so the session/event JSON must satisfy that bound or
* SessionNew/EventStart returns TAI_ERR_MEM. Default 1024 ≈ 4x the largest
* packet the bundled examples build (~260 B) and fits JSON up to ~700 chars;
* raise it (e.g. 2048/4096) for richer session configs. */
* raise it (e.g. 2048/4096) for richer session configs. It doubles as the
* small-frame coalesce scratch (see TAI_FRAME_COALESCE_LIMIT) — a control
* packet is shifted in place inside the same buffer, never copied out. */
#ifndef TAI_TX_CTRL_BUF_SIZE
# define TAI_TX_CTRL_BUF_SIZE 1024U
#endif
/* Scatter-gather header buffer: [5-byte frame header][app header] for one
* frame. Bounds the application header (pkt byte + attr block + media/text
* header); the streamed payload is never copied here. */
#ifndef TAI_TX_HDR_BUF_SIZE
# define TAI_TX_HDR_BUF_SIZE 256U
#endif

/* Maximum attributes decoded from a single packet */
#ifndef TAI_MAX_ATTRS
Expand Down Expand Up @@ -315,7 +329,10 @@ struct tai_ctx {
* sent zero-copy from the caller's buffer; tx_sig holds the per-frame
* signature computed before any byte goes on the wire. tx_ctrl_buf
* assembles a control packet (its attribute block can carry the user
* session/event JSON) which is then sent as that zero-copy payload. */
* session/event JSON) which is then sent as that zero-copy payload, and
* doubles as the coalesce scratch for whole frames under
* TAI_FRAME_COALESCE_LIMIT (one transport write; a control packet shifts
* in place within the same buffer). */
uint8_t tx_ctrl_buf[TAI_TX_CTRL_BUF_SIZE];
uint8_t tx_hdr_buf[TAI_TX_HDR_BUF_SIZE];
uint8_t tx_sig[32];
Expand Down
75 changes: 73 additions & 2 deletions modules/rtc-tcp-client/test/test_integration.c
Original file line number Diff line number Diff line change
Expand Up @@ -1610,9 +1610,12 @@ static void test_sg_send_failure(void)
(void)tai_loopback_pop_sent(tx, sizeof(tx));

/* Header write (call 0) ok, payload write (call 1) fails: mid-frame, bytes
* already committed -> TAI_ERR_NET, but the SDK must NOT tear down. */
* already committed -> TAI_ERR_NET, but the SDK must NOT tear down.
* pcm must push the whole frame over TAI_FRAME_COALESCE_LIMIT so it
* takes the 2-3-write scatter path — a coalesced small frame is a
* single write and has no mid-frame boundary to fail on. */
tai_loopback_fail_send_after(1);
uint8_t pcm[320]; memset(pcm, 0x42, sizeof(pcm));
uint8_t pcm[TAI_FRAME_COALESCE_LIMIT]; memset(pcm, 0x42, sizeof(pcm));
CHECK_EQ_INT(tai_send_audio_chunk(ctx, pcm, sizeof(pcm)), TAI_ERR_NET);

tai_loopback_fail_send_after(-1);
Expand Down Expand Up @@ -1787,6 +1790,73 @@ static void test_sg_text_fragmented_uplink(void)
tai_ctx_deinit(ctx);
}

/* =========================================================================
* Test: small-frame coalesce boundary (TAI_FRAME_COALESCE_LIMIT). One text
* send emits 4 packets: EventStart / PayloadsEnd / EventEnd are CONTROL frames
* assembled in tx_ctrl_buf — so on the coalesced path the payload and the
* scratch buffer are the SAME memory (the aliasing hazard) — while the Text
* frame's payload lives in the caller's buffer. A body sized so the whole Text
* frame (5 frame hdr + 5 text hdr + body + 32 sig) is one byte UNDER the limit
* coalesces; one byte larger crosses to the zero-copy scatter path. Every
* frame must land HMAC-intact on both sides, with byte-exact Text payloads.
* ========================================================================= */
static void test_sg_coalesce_boundary(void)
{
SECTION("sg_coalesce_boundary");
static uint8_t ctx_mem[sizeof(struct tai_ctx)];
tai_ctx_t *ctx = setup_ctx(ctx_mem);
CHECK(ctx != NULL);
CHECK_EQ_INT(tai_connect(ctx), TAI_OK);

static uint8_t tx[4096];
(void)tai_loopback_pop_sent(tx, sizeof(tx)); /* discard handshake */

/* text hdr = pkt byte + [id:2][flags:1][varint seq:1] = 5 bytes. */
static char under[TAI_FRAME_COALESCE_LIMIT - 5 - 5 - 32 - 1];
static char over [TAI_FRAME_COALESCE_LIMIT - 5 - 5 - 32];
for (size_t i = 0; i < sizeof(under); i++) under[i] = (char)('a' + (i % 26));
for (size_t i = 0; i < sizeof(over); i++) over[i] = (char)('A' + (i % 26));

for (int round = 0; round < 2; round++) {
const char *body = round ? over : under;
size_t body_len = round ? sizeof(over) : sizeof(under);
CHECK_EQ_INT(tai_send_text(ctx, body, body_len), TAI_OK);
sleep_ms(10);
size_t txn = tai_loopback_pop_sent(tx, sizeof(tx));
CHECK(txn > 0);

/* Walk every frame of the event: all HMAC-verified (the aliased
* coalesce corruption would fail verify/decode), exactly one TEXT. */
size_t off = 0;
int ntext = 0, nframes = 0;
while (off < txn) {
size_t flen = tai_frame_total_size(tx + off, txn - off);
if (flen == 0 || flen > txn - off) break;
CHECK(tai_frame_verify(tx + off, flen, 32, ctx->sign_key, ctx->pal) == TAI_OK);
uint8_t frag, pt; uint16_t seq; const uint8_t *pl; size_t pll;
CHECK(tai_frame_decode(tx + off, flen, 32, &frag, &seq, &pl, &pll) == TAI_OK);
CHECK_EQ_INT(frag, TAI_FRAG_NONE); /* never fragments here */
tai_attr_t attrs[TAI_MAX_ATTRS]; int na = 0;
const uint8_t *payload; size_t payload_len;
CHECK(tai_packet_decode(TAI_VER_21, pl, pll, &pt, attrs, TAI_MAX_ATTRS,
&na, &payload, &payload_len) == TAI_OK);
if (pt == TAI_PKT_TEXT) {
ntext++;
/* decoded payload drops the pkt byte: [id:2][flags:1][varint] + body */
CHECK_EQ_INT(payload_len, 4 + body_len);
CHECK(memcmp(payload + 4, body, body_len) == 0);
}
nframes++;
off += flen;
}
CHECK_EQ_INT(nframes, 4); /* Start/Text/PEnd/End */
CHECK_EQ_INT(ntext, 1);
}

tai_disconnect(ctx);
tai_ctx_deinit(ctx);
}

/* =========================================================================
* Test: control-packet attribute JSON vs the tx_ctrl_buf limit (§6.5, finding
* #4). The session/event JSON is escaped into the attribute block on the
Expand Down Expand Up @@ -1980,6 +2050,7 @@ int main(void)
test_sg_audio_fragmented_uplink();
test_sg_image_fragmented_uplink();
test_sg_text_fragmented_uplink();
test_sg_coalesce_boundary();
test_sg_mcp_response();
test_sg_send_failure();
test_sg_control_json_limit();
Expand Down
Loading