diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 2a8a622787626d0ca663e34947f72502e720609d..375093c5d93e937fde45dff4fdef33285564f553 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,7 +2,7 @@ {"_type":"issue","id":"tarantool-protobuf-exy","title":"ra6 3f: repeated strings + repeated messages (cached stack-idx)","description":"Repeated string fields and repeated message fields. Encode: iterate the Lua array via cached stack idx, write each element (string field or recursive submsg encode). Decode: first hit lazy-creates the result array and stashes its stack index in a per-field slot (sized by plan-\u003en_fields, cap 16 for the spike fixed-size); subsequent hits lua_rawseti directly without re-lookup; one lua_setfield at decode_message exit attaches the array to the result table. This is the SPIKE-VALIDATED PATTERN — naive lazy-getfield is 2x slower at 100KB. Depends on 3d (needs sub-message machinery for repeated messages). Acceptance: Person.emails (repeated string) and Person.friends (repeated Person, self-reference) both round-trip byte-equal to mode=full at 1KB, 10KB, 100KB sizes.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:52Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:52Z","dependencies":[{"issue_id":"tarantool-protobuf-exy","depends_on_id":"tarantool-protobuf-hwe","type":"blocks","created_at":"2026-05-18T23:22:18Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-jc9","title":"ra6 3e: repeated/packed scalar encode/decode","description":"Repeated scalar fields: encode-time, walk the Lua array via cached stack idx; decode-time, lazy-create the array on first hit and append via lua_rawseti with a cached stack idx (NOT lazy lua_getfield per element — that's 2x slower at 100KB per the spike). Packed repeated: length-prefixed varint blob; tight loop in C. Covers int32/sint32/uint32/int64/uint64/fixed32/fixed64/double/bool/enum. Depends on 3b + 3c (need the scalar primitives). Acceptance: Person.lucky_numbers (packed int32) round-trips byte-equal; a fixture with both packed and unpacked repeated scalars round-trips against mode=full at 10/100/1000-element counts.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:33Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:33Z","dependencies":[{"issue_id":"tarantool-protobuf-jc9","depends_on_id":"tarantool-protobuf-mz6","type":"blocks","created_at":"2026-05-18T23:22:07Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-jc9","depends_on_id":"tarantool-protobuf-y1n","type":"blocks","created_at":"2026-05-18T23:22:07Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-hwe","title":"ra6 3d: sub-message encode/decode (recursion)","description":"Nested-message support. Encode: open a sub-buffer, recurse with the submsg plan, prefix parent buffer with tag + length. Decode: bound the byte range, recurse, lua_setfield the resulting table. Sub-buffer stack-backed (4KB) and malloc-promoted, same pattern as the outer buffer. Recursion is by C function call into the same encode/decode routine with a different plan, so depth is bounded by C stack. Depends on 3b + 3c. Acceptance: Person.address (1-level) round-trips; deeper nesting via a 5-level test fixture round-trips byte-equal to mode=full.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:24Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:24Z","dependencies":[{"issue_id":"tarantool-protobuf-hwe","depends_on_id":"tarantool-protobuf-mz6","type":"blocks","created_at":"2026-05-18T23:22:06Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-hwe","depends_on_id":"tarantool-protobuf-y1n","type":"blocks","created_at":"2026-05-18T23:22:05Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":2,"dependent_count":4,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-awv","title":"ra6 3l: 64-bit cdata fidelity","description":"int64/uint64/fixed64/sfixed64 must round-trip as LuaJIT cdata in both encode and decode — never narrowed to a Lua double. Same convention as msgpackffi, net.box, box.tuple, the built-in protobuf. Use luaT_pushuint64/luaT_pushint64 from Tarantool's module.h for the C side. Independent transverse concern — can land in parallel with 3b/3c since it's a per-kind fix rather than a new pipeline stage. Acceptance: a value \u003e 2^53 round-trips byte-equal and remains cdata after decode; luatest test/protobuf_test.lua int64-cdata cases pass with PB_ENABLE_C=1.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:10Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:10Z","dependencies":[{"issue_id":"tarantool-protobuf-awv","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:55Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"tarantool-protobuf-mz6","title":"ra6 3c: decode scalars","description":"C-side decode for scalar wire types. Pre-sized result table via lua_createtable(0, n_fields) using descriptor stats; per-field plan-driven dispatch on wire type and kind; cached luaL_ref-based lua_setfield. Wire→table output must equal mode=full Lua output for the same input bytes. Depends on 3a. Acceptance: Person decode round-trip works for the bytes produced by 3b; the decoded table is shape-identical to pure-Lua decode (verified by running test/interop tests with PB_ENABLE_C=1).","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:06Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:06Z","dependencies":[{"issue_id":"tarantool-protobuf-mz6","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:54Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0} +{"_type":"issue","id":"tarantool-protobuf-mz6","title":"ra6 3c: decode scalars","description":"C-side decode for scalar wire types. Pre-sized result table via lua_createtable(0, n_fields) using descriptor stats; per-field plan-driven dispatch on wire type and kind; cached luaL_ref-based lua_setfield. Wire→table output must equal mode=full Lua output for the same input bytes. Depends on 3a. Acceptance: Person decode round-trip works for the bytes produced by 3b; the decoded table is shape-identical to pure-Lua decode (verified by running test/interop tests with PB_ENABLE_C=1).","status":"closed","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:06Z","created_by":"Eugene Blikh","updated_at":"2026-05-23T10:00:26Z","started_at":"2026-05-23T09:54:01Z","closed_at":"2026-05-23T10:00:26Z","close_reason":"C-side singular scalar/enum/string/bytes decode. pb.c_runtime.decode(plan, bytes) -\u003e table mirrors mode=full output: int64 family pushes cdata via luaL_pushint64/pushuint64, others Lua number/string/boolean. Linear field_number scan over plan-\u003efields per tag; repeated/map/message and unknown tags skipped by wire type — 3d/3e/3i extend later. 26 tests pass (full + runtime modes); full suite 832/832 with PB_ENABLE_C=1, 748/748+84 skipped without.","dependencies":[{"issue_id":"tarantool-protobuf-mz6","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:54Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-y1n","title":"ra6 3b: encode scalars (varint, fixed32/64, double, bool, enum, sint)","description":"C-side encode for scalar wire types into the message's output buffer. Reads field values via cached luaL_ref + lua_rawgeti, dispatches on plan field kind, writes varint/fixed32/fixed64/double/bool/enum/sint into the 4KB stack-backed buffer with malloc-promotion on overflow. Wire-byte output must be byte-equal to mode=full Lua output. Depends on 3a (needs the plan). Acceptance: Person encode round-trip works for {name='x', age=42, balance=-7, user_id=0xDEADBEEFCAFEBABEULL, weight_kg=3.14} with byte-equal output to mode=full pure Lua.","status":"closed","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:03Z","created_by":"Eugene Blikh","updated_at":"2026-05-23T09:41:31Z","started_at":"2026-05-23T09:32:14Z","closed_at":"2026-05-23T09:41:31Z","close_reason":"C-side singular-scalar encode landed in runtime/pb/c/c_runtime.c. New entry pb.c_runtime.encode(plan, msg) -\u003e string. Buffer strategy: 4KB stack scratch promoted to lua_newuserdata on overflow — mid-encode luaL_error doesn't leak (userdata GC'd at unwind). Covered kinds: int32/64, uint32/64, sint32/64, bool, fixed32/64, sfixed32/64, float, double, enum (number/string lookup via field-\u003eenum_ref's by_name), string, bytes. Repeated/map/message silently skipped (3d/3e scope). Proto3 zero-suppression mirrors mode=full exactly: empty strings, zero ints/fixed, +0.0 double skipped; -0.0 emitted via type-pun byte equality (matches Lua's 1/v == -math.huge guard); proto3-optional fields bypass suppression. WKT override plans rejected (3k/bd-rmf scope). New test test/c_runtime_encode_test.lua: 16 cases per mode (full + runtime) = 32 tests covering acceptance (Person {name='x', age=42, balance=-7, user_id=0xDEADBEEFCAFEBABEULL, weight_kg=3.14} byte-equal to mode=full), per-kind sweep, edge cases (proto3-optional empty string, -0.0 double, enum-string lookup + unknown-name error, cdata uint64 fixed64, heap-buffer grow at 8KB string, repeated/message-skip silent). Full suite: 806/806 with PB_ENABLE_C=1, 748/748 + 58 skipped without (silent fallback intact). Unblocks 3d/3e/3g/3i/3j.","dependencies":[{"issue_id":"tarantool-protobuf-y1n","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:53Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-mq7","title":"ra6 3a: descriptor → C plan compiler","description":"Compile finalized Lua descriptors into a C-side plan userdata, stashed on desc.c_plan. The plan carries per-field records (field number, wire type, kind tag, presence offset, default index), pre-encoded tag bytes, luaL_ref slots for cached field names, sub-descriptor pointers, oneof grouping metadata (parallel array, matches desc.oneofs_list), extension range hooks, and WKT override pointers (desc.encode/desc.decode passthrough). Rebuilt at pb.finalize_message time when PB_ENABLE_C=1. Not serialized, not shared across processes. Foundation for all other ra6 sub-issues — no other 3* issue can start until this lands. Acceptance: pb.c_runtime.compile_plan(desc) returns a userdata; plan-\u003en_fields and plan-\u003efields[i].tag are readable from a C-level smoke test; plans for hello.Person and Address build without error.","status":"closed","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:19:58Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:46:39Z","started_at":"2026-05-19T04:26:53Z","closed_at":"2026-05-19T04:46:39Z","close_reason":"Descriptor -\u003e C plan compiler landed in runtime/pb/c/c_runtime.c. Compiles a finalized Lua descriptor into an opaque pb_plan userdata with: per-field records (field_number, wire_type, kind, repeated/packed/optional, pre-encoded tag bytes, sub_plan_idx, oneof_idx, enum_ref), oneof grouping (parallel array with member indices + field oneof_idx back-pointers), WKT override pointers (has_override + override_encode_ref/decode_ref skipping field-walk), extension range hooks (proto2 scaffolding), field-name luaL_ref cache, sub-plan table for cycle-safe recursion. Self-references (Person.friends -\u003e Person) handled by stashing c_plan on desc BEFORE recursing, so resolve_sub_plan returns the same userdata. Idempotent: re-compiling returns cached plan. Build via 'just build-c'. Smoke test at test/c_runtime_plan_test.lua: 26 assertions across both codegen modes (full + runtime) verify module surface, scalar/enum/message/map/repeated/packed shapes, sub-plan resolution, self-reference cycle break, oneof grouping (Result.outcome), idempotency, WKT override detection (Timestamp). Full suite: 771/771 with PB_ENABLE_C=1, 745+26 skipped without (silent fallback verified). Unblocks 3b/3c/3l/3k (y1n, mz6, awv, rmf).","dependencies":[{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-47e","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-z7x","type":"blocks","created_at":"2026-05-18T23:21:53Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":3,"dependent_count":5,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-1eq","title":"C accel spike: generic one-call C codec variant (strategy 3)","description":"Sub-issue of tarantool-protobuf-04c. Implement strategy 3: a generic C codec that gets a descriptor + Lua table once per top-level encode/decode call and owns the inner loop. Internally dispatches per-field on descriptor kind (the work pf6 / ra6 ends up shipping in production). Measure with bench/c_accel/spike_bench.lua. Acceptance: numbers in bench/c_accel/README.md showing the gap to strategy 4 (hand-written, no dispatch).","notes":"Phase A measured strategy 4 ceiling at 3-10x over pure Lua. The 4 vs 3 gap is the key signal for pf6 architecture: \u003c20-40% gap means generic C runtime (ra6) alone is sufficient; \u003e40% gap means we need codegen-emitted C (c0i). For the spike, dispatch can be simple (switch on protoreflect.Kind); no need to design the compiled-plan format here.","status":"closed","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T19:28:08Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T19:44:07Z","closed_at":"2026-05-18T19:44:07Z","close_reason":"Measured as part of the 04c spike; numbers and architecture conclusion captured in 04c close, bench/c_accel/README.md, and bd memory c-accel-spike-04c-final-2026-05-18.","dependencies":[{"issue_id":"tarantool-protobuf-1eq","depends_on_id":"tarantool-protobuf-04c","type":"blocks","created_at":"2026-05-18T22:28:15Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} diff --git a/runtime/pb/c/c_runtime.c b/runtime/pb/c/c_runtime.c index 82ecf6d733701573df4d73527e49dc054e2419a1..ddd282786c8051da37e068113c3847a4ad0fbb29 100644 --- a/runtime/pb/c/c_runtime.c +++ b/runtime/pb/c/c_runtime.c @@ -1144,6 +1144,281 @@ return 1; } /* ---------------------------------------------------------------- * + * Decode (bd-mz6 / ra6 3c): singular scalar fields. * + * * + * Symmetric scope with 3b. Repeated/map/message-typed fields are * + * skipped over by wire-type — 3d/3e/3i will overwrite this with * + * real handling. Unknown tags are skipped silently too; bd-wyp * + * will add round-trip capture. * + * * + * Result-table shape must match mode=full pure-Lua decode. That * + * pins per-kind value types: int64/uint64/sint64/fixed64/sfixed64 * + * push Tarantool int64_t/uint64_t cdata via luaL_pushint64 / * + * luaL_pushuint64; everything else pushes a Lua number, boolean, * + * or string. See runtime/pb/wire.lua decoder comments. * + * ---------------------------------------------------------------- */ + +typedef struct dec_ctx { + lua_State *L; + const uint8_t *buf; + size_t len; + size_t pos; +} dec_ctx; + +static uint64_t +dec_varint(dec_ctx *c) +{ + uint64_t v = 0; + int shift = 0; + for (int i = 0; i < 10; i++) { + if (c->pos >= c->len) + luaL_error(c->L, "truncated varint at offset %d", + (int)c->pos); + uint8_t b = c->buf[c->pos++]; + v |= ((uint64_t)(b & 0x7f)) << shift; + if ((b & 0x80) == 0) + return v; + shift += 7; + } + luaL_error(c->L, "varint exceeds 10 bytes at offset %d", + (int)c->pos); + return 0; +} + +static uint32_t +dec_fixed32(dec_ctx *c) +{ + if (c->len - c->pos < 4) + luaL_error(c->L, "truncated fixed32 at offset %d", + (int)c->pos); + uint32_t v = (uint32_t)c->buf[c->pos] + | ((uint32_t)c->buf[c->pos + 1] << 8) + | ((uint32_t)c->buf[c->pos + 2] << 16) + | ((uint32_t)c->buf[c->pos + 3] << 24); + c->pos += 4; + return v; +} + +static uint64_t +dec_fixed64(dec_ctx *c) +{ + if (c->len - c->pos < 8) + luaL_error(c->L, "truncated fixed64 at offset %d", + (int)c->pos); + uint64_t v = 0; + for (int i = 0; i < 8; i++) + v |= ((uint64_t)c->buf[c->pos + i]) << (i * 8); + c->pos += 8; + return v; +} + +static void +dec_skip(dec_ctx *c, uint8_t wt) +{ + switch (wt) { + case PB_WIRE_VARINT: + (void)dec_varint(c); + break; + case PB_WIRE_I32: + (void)dec_fixed32(c); + break; + case PB_WIRE_I64: + (void)dec_fixed64(c); + break; + case PB_WIRE_LEN: { + uint64_t plen = dec_varint(c); + if (c->len - c->pos < plen) + luaL_error(c->L, "truncated length-delimited payload"); + c->pos += (size_t)plen; + break; + } + default: + luaL_error(c->L, "unsupported wire type %d for skip", (int)wt); + } +} + +static inline int32_t +zigzag32_dec(uint32_t u) +{ + return (int32_t)((u >> 1) ^ (~(u & 1) + 1)); +} + +static inline int64_t +zigzag64_dec(uint64_t u) +{ + return (int64_t)((u >> 1) ^ (~(u & 1) + 1)); +} + +/* Decode a single value for field `f` and push it onto the Lua stack. */ +static void +dec_push_one(dec_ctx *c, pb_plan_field *f) +{ + switch (f->kind) { + case PB_KIND_INT32: { + uint64_t u = dec_varint(c); + int32_t v = (int32_t)(uint32_t)u; + lua_pushinteger(c->L, v); + return; + } + case PB_KIND_INT64: { + uint64_t u = dec_varint(c); + luaL_pushint64(c->L, (int64_t)u); + return; + } + case PB_KIND_UINT32: { + uint64_t u = dec_varint(c); + uint32_t v = (uint32_t)u; + lua_pushnumber(c->L, (double)v); + return; + } + case PB_KIND_UINT64: { + uint64_t u = dec_varint(c); + luaL_pushuint64(c->L, u); + return; + } + case PB_KIND_SINT32: { + uint64_t u = dec_varint(c); + int32_t v = zigzag32_dec((uint32_t)u); + lua_pushinteger(c->L, v); + return; + } + case PB_KIND_SINT64: { + uint64_t u = dec_varint(c); + luaL_pushint64(c->L, zigzag64_dec(u)); + return; + } + case PB_KIND_BOOL: { + uint64_t u = dec_varint(c); + lua_pushboolean(c->L, u != 0); + return; + } + case PB_KIND_ENUM: { + uint64_t u = dec_varint(c); + int32_t v = (int32_t)(uint32_t)u; + lua_pushinteger(c->L, v); + return; + } + case PB_KIND_FIXED32: { + uint32_t u = dec_fixed32(c); + lua_pushnumber(c->L, (double)u); + return; + } + case PB_KIND_SFIXED32: { + uint32_t u = dec_fixed32(c); + lua_pushinteger(c->L, (int32_t)u); + return; + } + case PB_KIND_FLOAT: { + uint32_t u = dec_fixed32(c); + union { uint32_t u; float f; } pun; + pun.u = u; + lua_pushnumber(c->L, (double)pun.f); + return; + } + case PB_KIND_FIXED64: { + uint64_t u = dec_fixed64(c); + luaL_pushuint64(c->L, u); + return; + } + case PB_KIND_SFIXED64: { + uint64_t u = dec_fixed64(c); + luaL_pushint64(c->L, (int64_t)u); + return; + } + case PB_KIND_DOUBLE: { + uint64_t u = dec_fixed64(c); + union { uint64_t u; double d; } pun; + pun.u = u; + lua_pushnumber(c->L, pun.d); + return; + } + case PB_KIND_STRING: + case PB_KIND_BYTES: { + uint64_t plen = dec_varint(c); + if (c->len - c->pos < plen) + luaL_error(c->L, "truncated string/bytes payload"); + lua_pushlstring(c->L, (const char *)(c->buf + c->pos), + (size_t)plen); + c->pos += (size_t)plen; + return; + } + default: + luaL_error(c->L, "dec_push_one: unsupported kind %d", + (int)f->kind); + } +} + +static int +decode_lua(lua_State *L) +{ + pb_plan *plan = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT); + size_t buf_len; + const char *buf = luaL_checklstring(L, 2, &buf_len); + + if (plan->has_override) { + return luaL_error(L, + "C decode on plan with override is unsupported (bd-rmf)"); + } + + /* Pre-size the result table: at most n_fields singular entries. */ + lua_createtable(L, 0, plan->n_fields); + int result_idx = lua_gettop(L); + + /* Pin field-names so the loop can rawgeti by 1-based field index. */ + if (plan->field_names_ref != LUA_NOREF) + lua_rawgeti(L, LUA_REGISTRYINDEX, plan->field_names_ref); + else + lua_pushnil(L); + int names_idx = lua_gettop(L); + + dec_ctx c; + c.L = L; + c.buf = (const uint8_t *)buf; + c.len = buf_len; + c.pos = 0; + + while (c.pos < c.len) { + uint64_t tag = dec_varint(&c); + uint32_t field_number = (uint32_t)(tag >> 3); + uint8_t wt = (uint8_t)(tag & 0x07); + + /* Linear scan over plan->fields. n_fields is typically small; + * tag-keyed dispatch table is a future optimization. */ + pb_plan_field *f = NULL; + for (int i = 0; i < plan->n_fields; i++) { + if (plan->fields[i].field_number == field_number) { + f = &plan->fields[i]; + break; + } + } + + /* Out-of-scope shapes for 3c: repeated, map, message. Skip + * by wire type so 3d/3e/3i can replace this branch later. */ + if (f == NULL + || f->repeated + || f->kind == PB_KIND_MAP + || f->kind == PB_KIND_MESSAGE) { + dec_skip(&c, wt); + continue; + } + + dec_push_one(&c, f); /* stack: ..., names, value */ + + /* result[name] = value. Field index within plan = pointer + * arithmetic against plan->fields[0]; +1 for the 1-based + * field_names_ref convention used elsewhere. */ + int field_idx_1based = (int)(f - plan->fields) + 1; + lua_rawgeti(L, names_idx, field_idx_1based); /* push name */ + lua_insert(L, -2); /* name, value */ + lua_rawset(L, result_idx); /* result[name] = value */ + } + + /* Drop names ref; leave result on top. */ + lua_pop(L, 1); + return 1; +} + +/* ---------------------------------------------------------------- * * Module entry. * * ---------------------------------------------------------------- */ @@ -1157,6 +1432,7 @@ {"plan_oneof_info", plan_oneof_info}, {"plan_has_override", plan_has_override}, {"plan_sub_plan", plan_sub_plan}, {"encode", encode_lua}, + {"decode", decode_lua}, {NULL, NULL}, }; diff --git a/test/c_runtime_decode_test.lua b/test/c_runtime_decode_test.lua new file mode 100644 index 0000000000000000000000000000000000000000..999b8d28a2183eb248294925dec3bb629d92c0c3 --- /dev/null +++ b/test/c_runtime_decode_test.lua @@ -0,0 +1,178 @@ +-- Test for bd-mz6 / ra6 3c: C-side scalar decode. +-- +-- Only runs when PB_ENABLE_C=1 is set in the environment AND the C +-- runtime module is loadable. Otherwise the group is skipped, mirroring +-- the gate on c_runtime_encode_test.lua. +-- +-- Acceptance per bd-mz6: +-- Person decode round-trip works for the bytes produced by 3b; the +-- decoded table is shape-identical to pure-Lua decode for the same +-- input bytes (both codegen modes). + +local t = require('luatest') +local ffi = require('ffi') + +local pb = require('pb') +local c_runtime = pb.c_runtime + +local function skip_if_no_c() + if c_runtime == nil then + t.skip('PB_ENABLE_C not set or pb.c_runtime not available') + end +end + +-- mode=full is the byte-equal reference for both encode and decode. +local full_hello + +for _, mode in ipairs({'full', 'runtime'}) do + local g = t.group('c_runtime_decode.' .. mode) + local hello + + g.before_all(function() + skip_if_no_c() + hello = require(mode .. '.hello.hello_pb') + full_hello = require('full.hello.hello_pb') + end) + + g.before_each(skip_if_no_c) + + -- ---------- Acceptance per bd-mz6 ---------- + + function g.test_acceptance_person_round_trip_from_3b_bytes() + local msg = { + name = 'x', + age = 42, + balance = -7, + user_id = 0xDEADBEEFCAFEBABEULL, + weight_kg = 3.14, + } + local plan = c_runtime.compile_plan(hello.Person_descriptor) + local bytes = c_runtime.encode(plan, msg) + local c_decoded = c_runtime.decode(plan, bytes) + local lua_decoded = full_hello.Person_decode(bytes) + t.assert_equals(c_decoded, lua_decoded, + 'C decode matches mode=full pure-Lua decode shape-for-shape') + end + + -- ---------- Per-kind coverage ---------- + + function g.test_empty_input_produces_empty_table() + local plan = c_runtime.compile_plan(hello.Address_descriptor) + t.assert_equals(c_runtime.decode(plan, ''), {}) + end + + function g.test_address_strings_and_int32() + local plan = c_runtime.compile_plan(hello.Address_descriptor) + local msg = {street = 'Main', city = 'Springfield', zip = 12345} + local bytes = full_hello.Address_encode(msg) + t.assert_equals(c_runtime.decode(plan, bytes), + full_hello.Address_decode(bytes)) + end + + function g.test_proto3_optional_empty_string_present() + -- Address.apartment is proto3-optional; presence beats default. + -- 3b emits the tag for apartment='' (optional bypasses suppression), + -- so decode must surface the empty string in the result table. + local plan = c_runtime.compile_plan(hello.Address_descriptor) + local bytes = full_hello.Address_encode({apartment = ''}) + local lua_decoded = full_hello.Address_decode(bytes) + local c_decoded = c_runtime.decode(plan, bytes) + t.assert_equals(c_decoded, lua_decoded) + t.assert_equals(c_decoded.apartment, '') + end + + function g.test_double_negative_zero_decodes() + local plan = c_runtime.compile_plan(hello.Person_descriptor) + -- LuaJIT constant-folds the literal -0.0 to +0.0, so build the + -- sign-bit-set zero at runtime via a cdata round-trip. Bytes + -- encoded from this value must round-trip to a value whose 1/x + -- is -inf — the only way to distinguish -0.0 from +0.0 in Lua. + local neg_zero = ffi.new('double[1]', 0)[0] * -1 + local bytes = full_hello.Person_encode({weight_kg = neg_zero}) + t.assert(#bytes > 0, '-0.0 should not be zero-suppressed') + local c_decoded = c_runtime.decode(plan, bytes) + t.assert_equals(1 / c_decoded.weight_kg, -math.huge) + end + + function g.test_enum_as_number_value() + local plan = c_runtime.compile_plan(hello.Person_descriptor) + local bytes = full_hello.Person_encode({status = 2}) -- ERROR + local c_decoded = c_runtime.decode(plan, bytes) + local lua_decoded = full_hello.Person_decode(bytes) + t.assert_equals(c_decoded, lua_decoded) + t.assert_equals(c_decoded.status, 2) + end + + function g.test_fixed64_returns_uint64_cdata() + local plan = c_runtime.compile_plan(hello.Person_descriptor) + local msg = {user_id = ffi.new('uint64_t', 0x123456789ABCDEF0)} + local bytes = full_hello.Person_encode(msg) + local c_decoded = c_runtime.decode(plan, bytes) + t.assert_equals(type(c_decoded.user_id), 'cdata') + t.assert_equals(c_decoded.user_id, msg.user_id) + end + + function g.test_sint32_negative_round_trip() + local plan = c_runtime.compile_plan(hello.Person_descriptor) + local bytes = full_hello.Person_encode({balance = -1}) + t.assert_equals(c_runtime.decode(plan, bytes), + full_hello.Person_decode(bytes)) + bytes = full_hello.Person_encode({balance = 0x7fffffff}) + t.assert_equals(c_runtime.decode(plan, bytes), + full_hello.Person_decode(bytes)) + end + + function g.test_bytes_field_preserves_high_bytes() + local plan = c_runtime.compile_plan(hello.Person_descriptor) + local bytes = full_hello.Person_encode({avatar = '\x00\x01\xff\xfe'}) + local c_decoded = c_runtime.decode(plan, bytes) + t.assert_equals(c_decoded.avatar, '\x00\x01\xff\xfe') + end + + function g.test_skips_repeated_and_message_tags() + -- 3c scope: repeated/map/message tags are skipped over by wire + -- type rather than decoded (3d/3e/3i extend this). The result + -- table contains only the singular scalar fields, matching what + -- a 3b-encoded input would have produced anyway. + local plan = c_runtime.compile_plan(hello.Person_descriptor) + -- Build bytes via the full Lua codec, then decode through C. + local rich = { + name = 'x', + emails = {'a@b'}, -- repeated string + address = {street = 'Main'}, -- message + lucky_numbers = {1, 2, 3}, -- repeated packed + ages_by_nickname = {alice = 30}, -- map + } + local bytes = full_hello.Person_encode(rich) + local c_decoded = c_runtime.decode(plan, bytes) + t.assert_equals(c_decoded, {name = 'x'}) + end + + function g.test_skips_unknown_field_tags() + -- Append a synthetic unknown-field tag (id=999, wire varint=0) + -- to a valid encoding. The C decoder should skip it, not error. + local good = full_hello.Person_encode({name = 'x'}) + -- field 999, wire 0 => tag varint = (999<<3) | 0 = 7992 => 0xF8 0x3E + local unknown_tag = '\xf8\x3e\x05' -- tag + varint value 5 + local plan = c_runtime.compile_plan(hello.Person_descriptor) + local c_decoded = c_runtime.decode(plan, good .. unknown_tag) + t.assert_equals(c_decoded, {name = 'x'}) + end + + function g.test_wkt_override_rejected() + -- has_override plans don't expose field arrays; mirror 3b's gate. + local plan = c_runtime.compile_plan(pb.wkt.Timestamp_descriptor) + t.assert_error_msg_contains('override', function() + c_runtime.decode(plan, '') + end) + end + + function g.test_truncated_input_errors() + local plan = c_runtime.compile_plan(hello.Person_descriptor) + -- Tag for field 1 (string) followed by length-prefix without the + -- string bytes that the length claims. + t.assert_error_msg_contains('truncated', function() + c_runtime.decode(plan, '\x0a\x05ab') + end) + end +end