M .beads/interactions.jsonl => .beads/interactions.jsonl +1 -0
@@ 21,3 21,4 @@
{"id":"int-b6e91276","kind":"field_change","created_at":"2026-05-19T04:46:39.175032Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-mq7","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Descriptor -> 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 -> 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)."}}
{"id":"int-59f687bd","kind":"field_change","created_at":"2026-05-23T09:41:30.983997Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-y1n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"C-side singular-scalar encode landed in runtime/pb/c/c_runtime.c. New entry pb.c_runtime.encode(plan, msg) -> 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->enum_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."}}
{"id":"int-32f1e096","kind":"field_change","created_at":"2026-05-23T10:00:25.652311Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-mz6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"C-side singular scalar/enum/string/bytes decode. pb.c_runtime.decode(plan, bytes) -> table mirrors mode=full output: int64 family pushes cdata via luaL_pushint64/pushuint64, others Lua number/string/boolean. Linear field_number scan over plan->fields 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."}}
+{"id":"int-634d0065","kind":"field_change","created_at":"2026-05-23T10:24:19.417709Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-hwe","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"ra6 3d: sub-message encode/decode (recursion) — refactored encode_lua/decode_lua into reusable encode_body/decode_body, added singular sub-message encode (force-promoted parent heap_idx before recursion to make lua_settop safe) and decode (temporarily shrunk c->len for bounded inner read). 22 new tests cover round-trip, empty sub-message presence, proto3-optional fields, 5-level depth (new test/proto/c_nested.proto), parent-buffer regrow path, truncated-input errors. Full suite 854/854 with PB_ENABLE_C=1."}}
M .beads/issues.jsonl => .beads/issues.jsonl +1 -1
@@ 1,6 1,6 @@
{"_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-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":"closed","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:24Z","created_by":"Eugene Blikh","updated_at":"2026-05-23T10:24:19Z","started_at":"2026-05-23T10:11:41Z","closed_at":"2026-05-23T10:24:19Z","close_reason":"ra6 3d: sub-message encode/decode (recursion) — refactored encode_lua/decode_lua into reusable encode_body/decode_body, added singular sub-message encode (force-promoted parent heap_idx before recursion to make lua_settop safe) and decode (temporarily shrunk c-\u003elen for bounded inner read). 22 new tests cover round-trip, empty sub-message presence, proto3-optional fields, 5-level depth (new test/proto/c_nested.proto), parent-buffer regrow path, truncated-input errors. Full suite 854/854 with PB_ENABLE_C=1.","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":"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}
A examples/expected/full/c_nested/c_nested_pb.lua => examples/expected/full/c_nested/c_nested_pb.lua +625 -0
@@ 0,0 1,625 @@
+-- Code generated by protoc-gen-tarantool. DO NOT EDIT.
+-- source: c_nested.proto
+-- syntax: proto3
+-- package: c_nested
+
+local pb = require("pb")
+local wire = pb.wire
+local string_byte = string.byte
+local band = bit.band
+local rshift = bit.rshift
+
+local M = {}
+
+M.options = {go_package = "tarantoolpb_synthetic/c_nested"}
+
+-- Pre-declare message descriptors so cross-references resolve.
+M.L1_descriptor = {name = "c_nested.L1"}
+M.L2_descriptor = {name = "c_nested.L2"}
+M.L3_descriptor = {name = "c_nested.L3"}
+M.L4_descriptor = {name = "c_nested.L4"}
+M.L5_descriptor = {name = "c_nested.L5"}
+
+-- Message: c_nested.L1
+M.L1_descriptor.fields = {
+ {name="next", id=1, kind='message', message=M.L2_descriptor},
+ {name="v", id=2, kind='scalar', proto_type="int32"},
+}
+pb.finalize_message(M.L1_descriptor)
+M.L1_fields = pb.field_names({
+ next = "next",
+ v = "v",
+})
+
+-- Message: c_nested.L2
+M.L2_descriptor.fields = {
+ {name="next", id=1, kind='message', message=M.L3_descriptor},
+ {name="v", id=2, kind='scalar', proto_type="int32"},
+}
+pb.finalize_message(M.L2_descriptor)
+M.L2_fields = pb.field_names({
+ next = "next",
+ v = "v",
+})
+
+-- Message: c_nested.L3
+M.L3_descriptor.fields = {
+ {name="next", id=1, kind='message', message=M.L4_descriptor},
+ {name="v", id=2, kind='scalar', proto_type="int32"},
+}
+pb.finalize_message(M.L3_descriptor)
+M.L3_fields = pb.field_names({
+ next = "next",
+ v = "v",
+})
+
+-- Message: c_nested.L4
+M.L4_descriptor.fields = {
+ {name="next", id=1, kind='message', message=M.L5_descriptor},
+ {name="v", id=2, kind='scalar', proto_type="int32"},
+}
+pb.finalize_message(M.L4_descriptor)
+M.L4_fields = pb.field_names({
+ next = "next",
+ v = "v",
+})
+
+-- Message: c_nested.L5
+M.L5_descriptor.fields = {
+ {name="v", id=2, kind='scalar', proto_type="int32"},
+}
+pb.finalize_message(M.L5_descriptor)
+M.L5_fields = pb.field_names({
+ v = "v",
+})
+
+-- EmmyLua / lua-language-server type annotations.
+-- These are comments — no runtime effect. They give editors
+-- autocomplete and type-checking for the generated wrappers.
+--- 5-level chain of singular sub-messages. Used by the bd-hwe (ra6 3d)
+--- C-runtime tests to verify recursion across non-trivial depth. Each
+--- level carries an int32 so the encoded body at each level is
+--- non-empty (catches off-by-one bugs in the length-prefix path).
+---@class c_nested.L1
+---@field next c_nested.L2
+---@field v integer
+
+---@class c_nested.L2
+---@field next c_nested.L3
+---@field v integer
+
+---@class c_nested.L3
+---@field next c_nested.L4
+---@field v integer
+
+---@class c_nested.L4
+---@field next c_nested.L5
+---@field v integer
+
+---@class c_nested.L5
+---@field v integer
+
+---@param t? c_nested.L1
+---@return c_nested.L1
+function M.L1_new(t) return t or {} end
+
+---@param t c_nested.L1
+---@return string
+function M.L1_encode(t)
+ if type(t) ~= 'table' then
+ error("expected table for c_nested.L1, got " .. type(t), 0)
+ end
+ local out, n = {}, 0
+ local v
+ -- field 1: next
+ v = t.next
+ if v ~= nil or type(v) == 'cdata' then
+ local _b = M.L2_encode(v)
+ n = n + 1; out[n] = "\x0a"
+ local _len = #_b
+ if _len < 128 then
+ n = n + 1; out[n] = string.char(_len)
+ else
+ n = n + 1; out[n] = wire.encode_varint(_len)
+ end
+ n = n + 1; out[n] = _b
+ end
+ -- field 2: v
+ v = t.v
+ if v ~= nil and v ~= 0 then
+ n = n + 1; out[n] = "\x10"
+ n = n + 1; out[n] = wire.encode_int32(v)
+ end
+ local _exts = t._extensions
+ if _exts ~= nil then
+ local _elist = M.L1_descriptor.extensions_list
+ if _elist ~= nil then
+ for _i = 1, #_elist do
+ local _ext = _elist[_i]
+ local _ev = _exts[_ext.full_name]
+ if _ev ~= nil then
+ pb.codec.encode_field(_ext, _ev, out, true)
+ end
+ end
+ n = #out
+ end
+ end
+ local _uf = t._unknown_fields
+ if _uf ~= nil and _uf ~= '' then n = n + 1; out[n] = _uf end
+ return table.concat(out)
+end
+
+---@param b string
+---@return c_nested.L1
+function M.L1_decode(buf)
+ if type(buf) ~= 'string' then
+ error("expected string for c_nested.L1 decode, got " .. type(buf), 0)
+ end
+ local result = {}
+ local pos, len = 1, #buf
+ local _uf
+ while pos <= len do
+ local _tag_start = pos
+ local id, wt
+ local _b = string_byte(buf, pos)
+ if _b ~= nil and _b < 0x80 then
+ wt = band(_b, 7)
+ if wt >= 6 then error("illegal wire type " .. wt, 0) end
+ id = rshift(_b, 3)
+ if id == 0 then error("illegal field number 0", 0) end
+ pos = pos + 1
+ else
+ id, wt, pos = wire.decode_tag(buf, pos)
+ end
+ if id == 1 then
+ local payload
+ payload, pos = wire.decode_len(buf, pos)
+ local prev = result.next
+ if prev == nil then
+ result.next = M.L2_decode(payload)
+ else
+ pb.codec.merge_message(M.L2_descriptor, prev, M.L2_decode(payload))
+ end
+ elseif id == 2 then
+ local val
+ val, pos = wire.decode_int32(buf, pos)
+ result.v = val
+ else
+ local _ebid = M.L1_descriptor.extensions_by_id
+ local _ext = _ebid and _ebid[id] or nil
+ if _ext ~= nil then
+ pos = pb.codec.decode_extension(_ext, buf, pos, wt, result)
+ else
+ pos = wire.skip_field(buf, pos, wt, id)
+ if _uf == nil then _uf = {} end
+ _uf[#_uf + 1] = buf:sub(_tag_start, pos - 1)
+ end
+ end
+ end
+ if _uf ~= nil then result._unknown_fields = table.concat(_uf) end
+ return result
+end
+
+---@param b string
+---@return pb.MessageView
+function M.L1_decode_lazy(b) return pb.decode_lazy(M.L1_descriptor, b) end
+---@param t c_nested.L1
+---@param opts? {single_line: boolean?, indent: string?}
+---@return string
+function M.L1_text(t, opts) return pb.text.encode(M.L1_descriptor, t, opts) end
+
+---@param t? c_nested.L2
+---@return c_nested.L2
+function M.L2_new(t) return t or {} end
+
+---@param t c_nested.L2
+---@return string
+function M.L2_encode(t)
+ if type(t) ~= 'table' then
+ error("expected table for c_nested.L2, got " .. type(t), 0)
+ end
+ local out, n = {}, 0
+ local v
+ -- field 1: next
+ v = t.next
+ if v ~= nil or type(v) == 'cdata' then
+ local _b = M.L3_encode(v)
+ n = n + 1; out[n] = "\x0a"
+ local _len = #_b
+ if _len < 128 then
+ n = n + 1; out[n] = string.char(_len)
+ else
+ n = n + 1; out[n] = wire.encode_varint(_len)
+ end
+ n = n + 1; out[n] = _b
+ end
+ -- field 2: v
+ v = t.v
+ if v ~= nil and v ~= 0 then
+ n = n + 1; out[n] = "\x10"
+ n = n + 1; out[n] = wire.encode_int32(v)
+ end
+ local _exts = t._extensions
+ if _exts ~= nil then
+ local _elist = M.L2_descriptor.extensions_list
+ if _elist ~= nil then
+ for _i = 1, #_elist do
+ local _ext = _elist[_i]
+ local _ev = _exts[_ext.full_name]
+ if _ev ~= nil then
+ pb.codec.encode_field(_ext, _ev, out, true)
+ end
+ end
+ n = #out
+ end
+ end
+ local _uf = t._unknown_fields
+ if _uf ~= nil and _uf ~= '' then n = n + 1; out[n] = _uf end
+ return table.concat(out)
+end
+
+---@param b string
+---@return c_nested.L2
+function M.L2_decode(buf)
+ if type(buf) ~= 'string' then
+ error("expected string for c_nested.L2 decode, got " .. type(buf), 0)
+ end
+ local result = {}
+ local pos, len = 1, #buf
+ local _uf
+ while pos <= len do
+ local _tag_start = pos
+ local id, wt
+ local _b = string_byte(buf, pos)
+ if _b ~= nil and _b < 0x80 then
+ wt = band(_b, 7)
+ if wt >= 6 then error("illegal wire type " .. wt, 0) end
+ id = rshift(_b, 3)
+ if id == 0 then error("illegal field number 0", 0) end
+ pos = pos + 1
+ else
+ id, wt, pos = wire.decode_tag(buf, pos)
+ end
+ if id == 1 then
+ local payload
+ payload, pos = wire.decode_len(buf, pos)
+ local prev = result.next
+ if prev == nil then
+ result.next = M.L3_decode(payload)
+ else
+ pb.codec.merge_message(M.L3_descriptor, prev, M.L3_decode(payload))
+ end
+ elseif id == 2 then
+ local val
+ val, pos = wire.decode_int32(buf, pos)
+ result.v = val
+ else
+ local _ebid = M.L2_descriptor.extensions_by_id
+ local _ext = _ebid and _ebid[id] or nil
+ if _ext ~= nil then
+ pos = pb.codec.decode_extension(_ext, buf, pos, wt, result)
+ else
+ pos = wire.skip_field(buf, pos, wt, id)
+ if _uf == nil then _uf = {} end
+ _uf[#_uf + 1] = buf:sub(_tag_start, pos - 1)
+ end
+ end
+ end
+ if _uf ~= nil then result._unknown_fields = table.concat(_uf) end
+ return result
+end
+
+---@param b string
+---@return pb.MessageView
+function M.L2_decode_lazy(b) return pb.decode_lazy(M.L2_descriptor, b) end
+---@param t c_nested.L2
+---@param opts? {single_line: boolean?, indent: string?}
+---@return string
+function M.L2_text(t, opts) return pb.text.encode(M.L2_descriptor, t, opts) end
+
+---@param t? c_nested.L3
+---@return c_nested.L3
+function M.L3_new(t) return t or {} end
+
+---@param t c_nested.L3
+---@return string
+function M.L3_encode(t)
+ if type(t) ~= 'table' then
+ error("expected table for c_nested.L3, got " .. type(t), 0)
+ end
+ local out, n = {}, 0
+ local v
+ -- field 1: next
+ v = t.next
+ if v ~= nil or type(v) == 'cdata' then
+ local _b = M.L4_encode(v)
+ n = n + 1; out[n] = "\x0a"
+ local _len = #_b
+ if _len < 128 then
+ n = n + 1; out[n] = string.char(_len)
+ else
+ n = n + 1; out[n] = wire.encode_varint(_len)
+ end
+ n = n + 1; out[n] = _b
+ end
+ -- field 2: v
+ v = t.v
+ if v ~= nil and v ~= 0 then
+ n = n + 1; out[n] = "\x10"
+ n = n + 1; out[n] = wire.encode_int32(v)
+ end
+ local _exts = t._extensions
+ if _exts ~= nil then
+ local _elist = M.L3_descriptor.extensions_list
+ if _elist ~= nil then
+ for _i = 1, #_elist do
+ local _ext = _elist[_i]
+ local _ev = _exts[_ext.full_name]
+ if _ev ~= nil then
+ pb.codec.encode_field(_ext, _ev, out, true)
+ end
+ end
+ n = #out
+ end
+ end
+ local _uf = t._unknown_fields
+ if _uf ~= nil and _uf ~= '' then n = n + 1; out[n] = _uf end
+ return table.concat(out)
+end
+
+---@param b string
+---@return c_nested.L3
+function M.L3_decode(buf)
+ if type(buf) ~= 'string' then
+ error("expected string for c_nested.L3 decode, got " .. type(buf), 0)
+ end
+ local result = {}
+ local pos, len = 1, #buf
+ local _uf
+ while pos <= len do
+ local _tag_start = pos
+ local id, wt
+ local _b = string_byte(buf, pos)
+ if _b ~= nil and _b < 0x80 then
+ wt = band(_b, 7)
+ if wt >= 6 then error("illegal wire type " .. wt, 0) end
+ id = rshift(_b, 3)
+ if id == 0 then error("illegal field number 0", 0) end
+ pos = pos + 1
+ else
+ id, wt, pos = wire.decode_tag(buf, pos)
+ end
+ if id == 1 then
+ local payload
+ payload, pos = wire.decode_len(buf, pos)
+ local prev = result.next
+ if prev == nil then
+ result.next = M.L4_decode(payload)
+ else
+ pb.codec.merge_message(M.L4_descriptor, prev, M.L4_decode(payload))
+ end
+ elseif id == 2 then
+ local val
+ val, pos = wire.decode_int32(buf, pos)
+ result.v = val
+ else
+ local _ebid = M.L3_descriptor.extensions_by_id
+ local _ext = _ebid and _ebid[id] or nil
+ if _ext ~= nil then
+ pos = pb.codec.decode_extension(_ext, buf, pos, wt, result)
+ else
+ pos = wire.skip_field(buf, pos, wt, id)
+ if _uf == nil then _uf = {} end
+ _uf[#_uf + 1] = buf:sub(_tag_start, pos - 1)
+ end
+ end
+ end
+ if _uf ~= nil then result._unknown_fields = table.concat(_uf) end
+ return result
+end
+
+---@param b string
+---@return pb.MessageView
+function M.L3_decode_lazy(b) return pb.decode_lazy(M.L3_descriptor, b) end
+---@param t c_nested.L3
+---@param opts? {single_line: boolean?, indent: string?}
+---@return string
+function M.L3_text(t, opts) return pb.text.encode(M.L3_descriptor, t, opts) end
+
+---@param t? c_nested.L4
+---@return c_nested.L4
+function M.L4_new(t) return t or {} end
+
+---@param t c_nested.L4
+---@return string
+function M.L4_encode(t)
+ if type(t) ~= 'table' then
+ error("expected table for c_nested.L4, got " .. type(t), 0)
+ end
+ local out, n = {}, 0
+ local v
+ -- field 1: next
+ v = t.next
+ if v ~= nil or type(v) == 'cdata' then
+ local _b = M.L5_encode(v)
+ n = n + 1; out[n] = "\x0a"
+ local _len = #_b
+ if _len < 128 then
+ n = n + 1; out[n] = string.char(_len)
+ else
+ n = n + 1; out[n] = wire.encode_varint(_len)
+ end
+ n = n + 1; out[n] = _b
+ end
+ -- field 2: v
+ v = t.v
+ if v ~= nil and v ~= 0 then
+ n = n + 1; out[n] = "\x10"
+ n = n + 1; out[n] = wire.encode_int32(v)
+ end
+ local _exts = t._extensions
+ if _exts ~= nil then
+ local _elist = M.L4_descriptor.extensions_list
+ if _elist ~= nil then
+ for _i = 1, #_elist do
+ local _ext = _elist[_i]
+ local _ev = _exts[_ext.full_name]
+ if _ev ~= nil then
+ pb.codec.encode_field(_ext, _ev, out, true)
+ end
+ end
+ n = #out
+ end
+ end
+ local _uf = t._unknown_fields
+ if _uf ~= nil and _uf ~= '' then n = n + 1; out[n] = _uf end
+ return table.concat(out)
+end
+
+---@param b string
+---@return c_nested.L4
+function M.L4_decode(buf)
+ if type(buf) ~= 'string' then
+ error("expected string for c_nested.L4 decode, got " .. type(buf), 0)
+ end
+ local result = {}
+ local pos, len = 1, #buf
+ local _uf
+ while pos <= len do
+ local _tag_start = pos
+ local id, wt
+ local _b = string_byte(buf, pos)
+ if _b ~= nil and _b < 0x80 then
+ wt = band(_b, 7)
+ if wt >= 6 then error("illegal wire type " .. wt, 0) end
+ id = rshift(_b, 3)
+ if id == 0 then error("illegal field number 0", 0) end
+ pos = pos + 1
+ else
+ id, wt, pos = wire.decode_tag(buf, pos)
+ end
+ if id == 1 then
+ local payload
+ payload, pos = wire.decode_len(buf, pos)
+ local prev = result.next
+ if prev == nil then
+ result.next = M.L5_decode(payload)
+ else
+ pb.codec.merge_message(M.L5_descriptor, prev, M.L5_decode(payload))
+ end
+ elseif id == 2 then
+ local val
+ val, pos = wire.decode_int32(buf, pos)
+ result.v = val
+ else
+ local _ebid = M.L4_descriptor.extensions_by_id
+ local _ext = _ebid and _ebid[id] or nil
+ if _ext ~= nil then
+ pos = pb.codec.decode_extension(_ext, buf, pos, wt, result)
+ else
+ pos = wire.skip_field(buf, pos, wt, id)
+ if _uf == nil then _uf = {} end
+ _uf[#_uf + 1] = buf:sub(_tag_start, pos - 1)
+ end
+ end
+ end
+ if _uf ~= nil then result._unknown_fields = table.concat(_uf) end
+ return result
+end
+
+---@param b string
+---@return pb.MessageView
+function M.L4_decode_lazy(b) return pb.decode_lazy(M.L4_descriptor, b) end
+---@param t c_nested.L4
+---@param opts? {single_line: boolean?, indent: string?}
+---@return string
+function M.L4_text(t, opts) return pb.text.encode(M.L4_descriptor, t, opts) end
+
+---@param t? c_nested.L5
+---@return c_nested.L5
+function M.L5_new(t) return t or {} end
+
+---@param t c_nested.L5
+---@return string
+function M.L5_encode(t)
+ if type(t) ~= 'table' then
+ error("expected table for c_nested.L5, got " .. type(t), 0)
+ end
+ local out, n = {}, 0
+ local v
+ -- field 2: v
+ v = t.v
+ if v ~= nil and v ~= 0 then
+ n = n + 1; out[n] = "\x10"
+ n = n + 1; out[n] = wire.encode_int32(v)
+ end
+ local _exts = t._extensions
+ if _exts ~= nil then
+ local _elist = M.L5_descriptor.extensions_list
+ if _elist ~= nil then
+ for _i = 1, #_elist do
+ local _ext = _elist[_i]
+ local _ev = _exts[_ext.full_name]
+ if _ev ~= nil then
+ pb.codec.encode_field(_ext, _ev, out, true)
+ end
+ end
+ n = #out
+ end
+ end
+ local _uf = t._unknown_fields
+ if _uf ~= nil and _uf ~= '' then n = n + 1; out[n] = _uf end
+ return table.concat(out)
+end
+
+---@param b string
+---@return c_nested.L5
+function M.L5_decode(buf)
+ if type(buf) ~= 'string' then
+ error("expected string for c_nested.L5 decode, got " .. type(buf), 0)
+ end
+ local result = {}
+ local pos, len = 1, #buf
+ local _uf
+ while pos <= len do
+ local _tag_start = pos
+ local id, wt
+ local _b = string_byte(buf, pos)
+ if _b ~= nil and _b < 0x80 then
+ wt = band(_b, 7)
+ if wt >= 6 then error("illegal wire type " .. wt, 0) end
+ id = rshift(_b, 3)
+ if id == 0 then error("illegal field number 0", 0) end
+ pos = pos + 1
+ else
+ id, wt, pos = wire.decode_tag(buf, pos)
+ end
+ if id == 2 then
+ local val
+ val, pos = wire.decode_int32(buf, pos)
+ result.v = val
+ else
+ local _ebid = M.L5_descriptor.extensions_by_id
+ local _ext = _ebid and _ebid[id] or nil
+ if _ext ~= nil then
+ pos = pb.codec.decode_extension(_ext, buf, pos, wt, result)
+ else
+ pos = wire.skip_field(buf, pos, wt, id)
+ if _uf == nil then _uf = {} end
+ _uf[#_uf + 1] = buf:sub(_tag_start, pos - 1)
+ end
+ end
+ end
+ if _uf ~= nil then result._unknown_fields = table.concat(_uf) end
+ return result
+end
+
+---@param b string
+---@return pb.MessageView
+function M.L5_decode_lazy(b) return pb.decode_lazy(M.L5_descriptor, b) end
+---@param t c_nested.L5
+---@param opts? {single_line: boolean?, indent: string?}
+---@return string
+function M.L5_text(t, opts) return pb.text.encode(M.L5_descriptor, t, opts) end
+
+return M
A examples/expected/runtime/c_nested/c_nested_pb.lua => examples/expected/runtime/c_nested/c_nested_pb.lua +187 -0
@@ 0,0 1,187 @@
+-- Code generated by protoc-gen-tarantool. DO NOT EDIT.
+-- source: c_nested.proto
+-- syntax: proto3
+-- package: c_nested
+
+local pb = require("pb")
+local wire = pb.wire
+local string_byte = string.byte
+local band = bit.band
+local rshift = bit.rshift
+
+local M = {}
+
+M.options = {go_package = "tarantoolpb_synthetic/c_nested"}
+
+-- Pre-declare message descriptors so cross-references resolve.
+M.L1_descriptor = {name = "c_nested.L1"}
+M.L2_descriptor = {name = "c_nested.L2"}
+M.L3_descriptor = {name = "c_nested.L3"}
+M.L4_descriptor = {name = "c_nested.L4"}
+M.L5_descriptor = {name = "c_nested.L5"}
+
+-- Message: c_nested.L1
+M.L1_descriptor.fields = {
+ {name="next", id=1, kind='message', message=M.L2_descriptor},
+ {name="v", id=2, kind='scalar', proto_type="int32"},
+}
+pb.finalize_message(M.L1_descriptor)
+M.L1_fields = pb.field_names({
+ next = "next",
+ v = "v",
+})
+
+-- Message: c_nested.L2
+M.L2_descriptor.fields = {
+ {name="next", id=1, kind='message', message=M.L3_descriptor},
+ {name="v", id=2, kind='scalar', proto_type="int32"},
+}
+pb.finalize_message(M.L2_descriptor)
+M.L2_fields = pb.field_names({
+ next = "next",
+ v = "v",
+})
+
+-- Message: c_nested.L3
+M.L3_descriptor.fields = {
+ {name="next", id=1, kind='message', message=M.L4_descriptor},
+ {name="v", id=2, kind='scalar', proto_type="int32"},
+}
+pb.finalize_message(M.L3_descriptor)
+M.L3_fields = pb.field_names({
+ next = "next",
+ v = "v",
+})
+
+-- Message: c_nested.L4
+M.L4_descriptor.fields = {
+ {name="next", id=1, kind='message', message=M.L5_descriptor},
+ {name="v", id=2, kind='scalar', proto_type="int32"},
+}
+pb.finalize_message(M.L4_descriptor)
+M.L4_fields = pb.field_names({
+ next = "next",
+ v = "v",
+})
+
+-- Message: c_nested.L5
+M.L5_descriptor.fields = {
+ {name="v", id=2, kind='scalar', proto_type="int32"},
+}
+pb.finalize_message(M.L5_descriptor)
+M.L5_fields = pb.field_names({
+ v = "v",
+})
+
+-- EmmyLua / lua-language-server type annotations.
+-- These are comments — no runtime effect. They give editors
+-- autocomplete and type-checking for the generated wrappers.
+--- 5-level chain of singular sub-messages. Used by the bd-hwe (ra6 3d)
+--- C-runtime tests to verify recursion across non-trivial depth. Each
+--- level carries an int32 so the encoded body at each level is
+--- non-empty (catches off-by-one bugs in the length-prefix path).
+---@class c_nested.L1
+---@field next c_nested.L2
+---@field v integer
+
+---@class c_nested.L2
+---@field next c_nested.L3
+---@field v integer
+
+---@class c_nested.L3
+---@field next c_nested.L4
+---@field v integer
+
+---@class c_nested.L4
+---@field next c_nested.L5
+---@field v integer
+
+---@class c_nested.L5
+---@field v integer
+
+---@param t? c_nested.L1
+---@return c_nested.L1
+function M.L1_new(t) return t or {} end
+---@param t c_nested.L1
+---@return string
+function M.L1_encode(t) return pb.encode(M.L1_descriptor, t) end
+---@param b string
+---@return c_nested.L1
+function M.L1_decode(b) return pb.decode(M.L1_descriptor, b) end
+---@param b string
+---@return pb.MessageView
+function M.L1_decode_lazy(b) return pb.decode_lazy(M.L1_descriptor, b) end
+---@param t c_nested.L1
+---@param opts? {single_line: boolean?, indent: string?}
+---@return string
+function M.L1_text(t, opts) return pb.text.encode(M.L1_descriptor, t, opts) end
+
+---@param t? c_nested.L2
+---@return c_nested.L2
+function M.L2_new(t) return t or {} end
+---@param t c_nested.L2
+---@return string
+function M.L2_encode(t) return pb.encode(M.L2_descriptor, t) end
+---@param b string
+---@return c_nested.L2
+function M.L2_decode(b) return pb.decode(M.L2_descriptor, b) end
+---@param b string
+---@return pb.MessageView
+function M.L2_decode_lazy(b) return pb.decode_lazy(M.L2_descriptor, b) end
+---@param t c_nested.L2
+---@param opts? {single_line: boolean?, indent: string?}
+---@return string
+function M.L2_text(t, opts) return pb.text.encode(M.L2_descriptor, t, opts) end
+
+---@param t? c_nested.L3
+---@return c_nested.L3
+function M.L3_new(t) return t or {} end
+---@param t c_nested.L3
+---@return string
+function M.L3_encode(t) return pb.encode(M.L3_descriptor, t) end
+---@param b string
+---@return c_nested.L3
+function M.L3_decode(b) return pb.decode(M.L3_descriptor, b) end
+---@param b string
+---@return pb.MessageView
+function M.L3_decode_lazy(b) return pb.decode_lazy(M.L3_descriptor, b) end
+---@param t c_nested.L3
+---@param opts? {single_line: boolean?, indent: string?}
+---@return string
+function M.L3_text(t, opts) return pb.text.encode(M.L3_descriptor, t, opts) end
+
+---@param t? c_nested.L4
+---@return c_nested.L4
+function M.L4_new(t) return t or {} end
+---@param t c_nested.L4
+---@return string
+function M.L4_encode(t) return pb.encode(M.L4_descriptor, t) end
+---@param b string
+---@return c_nested.L4
+function M.L4_decode(b) return pb.decode(M.L4_descriptor, b) end
+---@param b string
+---@return pb.MessageView
+function M.L4_decode_lazy(b) return pb.decode_lazy(M.L4_descriptor, b) end
+---@param t c_nested.L4
+---@param opts? {single_line: boolean?, indent: string?}
+---@return string
+function M.L4_text(t, opts) return pb.text.encode(M.L4_descriptor, t, opts) end
+
+---@param t? c_nested.L5
+---@return c_nested.L5
+function M.L5_new(t) return t or {} end
+---@param t c_nested.L5
+---@return string
+function M.L5_encode(t) return pb.encode(M.L5_descriptor, t) end
+---@param b string
+---@return c_nested.L5
+function M.L5_decode(b) return pb.decode(M.L5_descriptor, b) end
+---@param b string
+---@return pb.MessageView
+function M.L5_decode_lazy(b) return pb.decode_lazy(M.L5_descriptor, b) end
+---@param t c_nested.L5
+---@param opts? {single_line: boolean?, indent: string?}
+---@return string
+function M.L5_text(t, opts) return pb.text.encode(M.L5_descriptor, t, opts) end
+
+return M
M runtime/pb/c/c_runtime.c => runtime/pb/c/c_runtime.c +209 -65
@@ 775,16 775,23 @@ plan_sub_plan(lua_State *L)
}
/* ---------------------------------------------------------------- *
- * Encode (bd-y1n / ra6 3b): singular scalar fields. *
+ * Encode (bd-y1n / ra6 3b, bd-hwe / ra6 3d). *
* *
- * Repeated/packed, map, and message-typed fields are skipped — *
- * those land in 3d (sub-message) and 3e (repeated/packed). *
+ * Singular scalars (3b) and singular sub-messages (3d). Repeated/ *
+ * packed and map fields are silently skipped — 3e / 3h land them. *
* *
* Buffer strategy: a 4KB stack-backed scratch buffer that promotes *
* to a Lua userdata (GC'd automatically) on overflow. Using *
* `lua_newuserdata` for heap growth means a luaL_error mid-encode *
* doesn't leak — the userdata is still on the stack at the unwind *
* point and gets collected normally. *
+ * *
+ * Recursion shape: encode_body is called once per message; sub- *
+ * message fields recurse by allocating a fresh enc_buf on the C *
+ * stack and re-entering encode_body with the sub-plan. The parent's *
+ * heap_idx is force-established before recursing so it survives *
+ * the lua_settop cleanup at sub-encode exit (see *
+ * encode_submessage_field for the contract). *
* ---------------------------------------------------------------- */
#define ENC_STACK_BUF 4096
@@ 1093,38 1100,83 @@ encode_one_field(lua_State *L, enc_buf *b, pb_plan_field *f, int val_idx)
}
}
-static int
-encode_lua(lua_State *L)
+/* Forward decls for the recursive encode pair. */
+static void encode_body(lua_State *L, enc_buf *b, pb_plan *plan, int msg_idx);
+
+/* Encode one singular sub-message field into the parent buffer `b`.
+ * Lifecycle / stack-management contract:
+ * - Parent's heap is force-established (one 8KB grow) BEFORE recursing
+ * so b->heap_idx is below the saved_top. This way the final
+ * ebuf_reserve on the parent can only either re-use b->heap_idx via
+ * lua_replace (no new stack slot) or — if no further grow is
+ * needed — leave the stack alone. Either way, lua_settop(L,
+ * saved_top) at the end is safe.
+ * - sub-buf is a fresh stack-backed enc_buf; its potential heap
+ * userdata is on the Lua stack above saved_top and is dropped by
+ * the lua_settop. */
+static void
+encode_submessage_field(lua_State *L, enc_buf *b, pb_plan *plan,
+ pb_plan_field *f, int val_idx)
{
- pb_plan *plan = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
- luaL_checktype(L, 2, LUA_TTABLE);
- int msg_idx = 2;
+ if (lua_type(L, val_idx) != LUA_TTABLE)
+ luaL_error(L, "message field requires a table value");
+ if (plan->sub_plans_ref == LUA_NOREF)
+ luaL_error(L, "plan '%s' has no sub-plans table",
+ plan->name != NULL ? plan->name : "?");
+
+ /* Force parent's heap to exist before the sub-encode allocates. */
+ if (b->heap_idx == 0)
+ ebuf_grow(L, b, 1);
+
+ val_idx = abs_idx(L, val_idx);
+ int saved_top = lua_gettop(L);
+
+ lua_rawgeti(L, LUA_REGISTRYINDEX, plan->sub_plans_ref);
+ lua_rawgeti(L, -1, f->sub_plan_idx);
+ pb_plan *subplan = (pb_plan *)lua_touserdata(L, -1);
+ if (subplan == NULL)
+ luaL_error(L, "sub-plan at index %d is not a userdata",
+ f->sub_plan_idx);
+
+ enc_buf sub;
+ ebuf_init(&sub);
+ encode_body(L, &sub, subplan, val_idx);
+
+ /* Write tag + length-varint + body into parent. Parent regrowth
+ * here goes through lua_replace at b->heap_idx (safely below
+ * saved_top) — no stack-frame disruption. */
+ ebuf_reserve(L, b, f->tag_len + 10 + sub.used);
+ ebuf_put_tag(b, f);
+ ebuf_put_varint(b, (uint64_t)sub.used);
+ if (sub.used > 0)
+ ebuf_put_bytes(b, ebuf_base(&sub), sub.used);
+
+ lua_settop(L, saved_top);
+}
+
+static void
+encode_body(lua_State *L, enc_buf *b, pb_plan *plan, int msg_idx)
+{
+ msg_idx = abs_idx(L, msg_idx);
if (plan->has_override) {
- return luaL_error(L,
+ luaL_error(L,
"C encode on plan with override is unsupported (bd-rmf)");
}
- /* Pin the field-names table on the stack for the loop's rawgeti. */
- if (plan->field_names_ref == LUA_NOREF || plan->n_fields == 0) {
- lua_pushliteral(L, "");
- return 1;
- }
+ if (plan->field_names_ref == LUA_NOREF || plan->n_fields == 0)
+ return;
+
lua_rawgeti(L, LUA_REGISTRYINDEX, plan->field_names_ref);
int names_idx = lua_gettop(L);
- enc_buf b;
- ebuf_init(&b);
-
for (int i = 0; i < plan->n_fields; i++) {
pb_plan_field *f = &plan->fields[i];
- /* 3b scope: singular scalar/enum/string/bytes. Repeated, map,
- * and message-typed fields are silently skipped — they land
- * in 3d/3e and will overwrite this short-circuit. */
+ /* Scope for now: singular scalar/enum/string/bytes/message.
+ * Repeated and map are 3e/3h territory — still skipped. */
if (f->repeated) continue;
- if (f->kind == PB_KIND_MAP || f->kind == PB_KIND_MESSAGE)
- continue;
+ if (f->kind == PB_KIND_MAP) continue;
lua_rawgeti(L, names_idx, i + 1); /* push field name */
lua_rawget(L, msg_idx); /* push msg[name] */
@@ 1135,27 1187,55 @@ encode_lua(lua_State *L)
continue;
}
- encode_one_field(L, &b, f, val_idx);
+ if (f->kind == PB_KIND_MESSAGE) {
+ encode_submessage_field(L, b, plan, f, val_idx);
+ } else {
+ encode_one_field(L, b, f, val_idx);
+ }
lua_pop(L, 1);
}
+ lua_pop(L, 1); /* names table */
+}
+
+static int
+encode_lua(lua_State *L)
+{
+ pb_plan *plan = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT);
+ luaL_checktype(L, 2, LUA_TTABLE);
+
+ if (plan->has_override) {
+ return luaL_error(L,
+ "C encode on plan with override is unsupported (bd-rmf)");
+ }
+
+ enc_buf b;
+ ebuf_init(&b);
+ encode_body(L, &b, plan, 2);
+
lua_pushlstring(L, (const char *)ebuf_base(&b), b.used);
return 1;
}
/* ---------------------------------------------------------------- *
- * Decode (bd-mz6 / ra6 3c): singular scalar fields. *
+ * Decode (bd-mz6 / ra6 3c, bd-hwe / ra6 3d). *
* *
- * 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. *
+ * Singular scalars (3c) and singular sub-messages (3d). Repeated *
+ * and map fields are skipped by wire type — 3e / 3h will land *
+ * them. 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. *
+ * *
+ * Nested-message decode bounds the byte range by temporarily *
+ * shrinking c->len to the sub-message's end offset; the wire-prim *
+ * helpers (dec_varint / dec_fixed*) already bounds-check against *
+ * c->len, so this keeps a malformed inner payload from over- *
+ * reading into the outer message's bytes. *
* ---------------------------------------------------------------- */
typedef struct dec_ctx {
@@ 1348,37 1428,74 @@ dec_push_one(dec_ctx *c, pb_plan_field *f)
}
}
-static int
-decode_lua(lua_State *L)
+/* Forward decl for the recursive decode. */
+static void decode_body(dec_ctx *c, pb_plan *plan, int result_idx);
+static void decode_submessage_field(dec_ctx *c, pb_plan_field *f,
+ int sub_plans_idx);
+
+/* Decode one singular sub-message field. On entry, `c->pos` points at
+ * the length-varint byte; on exit, `c->pos == c->pos + plen`. Pushes
+ * the decoded sub-table onto the Lua stack. */
+static void
+decode_submessage_field(dec_ctx *c, pb_plan_field *f, int sub_plans_idx)
{
- 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);
+ lua_State *L = c->L;
+ uint64_t plen = dec_varint(c);
+ if (c->len - c->pos < plen)
+ luaL_error(L, "truncated nested message at offset %d",
+ (int)c->pos);
+
+ lua_rawgeti(L, sub_plans_idx, f->sub_plan_idx);
+ pb_plan *subplan = (pb_plan *)lua_touserdata(L, -1);
+ if (subplan == NULL)
+ luaL_error(L, "sub-plan at index %d is not a userdata",
+ f->sub_plan_idx);
+ lua_pop(L, 1);
+ lua_createtable(L, 0, subplan->n_fields);
+ int sub_result_idx = lua_gettop(L);
+
+ /* Temporarily shrink c->len so the inner decode loop terminates at
+ * the sub-message boundary and so inner wire-prim reads cannot
+ * spill past it. */
+ size_t saved_len = c->len;
+ c->len = c->pos + (size_t)plen;
+ decode_body(c, subplan, sub_result_idx);
+ if (c->pos != c->len)
+ luaL_error(L,
+ "nested message body underflow at offset %d (expected %d)",
+ (int)c->pos, (int)c->len);
+ c->len = saved_len;
+}
+
+static void
+decode_body(dec_ctx *c, pb_plan *plan, int result_idx)
+{
+ lua_State *L = c->L;
if (plan->has_override) {
- return luaL_error(L,
+ 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);
+ result_idx = abs_idx(L, result_idx);
- /* Pin field-names so the loop can rawgeti by 1-based field index. */
- if (plan->field_names_ref != LUA_NOREF)
+ /* Pin field-names + sub-plans so the loop can rawgeti by index. */
+ int names_idx, sub_plans_idx;
+ if (plan->field_names_ref != LUA_NOREF) {
lua_rawgeti(L, LUA_REGISTRYINDEX, plan->field_names_ref);
- else
+ } 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;
+ }
+ names_idx = lua_gettop(L);
+ if (plan->sub_plans_ref != LUA_NOREF) {
+ lua_rawgeti(L, LUA_REGISTRYINDEX, plan->sub_plans_ref);
+ } else {
+ lua_pushnil(L);
+ }
+ sub_plans_idx = lua_gettop(L);
- while (c.pos < c.len) {
- uint64_t tag = dec_varint(&c);
+ 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);
@@ 1392,29 1509,56 @@ decode_lua(lua_State *L)
}
}
- /* 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);
+ /* Out-of-scope shapes for current bd-hwe: repeated, map.
+ * 3e / 3h will replace this skip. */
+ if (f == NULL || f->repeated || f->kind == PB_KIND_MAP) {
+ dec_skip(c, wt);
+ continue;
+ }
+
+ if (f->kind == PB_KIND_MESSAGE) {
+ decode_submessage_field(c, f, sub_plans_idx);
+ /* stack: ..., names, sub_plans, sub_result */
+ int field_idx_1based = (int)(f - plan->fields) + 1;
+ lua_rawgeti(L, names_idx, field_idx_1based);
+ lua_insert(L, -2); /* name, sub_result */
+ lua_rawset(L, result_idx); /* result[name] = sub_result */
continue;
}
- dec_push_one(&c, f); /* stack: ..., names, value */
+ dec_push_one(c, f); /* stack: ..., names, sub_plans, 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 */
+ lua_rawgeti(L, names_idx, field_idx_1based);
+ 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);
+ lua_pop(L, 2); /* sub_plans, names */
+}
+
+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)");
+ }
+
+ lua_createtable(L, 0, plan->n_fields);
+ int result_idx = lua_gettop(L);
+
+ dec_ctx c;
+ c.L = L;
+ c.buf = (const uint8_t *)buf;
+ c.len = buf_len;
+ c.pos = 0;
+
+ decode_body(&c, plan, result_idx);
return 1;
}
M test/c_runtime_decode_test.lua => test/c_runtime_decode_test.lua +64 -11
@@ 129,23 129,76 @@ for _, mode in ipairs({'full', 'runtime'}) do
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.
+ function g.test_skips_repeated_and_map_tags()
+ -- 3d scope: singular message decoded into a sub-table.
+ -- Repeated and map remain skipped (3e/3h replace this).
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
+ emails = {'a@b'}, -- repeated string (skip)
+ address = {street = 'Main'}, -- message (decode)
+ lucky_numbers = {1, 2, 3}, -- repeated packed (skip)
+ ages_by_nickname = {alice = 30}, -- map (skip)
}
local bytes = full_hello.Person_encode(rich)
local c_decoded = c_runtime.decode(plan, bytes)
- t.assert_equals(c_decoded, {name = 'x'})
+ t.assert_equals(c_decoded, {name = 'x', address = {street = 'Main'}})
+ end
+
+ -- ---------- Sub-message decode (bd-hwe / ra6 3d) ----------
+
+ function g.test_singular_submessage_round_trip()
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ local msg = {
+ name = 'x',
+ address = {street = 'Main', city = 'Springfield', zip = 100},
+ }
+ local bytes = full_hello.Person_encode(msg)
+ 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.address.zip, 100)
+ end
+
+ function g.test_empty_submessage_decodes_to_empty_table()
+ -- The Lua codec emits tag + length(0) for an empty sub-message
+ -- table; the C decoder must surface it as {} (not nil).
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ local bytes = full_hello.Person_encode({address = {}})
+ local c_decoded = c_runtime.decode(plan, bytes)
+ t.assert_equals(type(c_decoded.address), 'table')
+ t.assert_equals(c_decoded.address, {})
+ end
+
+ function g.test_submessage_with_proto3_optional()
+ -- Address.apartment='' must survive the round trip.
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ local bytes = full_hello.Person_encode({address = {apartment = ''}})
+ local c_decoded = c_runtime.decode(plan, bytes)
+ t.assert_equals(c_decoded.address.apartment, '')
+ end
+
+ function g.test_nested_5_levels()
+ local cn = require(mode .. '.c_nested.c_nested_pb')
+ local full_cn = require('full.c_nested.c_nested_pb')
+ local msg = {
+ v = 1,
+ next = {v = 2, next = {v = 3, next = {v = 4,
+ next = {v = 5}}}},
+ }
+ local plan = c_runtime.compile_plan(cn.L1_descriptor)
+ local bytes = full_cn.L1_encode(msg)
+ t.assert_equals(c_runtime.decode(plan, bytes),
+ full_cn.L1_decode(bytes))
+ end
+
+ function g.test_truncated_submessage_errors()
+ -- Tag for address (field 5, wire 2 = 0x2a), length-prefix=10,
+ -- but actual body shorter than advertised.
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ t.assert_error_msg_contains('truncated', function()
+ c_runtime.decode(plan, '\x2a\x0a\x0a\x03')
+ end)
end
function g.test_skips_unknown_field_tags()
M test/c_runtime_encode_test.lua => test/c_runtime_encode_test.lua +77 -8
@@ 144,21 144,90 @@ for _, mode in ipairs({'full', 'runtime'}) do
full_hello.Person_encode(msg))
end
- function g.test_skips_repeated_and_message_fields()
- -- 3b scope: repeated/map/message silently skipped (3d/3e will
- -- replace this). Singular fields still encode.
+ function g.test_skips_repeated_and_map_fields()
+ -- 3d scope: singular message now encoded. Repeated and map
+ -- are still silently skipped — 3e/3h will replace this.
local plan = c_runtime.compile_plan(hello.Person_descriptor)
local msg = {
name = 'x',
- emails = {'a@b'}, -- repeated string
- address = {street = 'Main'}, -- message
- lucky_numbers = {1, 2, 3}, -- repeated packed
- ages_by_nickname = {alice = 30}, -- map
+ emails = {'a@b'}, -- repeated string (skip)
+ address = {street = 'Main'}, -- message (encode)
+ lucky_numbers = {1, 2, 3}, -- repeated packed (skip)
+ ages_by_nickname = {alice = 30}, -- map (skip)
}
- local expected = full_hello.Person_encode({name = 'x'})
+ local expected = full_hello.Person_encode(
+ {name = 'x', address = {street = 'Main'}})
t.assert_equals(c_runtime.encode(plan, msg), expected)
end
+ -- ---------- Sub-message encode (bd-hwe / ra6 3d) ----------
+
+ function g.test_singular_submessage_round_trip()
+ -- Person.address (one-level singular sub-message) round-trip.
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ local msg = {
+ name = 'x',
+ address = {street = 'Main', city = 'Springfield', zip = 100},
+ }
+ t.assert_equals(c_runtime.encode(plan, msg),
+ full_hello.Person_encode(msg))
+ end
+
+ function g.test_empty_submessage_emits_tag_with_zero_length()
+ -- Singular sub-message with an empty table — proto3 presence
+ -- semantics require emitting tag + length(0). The Lua codec
+ -- does this; the C codec must match.
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ local msg = {name = 'x', address = {}}
+ t.assert_equals(c_runtime.encode(plan, msg),
+ full_hello.Person_encode(msg))
+ end
+
+ function g.test_submessage_with_proto3_optional_field()
+ -- Address.apartment is proto3-optional. Encode through the
+ -- sub-message must surface the empty string just like the
+ -- top-level Address codec does.
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ local msg = {address = {apartment = ''}}
+ t.assert_equals(c_runtime.encode(plan, msg),
+ full_hello.Person_encode(msg))
+ end
+
+ function g.test_submessage_non_table_value_errors()
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ t.assert_error_msg_contains('table', function()
+ c_runtime.encode(plan, {address = 'not a table'})
+ end)
+ end
+
+ function g.test_nested_5_levels()
+ -- 5-level chain of singular sub-messages. Exercises the
+ -- recursion depth and the cycle-breaking c_plan stash.
+ local cn = require(mode .. '.c_nested.c_nested_pb')
+ local full_cn = require('full.c_nested.c_nested_pb')
+ local msg = {
+ v = 1,
+ next = {v = 2, next = {v = 3, next = {v = 4,
+ next = {v = 5}}}},
+ }
+ local plan = c_runtime.compile_plan(cn.L1_descriptor)
+ t.assert_equals(c_runtime.encode(plan, msg),
+ full_cn.L1_encode(msg))
+ end
+
+ function g.test_large_submessage_triggers_parent_grow()
+ -- Parent buffer starts at 4KB stack-backed; force a parent
+ -- regrow path during the post-recursion ebuf_reserve by
+ -- packing a >4KB string into the sub-message.
+ local plan = c_runtime.compile_plan(hello.Person_descriptor)
+ local msg = {
+ name = 'x',
+ address = {street = string.rep('s', 8192)},
+ }
+ t.assert_equals(c_runtime.encode(plan, msg),
+ full_hello.Person_encode(msg))
+ end
+
function g.test_long_string_grows_buffer()
-- Stack buffer is 4KB; force the heap-promotion path with a
-- string that pushes past it.
A test/proto/c_nested.proto => test/proto/c_nested.proto +13 -0
@@ 0,0 1,13 @@
+syntax = "proto3";
+
+package c_nested;
+
+// 5-level chain of singular sub-messages. Used by the bd-hwe (ra6 3d)
+// C-runtime tests to verify recursion across non-trivial depth. Each
+// level carries an int32 so the encoded body at each level is
+// non-empty (catches off-by-one bugs in the length-prefix path).
+message L1 { L2 next = 1; int32 v = 2; }
+message L2 { L3 next = 1; int32 v = 2; }
+message L3 { L4 next = 1; int32 v = 2; }
+message L4 { L5 next = 1; int32 v = 2; }
+message L5 { int32 v = 2; }