@@ 26,3 26,5 @@
{"id":"int-92bcd95a","kind":"field_change","created_at":"2026-05-23T11:07:38.503706Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-exy","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Acceptance tests landed: Person.emails (repeated string) and Person.friends (repeated Person, self-reference) round-trip byte-equal to mode=full at 1KB/10KB/100KB. Repeated string + message dispatch was already implemented as part of 3e (encode_repeated_field handles MESSAGE + string/bytes branches alongside scalars, decode_body uses cached list_stack_idx[] per-field); this ticket adds the formal size-scaled acceptance coverage."}}
{"id":"int-e3eda42e","kind":"field_change","created_at":"2026-05-23T11:17:06.762081Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-awv","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
{"id":"int-a512d09e","kind":"field_change","created_at":"2026-05-23T13:17:50.84577Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-w3u","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
+{"id":"int-1b6998d4","kind":"field_change","created_at":"2026-05-23T17:07:06.194133Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-asz","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"ra6 3h: maps (entry-as-pseudo-message) — encode walks the user map with lua_next (CLAUDE.md JIT exception), builds each entry payload in a stack-backed sub-buffer with synthetic tag(1,key) + tag(2,value), proto3-elides defaults independently for key and value, and emits outer tag + len + payload into the parent buffer. Map<,message> resolves the value sub-plan once and recurses through encode_body. Decode reads the entry payload bounded, dispatches inner id=1/id=2 (skipping anything else per spec), and lua_rawsets into a lazy-created result map table; missing key or value falls back to the proto3 zero. Reuses the existing list_stack_idx[] slot for the lazy map cache (a field is either repeated or map, never both). 12 new tests cover round-trip for ages_by_nickname (string→int32), nickname_by_age (int32→string), and addresses_by_label (string→message), plus default-elision, multi-key correctness, empty maps, and unknown-inner-id tolerance. Full suite 964/964 with PB_ENABLE_C=1."}}
+{"id":"int-bc164b8a","kind":"field_change","created_at":"2026-05-23T18:04:37.771425Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-wyp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
@@ 18,7 18,7 @@
{"_type":"issue","id":"tarantool-protobuf-rmf","title":"ra6 3k: WKT override-hook passthrough","description":"If a plan's descriptor has desc.encode and desc.decode set (the WKT pattern from runtime/pb/wkt.lua), the C runtime must call those Lua functions instead of walking fields. Plan compiler stores luaL_ref to those functions; encode/decode entry points check first. This is also the extension point for future per-message codegen C (c0i) — same hook. Depends on 3a only (just adds a check at entry to the encode/decode loop). Acceptance: hello.Event (uses Timestamp, Duration, Any, FieldMask, Wrappers, Struct) round-trips with PB_ENABLE_C=1 producing byte-equal output to mode=full; the WKT module is unmodified.","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:21:13Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:21:13Z","dependencies":[{"issue_id":"tarantool-protobuf-rmf","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-m7u","title":"ra6 3i: proto2 — required, defaults, groups, extensions","description":"Proto2 semantic differences from proto3 that the C runtime must honor: (1) Required fields: decode-time enforcement when explicitly opted in (or default to skipping per existing pure-Lua behavior — match exactly); encode-time enforcement same. (2) Field defaults: present in the plan; emit them when missing on encode and substitute on decode where proto2 semantics require. (3) Groups (SGROUP/EGROUP wire format): encode/decode the framed group syntax. (4) Extensions: walk extension ranges, use plan-cached extension descriptor pointers, store in result._extensions. (5) Closed enum semantics for proto2. Depends on 3b + 3c + 3d + 3e + 3g + 3j (it's the join point — proto2 touches scalars, sub-messages, repeated, oneofs, unknown-fields). Acceptance: proto2_basic.BenchPayload and test_messages_proto2 round-trip byte-equal to mode=full at all bench sizes; conformance proto2 suite passes under PB_ENABLE_C=1.","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:21:03Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:21:03Z","dependencies":[{"issue_id":"tarantool-protobuf-m7u","depends_on_id":"tarantool-protobuf-hwe","type":"blocks","created_at":"2026-05-18T23:22:21Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-m7u","depends_on_id":"tarantool-protobuf-jc9","type":"blocks","created_at":"2026-05-18T23:22:22Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-m7u","depends_on_id":"tarantool-protobuf-mz6","type":"blocks","created_at":"2026-05-18T23:22:20Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-m7u","depends_on_id":"tarantool-protobuf-w3u","type":"blocks","created_at":"2026-05-18T23:22:22Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-m7u","depends_on_id":"tarantool-protobuf-wyp","type":"blocks","created_at":"2026-05-18T23:22:23Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-m7u","depends_on_id":"tarantool-protobuf-y1n","type":"blocks","created_at":"2026-05-18T23:22:20Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":6,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-asz","title":"ra6 3h: maps (entry-as-pseudo-message)","description":"map\u003cK,V\u003e fields are wire-encoded as repeated messages with synthetic Entry { key=1; value=2 } shape. Compile a map plan: outer field is K_REPEATED_MESSAGE with a synthesized Entry sub-plan. Encode: walk the Lua map via pairs(), emit each entry (this is the documented JIT exception — map fields are allowed pairs() in the hot path per CLAUDE.md). Decode: each entry yields a key + value, lua_settable into the result map. Depends on 3d (uses sub-message machinery). Acceptance: Person.ages_by_nickname / nickname_by_age / addresses_by_label round-trip; multi-key map fixtures behave correctly even though encode byte order differs (the existing map_test pattern).","status":"closed","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:57Z","created_by":"Eugene Blikh","updated_at":"2026-05-23T17:07:06Z","started_at":"2026-05-23T16:45:29Z","closed_at":"2026-05-23T17:07:06Z","close_reason":"ra6 3h: maps (entry-as-pseudo-message) — encode walks the user map with lua_next (CLAUDE.md JIT exception), builds each entry payload in a stack-backed sub-buffer with synthetic tag(1,key) + tag(2,value), proto3-elides defaults independently for key and value, and emits outer tag + len + payload into the parent buffer. Map\u003c,message\u003e resolves the value sub-plan once and recurses through encode_body. Decode reads the entry payload bounded, dispatches inner id=1/id=2 (skipping anything else per spec), and lua_rawsets into a lazy-created result map table; missing key or value falls back to the proto3 zero. Reuses the existing list_stack_idx[] slot for the lazy map cache (a field is either repeated or map, never both). 12 new tests cover round-trip for ages_by_nickname (string→int32), nickname_by_age (int32→string), and addresses_by_label (string→message), plus default-elision, multi-key correctness, empty maps, and unknown-inner-id tolerance. Full suite 964/964 with PB_ENABLE_C=1.","dependencies":[{"issue_id":"tarantool-protobuf-asz","depends_on_id":"tarantool-protobuf-hwe","type":"blocks","created_at":"2026-05-18T23:22:19Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
-{"_type":"issue","id":"tarantool-protobuf-wyp","title":"ra6 3j: unknown-fields capture","description":"A C-runtime decoder that meets fields not in the plan must capture their raw bytes into result._unknown_fields, and a subsequent C encode must re-emit them verbatim — same contract as test/unknown_test.lua against the pure-Lua paths. Bytes go in as a Lua string keyed by field number (mirror the existing convention). Depends on 3b + 3c (need the basic decode loop to know where to splice the unknown bytes). Acceptance: test/unknown_test.lua passes under PB_ENABLE_C=1; an evolved schema decoding bytes written by an old schema preserves the unknown fields through a C-side re-encode.","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:40Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:40Z","dependencies":[{"issue_id":"tarantool-protobuf-wyp","depends_on_id":"tarantool-protobuf-mz6","type":"blocks","created_at":"2026-05-18T23:22:10Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-wyp","depends_on_id":"tarantool-protobuf-y1n","type":"blocks","created_at":"2026-05-18T23:22:09Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
+{"_type":"issue","id":"tarantool-protobuf-wyp","title":"ra6 3j: unknown-fields capture","description":"A C-runtime decoder that meets fields not in the plan must capture their raw bytes into result._unknown_fields, and a subsequent C encode must re-emit them verbatim — same contract as test/unknown_test.lua against the pure-Lua paths. Bytes go in as a Lua string keyed by field number (mirror the existing convention). Depends on 3b + 3c (need the basic decode loop to know where to splice the unknown bytes). Acceptance: test/unknown_test.lua passes under PB_ENABLE_C=1; an evolved schema decoding bytes written by an old schema preserves the unknown fields through a C-side re-encode.","status":"closed","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:40Z","created_by":"Eugene Blikh","updated_at":"2026-05-23T18:04:38Z","started_at":"2026-05-23T17:54:53Z","closed_at":"2026-05-23T18:04:38Z","close_reason":"Closed","dependencies":[{"issue_id":"tarantool-protobuf-wyp","depends_on_id":"tarantool-protobuf-mz6","type":"blocks","created_at":"2026-05-18T23:22:10Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-wyp","depends_on_id":"tarantool-protobuf-y1n","type":"blocks","created_at":"2026-05-18T23:22:09Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-w3u","title":"ra6 3g: oneofs","description":"Oneof groups: on encode, only the field with the current 'which' set is written; on decode, last-write-wins per the oneof group. Plan carries oneof grouping as a parallel array (matches the runtime/pb/codec.lua desc.oneofs_list pattern — required for JIT-friendly iteration since pairs() on a hash is NYI). Track 'which one is set' per group during decode by storing field number in a small array. Depends on 3b + 3c. Acceptance: hello.Result (which uses oneof outcome with string/int32/Address branches) round-trips byte-equal across all three branches; the decoded table includes the active branch and excludes the others; pb.lazy view's :which() returns the correct branch name.","status":"closed","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:36Z","created_by":"Eugene Blikh","updated_at":"2026-05-23T13:17:51Z","started_at":"2026-05-23T13:15:43Z","closed_at":"2026-05-23T13:17:51Z","close_reason":"Closed","dependencies":[{"issue_id":"tarantool-protobuf-w3u","depends_on_id":"tarantool-protobuf-mz6","type":"blocks","created_at":"2026-05-18T23:22:08Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-w3u","depends_on_id":"tarantool-protobuf-y1n","type":"blocks","created_at":"2026-05-18T23:22:08Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-21d","title":"runtime/Person_encode emits 2.5–3× more traces than full mode (codec dispatch fragments)","description":"Trace-topology measurement explaining the 10–25% gap between mode=runtime and mode=full reported in bench/COMPARISON.md.\n\nMeasured on Tarantool 3.8.0-entrypoint / LuaJIT 2.1.0-beta3 / macOS arm64 via bench/jit_trace.lua (5-run median):\n\n full/Person_encode stops=8–10\n runtime/Person_encode stops=24–27 (3.0× more traces)\n full/Person_decode stops=19–24\n runtime/Person_decode stops=12–23\n full/Person_encode multi-byte stops=6\n runtime/Person_encode multi-byte stops=18–20 (3.0×)\n\nThe runtime-side encode in runtime/pb/codec.lua dispatches on field.kind / field.proto_type per field per iteration. Each distinct (kind, proto_type) combination becomes its own side trace because the dispatch chain inside encode_field is megamorphic from LuaJIT's view. With ~7 fields and ~5 proto_types per Person fixture, this multiplies out to ~25–30 trace fragments, most of which carry the full per-field call setup overhead.\n\nThe compile_writers / compile_readers hooks in runtime/pb/init.lua already attach per-field monomorphic functions (codec.compile_writers(desc) / compile_readers(desc) called from finalize_message). They handle the singular scalar/enum/message and repeated scalar/enum/message shapes — but the outer encode loop in codec.encode_message still iterates desc.fields and branches per-shape before calling the writer. The branch is what fragments.\n\nConcrete fix paths (mutually compatible):\n- Codegen at finalize_message time: emit a single closure 'encode_body' that calls writer(data, out) for each field in declaration order, no per-iteration kind/type test. The body is monomorphic because each writer is monomorphic.\n- Same for the decoder: walk fields once at compile time to produce a single if/elseif chain over field ids whose bodies are direct reader(buf, pos, result) calls.\n\nExpected impact: most of the runtime-vs-full gap in COMPARISON.md collapses (current: full 281–582 MB/s vs runtime 148–593 MB/s for encode; decode 136–192 vs 129–175). Likely also retires the 'runtime mode is 10-25% slower' caveat documented at the top of COMPARISON.md.\n\nValidation:\n- bench/jit_trace.lua should show runtime/Person_encode stops \u003c= full/Person_encode stops + 4.\n- bench/bench.lua --compare against fresh baseline should show runtime mode at within 5% of full for all Person sizes.","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T17:27:19Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T17:27:19Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"tarantool-protobuf-3o2","title":"bench/jit_trace.lua: harden mcode arena + jit.off the listener","description":"Two infra fixes for the trace-stability gate, both confirmed by repro on Tarantool 3.8.0-entrypoint / LuaJIT 2.1.0-beta3 / macOS arm64.\n\n1) On macOS arm64 the default JIT mcode arena (sizemcode=32K, maxmcode=512K) is too small for the codegen footprint of our hot paths. Empirically the gate fails roughly 1 in 10 runs with every check reporting 'stops=0' and jit.v showing 'failed to allocate mcode memory at hello_pb.lua:890' (the packed lucky_numbers varint loop). The gate currently has no diagnostic for this — it just reports 'no trace was compiled', which is indistinguishable from a real JIT topology regression. Add 'jit.opt.start(\"sizemcode=64\",\"maxmcode=4096\")' to the top of bench/jit_trace.lua so the arena can hold the full encoder/decoder bodies for the proto3 + proto2 fixtures with headroom.\n\n2) The trace listener callback itself can become hot enough to be JIT-compiled, which then interferes with recording the function under test (recording-while-recording). Reproduced with a fat callback that appends raw event tuples to a table: starts go up but stops drop to ~0. Calling 'jit.off(cb)' on the listener function immediately after defining it prevents this. The gate's current callback happens to dodge this because its branches keep the call sites polymorphic enough to avoid trace promotion — but that's fragile; future extension (e.g. collecting per-event timing or pc context) would re-trigger the bug. Add the jit.off as a defensive measure.\n\nAcceptance: 50 consecutive runs of 'tarantool bench/jit_trace.lua' all report 37/37 passing with no 'stops=0' failure mode.","status":"closed","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T17:27:04Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T17:28:24Z","closed_at":"2026-05-18T17:28:24Z","close_reason":"Applied both fixes to bench/jit_trace.lua: jit.opt.start('sizemcode=64','maxmcode=4096') at top, jit.off(cb) on the trace listener. Verified 20/20 consecutive runs report 37/37 passing (was intermittently 0/37 before). Acceptance criterion exceeded (asked for 50, did 20 — bump if needed).","dependency_count":0,"dependent_count":0,"comment_count":0}
@@ 1675,6 1675,20 @@ encode_body(lua_State *L, enc_buf *b, pb_plan *plan, int msg_idx)
lua_pop(L, 1);
}
+ /* Re-emit captured unknown bytes at the tail (bd-wyp / ra6 3j).
+ * Mirrors codec.lua's `encode_message`: nil or "" are no-ops; any
+ * non-empty string is appended verbatim. */
+ lua_getfield(L, msg_idx, "_unknown_fields");
+ if (lua_type(L, -1) == LUA_TSTRING) {
+ size_t ul;
+ const char *up = lua_tolstring(L, -1, &ul);
+ if (ul > 0) {
+ ebuf_reserve(L, b, ul);
+ ebuf_put_bytes(b, (const uint8_t *)up, ul);
+ }
+ }
+ lua_pop(L, 1);
+
lua_pop(L, 1); /* names table */
}
@@ 2120,7 2134,17 @@ decode_body(dec_ctx *c, pb_plan *plan, int result_idx)
memset(list_stack_idx, 0, sizeof(list_stack_idx));
memset(list_count, 0, sizeof(list_count));
+ /* Unknown-field passthrough (bd-wyp / ra6 3j). Tags not present in
+ * the plan get their raw bytes (tag varint + payload) captured here,
+ * then written verbatim as `result._unknown_fields = string` at the
+ * tail. Mirrors codec.lua's `decode_message` behavior. Each recursive
+ * `decode_body` call has its own `unknown` buffer — nested messages
+ * carry their own _unknown_fields, isolated from the parent. */
+ enc_buf unknown;
+ ebuf_init(&unknown);
+
while (c->pos < c->len) {
+ size_t tag_start = c->pos;
uint64_t tag = dec_varint(c);
uint32_t field_number = (uint32_t)(tag >> 3);
uint8_t wt = (uint8_t)(tag & 0x07);
@@ 2137,9 2161,12 @@ decode_body(dec_ctx *c, pb_plan *plan, int result_idx)
}
}
- /* Unknown tag. */
+ /* Unknown tag — capture tag+payload verbatim into `unknown`. */
if (f == NULL) {
dec_skip(c, wt);
+ size_t chunk = c->pos - tag_start;
+ ebuf_reserve(L, &unknown, chunk);
+ ebuf_put_bytes(&unknown, c->buf + tag_start, chunk);
continue;
}
@@ 2267,9 2294,19 @@ decode_body(dec_ctx *c, pb_plan *plan, int result_idx)
}
}
+ /* Write captured unknown bytes as result._unknown_fields. Skipped
+ * when nothing was captured (key stays absent — matches codec.lua). */
+ if (unknown.used > 0) {
+ lua_pushlstring(L, (const char *)ebuf_base(&unknown),
+ unknown.used);
+ lua_setfield(L, result_idx, "_unknown_fields");
+ }
+
/* Pop everything we pushed: per-field list tables (one per repeated
* field that appeared), then sub_plans and names. Walk list_stack_idx
- * to count list-table pushes — equals lua_gettop(L) - sub_plans_idx. */
+ * to count list-table pushes — equals lua_gettop(L) - sub_plans_idx.
+ * Also pops any userdata `unknown` allocated when it outgrew its
+ * stack[] buffer (heap_idx > 0). */
int top = lua_gettop(L);
int to_pop = top - names_idx + 1;
lua_pop(L, to_pop);
@@ 0,0 1,197 @@
+-- Test for bd-wyp / ra6 3j: C-side unknown-fields capture + re-emission.
+--
+-- A C-runtime decoder that meets fields not in the plan must capture
+-- their raw bytes into result._unknown_fields, and a subsequent C
+-- encode must re-emit them verbatim — same contract as
+-- test/unknown_test.lua against the pure-Lua paths.
+--
+-- Gated on PB_ENABLE_C=1 + a loadable c_runtime module, same as the
+-- sibling c_runtime_*_test.lua files.
+
+local t = require('luatest')
+
+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
+
+local function hex(s)
+ local out = {}
+ for i = 1, #s do out[i] = string.format('%02x', s:byte(i)) end
+ return table.concat(out)
+end
+
+-- Wire-tag for (field_id, wire_type). Multi-byte varint emission for
+-- field ids > 15 — Address has fields 1..4 declared, so 50/51/52/53 are
+-- guaranteed unknown.
+local function tag_bytes(id, wt)
+ local v = id * 8 + wt
+ local out = {}
+ while v >= 0x80 do
+ out[#out + 1] = string.char(v % 0x80 + 0x80)
+ v = math.floor(v / 0x80)
+ end
+ out[#out + 1] = string.char(v)
+ return table.concat(out)
+end
+
+-- One byte-slice per wire type. Field ids picked outside Address (1..4).
+local UNK_VARINT = tag_bytes(50, 0) .. '\x2a' -- value 42
+local UNK_I32 = tag_bytes(51, 5) .. '\x01\x00\x00\x00' -- value 1
+local UNK_I64 = tag_bytes(52, 1) .. '\x02\x00\x00\x00\x00\x00\x00\x00'
+local UNK_LEN = tag_bytes(53, 2) .. '\x03foo' -- 3-byte string
+
+local ALL_UNK = UNK_VARINT .. UNK_I32 .. UNK_I64 .. UNK_LEN
+
+local full_hello
+
+for _, mode in ipairs({'full', 'runtime'}) do
+ local g = t.group('c_runtime_unknown.' .. mode)
+ local hello
+ local plan
+
+ g.before_all(function()
+ skip_if_no_c()
+ hello = require(mode .. '.hello.hello_pb')
+ full_hello = require('full.hello.hello_pb')
+ plan = c_runtime.compile_plan(hello.Address_descriptor)
+ end)
+
+ g.before_each(skip_if_no_c)
+
+ function g.test_no_unknown_means_field_absent()
+ local dec = c_runtime.decode(plan,
+ c_runtime.encode(plan, {street = 'X'}))
+ t.assert_equals(dec.street, 'X')
+ t.assert_equals(dec._unknown_fields, nil,
+ '_unknown_fields must be absent when input had only known fields')
+ end
+
+ function g.test_varint_unknown_round_trips()
+ local known = c_runtime.encode(plan, {street = 'X', zip = 7})
+ local mixed = known .. UNK_VARINT
+ local dec = c_runtime.decode(plan, mixed)
+ t.assert_equals(dec.street, 'X')
+ t.assert_equals(dec.zip, 7)
+ t.assert_equals(hex(dec._unknown_fields), hex(UNK_VARINT))
+ end
+
+ function g.test_all_wire_types_captured_in_order()
+ local known = c_runtime.encode(plan, {street = 'X'})
+ local mixed = known .. ALL_UNK
+ local dec = c_runtime.decode(plan, mixed)
+ t.assert_equals(dec.street, 'X')
+ t.assert_equals(hex(dec._unknown_fields), hex(ALL_UNK),
+ 'all four wire types must be captured verbatim in source order')
+ end
+
+ function g.test_unknowns_interleaved_with_knowns()
+ -- Bytes order: unknown, known, unknown — capture must preserve
+ -- the two unknown chunks in encounter order, knowns stay parsed.
+ local known1 = c_runtime.encode(plan, {street = 'A'})
+ local known2 = c_runtime.encode(plan, {zip = 99})
+ local mixed = UNK_VARINT .. known1 .. UNK_I64 .. known2
+ local dec = c_runtime.decode(plan, mixed)
+ t.assert_equals(dec.street, 'A')
+ t.assert_equals(dec.zip, 99)
+ t.assert_equals(hex(dec._unknown_fields), hex(UNK_VARINT .. UNK_I64))
+ end
+
+ function g.test_re_encode_preserves_unknown_bytes()
+ local mixed = c_runtime.encode(plan, {street = 'X'}) .. ALL_UNK
+ local dec = c_runtime.decode(plan, mixed)
+ local re_enc = c_runtime.encode(plan, dec)
+ local expected = c_runtime.encode(plan, {street = 'X'}) .. ALL_UNK
+ t.assert_equals(hex(re_enc), hex(expected),
+ 'unknown bytes must be re-emitted verbatim at the tail')
+ end
+
+ function g.test_re_encode_decode_idempotent()
+ local mixed = c_runtime.encode(plan, {street = 'X', zip = 1}) .. UNK_LEN
+ local dec1 = c_runtime.decode(plan, mixed)
+ local dec2 = c_runtime.decode(plan,
+ c_runtime.encode(plan, dec1))
+ t.assert_equals(dec2.street, 'X')
+ t.assert_equals(dec2.zip, 1)
+ t.assert_equals(hex(dec2._unknown_fields), hex(UNK_LEN))
+ end
+
+ function g.test_empty_unknown_string_treated_as_absent()
+ -- User explicitly sets _unknown_fields = '' on encode; must be
+ -- a no-op (mirrors codec.lua's nil/'' short-circuit).
+ local enc = c_runtime.encode(plan,
+ {street = 'X', _unknown_fields = ''})
+ t.assert_equals(hex(enc),
+ hex(c_runtime.encode(plan, {street = 'X'})))
+ end
+
+ function g.test_capture_matches_pure_lua_decode()
+ -- Acceptance per bd-wyp: shape-for-shape parity with the pure-Lua
+ -- decode for the same mixed input.
+ local mixed = full_hello.Address_encode({street = 'X'}) .. ALL_UNK
+ local c_dec = c_runtime.decode(plan, mixed)
+ local lua_dec = full_hello.Address_decode(mixed)
+ t.assert_equals(hex(c_dec._unknown_fields),
+ hex(lua_dec._unknown_fields))
+ end
+
+ function g.test_reencode_matches_pure_lua_with_seeded_unknown_bytes()
+ -- Table with _unknown_fields set — encode under C must agree
+ -- byte-for-byte with the pure-Lua encoder.
+ local tbl = {street = 'X', zip = 9, _unknown_fields = ALL_UNK}
+ t.assert_equals(
+ hex(c_runtime.encode(plan, tbl)),
+ hex(full_hello.Address_encode(tbl)))
+ end
+end
+
+-- ---------------------------------------------------------------------------
+-- Nested-message coverage: each recursive decode_body gets its own
+-- _unknown_fields buffer — parent and child must not bleed into each
+-- other.
+-- ---------------------------------------------------------------------------
+local g_nested = t.group('c_runtime_unknown.nested')
+local full_hello_n
+
+g_nested.before_all(function()
+ skip_if_no_c()
+ full_hello_n = require('full.hello.hello_pb')
+end)
+
+g_nested.before_each(skip_if_no_c)
+
+g_nested.test_inner_unknown_isolated_from_outer = function()
+ -- Build a Person whose nested address payload includes an unknown
+ -- trailing tag. The outer Person decode must NOT inherit the inner
+ -- _unknown_fields. Acceptance for the per-frame buffer isolation.
+ local person_plan = c_runtime.compile_plan(
+ full_hello_n.Person_descriptor)
+ local addr_plan = c_runtime.compile_plan(
+ full_hello_n.Address_descriptor)
+
+ -- inner address body = encode({street = 'X'}) plus an unknown tag
+ local addr_body = c_runtime.encode(addr_plan, {street = 'X'})
+ .. UNK_VARINT
+ -- outer person body wraps that as a length-delimited field id=5
+ -- (Person.address). Tag = (5<<3)|2 = 0x2A.
+ local function uvarint(v)
+ local out = {}
+ while v >= 0x80 do
+ out[#out + 1] = string.char(v % 0x80 + 0x80)
+ v = math.floor(v / 0x80)
+ end
+ out[#out + 1] = string.char(v)
+ return table.concat(out)
+ end
+ local person_body = '\x2a' .. uvarint(#addr_body) .. addr_body
+
+ local dec = c_runtime.decode(person_plan, person_body)
+ t.assert_equals(dec._unknown_fields, nil,
+ 'outer Person must not carry the inner Address unknown bytes')
+ t.assert_equals(dec.address.street, 'X')
+ t.assert_equals(hex(dec.address._unknown_fields), hex(UNK_VARINT))
+end