diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 361bd959a31d3bf264b4c1da7af394419fe764ff..50db094aa195ca8a235ae4f2685e56c8e1f6bbd4 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -15,7 +15,7 @@ {"_type":"issue","id":"tarantool-protobuf-gcy","title":"Decoder: inline nested-message decode at the call site","description":"Today Person_decode calls Address_decode(slice) across a function boundary; the side trace into Address_decode may not stitch back (memory: luajit_side_trace_inlined_return). For field-typed messages in mode=full, emit the decode body inline at the call site. Expected: 20-40% on payloads with nested messages. Codegen growth is bounded by recursion depth — pick a depth limit and call out beyond it.","status":"closed","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T15:47:09Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T18:44:22Z","started_at":"2026-05-18T18:32:15Z","closed_at":"2026-05-18T18:44:22Z","close_reason":"Attempted but reverted. Inlining Address_decode body into Person_decode (singular non-recursive candidate) showed 745/745 tests passing and 37/37 JIT, but bench median-of-3 regressed Person 1KB/10KB/100KB decode by 5-8% and encode by 3-6%. Profile's '100% interpreter bail' claim turned out to be a vl trace-attribution artifact; LuaJIT was already inlining Address_decode into Person_decode's trace effectively. Larger root traces (22 stops) compiled less efficiently. See bench/PERF_LOG.md entry for full data and analysis. Real decode bottlenecks remain decode_string utf8 (6bb), decode_tag fast path (4kj), repeated append (cch).","labels":["codegen","decoder","perf"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-h8v","title":"Encoder: codegen-time inline FFI writes (mode=full)","description":"Replace per-field wire.encode_* calls + ../table.concat chain with directly-emitted FFI byte writes at every field site in mode=full. Today every encode_len(s) is 'encode_varint(#s) .. s' — two allocations and a concat per string. Sidesteps the per-byte b:alloc(1) cliff that sank the earlier ibuf attempt (memory: tarantool_ibuf_perf). Expected: 1.5-2x encode throughput; alloc/op drops from ~130 KB at 100 KB Person to near zero. Biggest single perf move. Bench reference: bench/starwing_bench.lua + bench/COMPARISON.md show starwing C encoder at 2.3-3x ours across all sizes.","status":"closed","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T15:47:04Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T18:31:54Z","started_at":"2026-05-18T18:25:39Z","closed_at":"2026-05-18T18:31:54Z","close_reason":"Inlined 1-byte varint length prefix at every LEN emit site; +25-48% encode at 1KB+ Person. Full FFI-buffer rewrite deferred — this slice captured the dominant profile target without that level of disruption. See bench/PERF_LOG.md entry for details.","labels":["codegen","encoder","perf"],"dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-e4t","title":"LSP: plugin emits @class type stubs for generated messages","description":"Phase 3 of the LSP / LLM affordance work. Follows phase 1 (commit 8721548 — runtime annotations) and phase 2 (bd-74c — annotate codec/grpc/json/wkt).\n\nHighest-payoff phase for LLM grounding. Modify the protoc plugin (cmd/protoc-gen-tarantool/internal/gen) so each generated _pb.lua module emits ---@class blocks describing every message, plus typed @param/@return on the _encode/_decode/_descriptor surface. After this lands, anyone (human or LLM) who require('full.hello.hello_pb') gets full type info on Person, Address, etc., without reading the .proto.\n\nEmit shape (decision: inline at top of generated file; one file per .proto already includes all messages from that file):\n\n ---@class hello.Person\n ---@field name? string\n ---@field user_id? ffi.cdata* # uint64\n ---@field age? integer # int32\n ---@field weight_kg? number # double\n ---@field emails? string[]\n ---@field address? hello.Address\n ---@field lucky_numbers? integer[]\n ---@field favorite_color? hello.Color # enum alias\n ---@field tags? table\u003cstring,string\u003e # map\u003cstring,string\u003e\n ---@field unknown_fields? string\n\n ---@param tbl hello.Person\n ---@return string\n function M.Person_encode(tbl) ... end\n\n ---@param bytes string\n ---@return hello.Person\n function M.Person_decode(bytes) ... end\n\nField-kind -\u003e Lua-type mapping:\n- scalar int32/uint32/sint32/fixed32/sfixed32/bool → integer/boolean\n- scalar int64/uint64/sint64/fixed64/sfixed64 → ffi.cdata* (LuaJIT cdata, per project convention)\n- scalar float/double → number\n- scalar string/bytes → string\n- enum → alias of integer (emit ---@alias hello.Color integer)\n- message → another @class reference\n- repeated T → T[]\n- map\u003cK,V\u003e → table\u003cK_lua, V_lua\u003e\n- oneof — all members are optional, generator should NOT emit a discriminator field; user calls view:which() / inspects which value is non-nil\n\nAll fields are optional (? suffix) since proto3 default-elision means absence-on-wire is indistinguishable from default value. Required fields in proto2 omit the ?.\n\nBoth mode=full and mode=runtime emit the same _encode/_decode wrapper signatures, so the stubs apply uniformly.\n\nVerify by:\n1. Regenerating examples/expected/ via just gen\n2. Confirming the generated _pb.lua files load and pass tests (no behavior change)\n3. Opening examples/expected/full/hello/hello_pb.lua in an LSP-aware editor and checking that hover on hello.Person, M.Person_encode shows the @class + typed signature\n4. Sanity: require a generated module from spike code and confirm autocomplete on the result-table field names","acceptance_criteria":"Plugin emits ---@class blocks + typed _encode/_decode signatures into every generated _pb.lua; just gen + just test pass; LSP hover on require('full.hello.hello_pb').Person_encode shows ---@param tbl hello.Person ---@return string; ---@class hello.Person is reachable via hover on a decoded value","status":"open","priority":2,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-05-23T09:30:19Z","created_by":"Eugene Blikh","updated_at":"2026-05-23T09:30:19Z","labels":["codegen","lsp"],"dependencies":[{"issue_id":"tarantool-protobuf-e4t","depends_on_id":"tarantool-protobuf-74c","type":"blocks","created_at":"2026-05-23T12:30:18Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-74c","title":"LSP: annotate codec/grpc/json/wkt with EmmyLua types","description":"Phase 2 of the LSP / LLM affordance work (phase 1 landed in commit 8721548 — .luarc.json, runtime/pb/_types.lua, init.lua + lazy.lua annotations).\n\nAdd ---@param / ---@return annotations to the remaining public-surface modules so editor hover and LLM context show types beyond just init.lua and the lazy views:\n\n- runtime/pb/codec.lua — encode(desc, t) / decode(desc, b); compile_writers / compile_readers (called from pb.finalize_message)\n- runtime/pb/grpc.lua — loopback / multiplex transport factories; the service client (factory(transport) -\u003e client_methods) and server (impl -\u003e {service, methods}) shapes\n- runtime/pb/json.lua — pb.json.encode(desc, t, opts) / pb.json.decode(desc, s, opts); opts shape (preserve_proto_field_names, emit_unpopulated, ...)\n- runtime/pb/wkt.lua — register(full_name, desc), lookup, any_pack/any_unpack, NULL sentinel\n\nFoundational @class declarations (pb.Descriptor, pb.Field, pb.GrpcTransport, pb.Json, pb.Wkt, pb.Module) already live in runtime/pb/_types.lua — extend as needed for opts shapes / map\u003cK,V\u003e generics.\n\nPure metadata change — no runtime behavior. Verify by running just test (must stay at 748 passing) and by opening a generated _pb.lua in an LSP-aware editor and confirming hover on require('pb').encode / .grpc.loopback / .json.encode shows the right signatures.","acceptance_criteria":"just test still green (748+ tests); hover in lua-language-server-aware editor shows typed signatures for pb.encode, pb.decode, grpc.loopback, json.encode, json.decode, wkt.register","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-23T09:29:55Z","created_by":"Eugene Blikh","updated_at":"2026-05-23T09:29:55Z","labels":["docs","lsp"],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"_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-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":"in_progress","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:21:13Z","created_by":"Eugene Blikh","updated_at":"2026-05-23T18:17:19Z","started_at":"2026-05-23T18:17:19Z","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":"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} diff --git a/runtime/pb/c/c_runtime.c b/runtime/pb/c/c_runtime.c index ee589c23473eee3ce251f430de9ae03c3ad7679e..94f16a420487357ef5cf9213ea48cb1adc66ba3a 100644 --- a/runtime/pb/c/c_runtime.c +++ b/runtime/pb/c/c_runtime.c @@ -1459,8 +1459,6 @@ static void encode_submessage_field(lua_State *L, enc_buf *b, pb_plan *plan, pb_plan_field *f, int val_idx) { - 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 : "?"); @@ -1478,6 +1476,32 @@ 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); + + /* WKT override: call desc.encode(value) for the body bytes; emit + * tag + len + body verbatim. Accepts any Lua type (datetime cdata, + * number, string, table) per the override's contract. */ + if (subplan->override_encode_ref != LUA_NOREF) { + lua_rawgeti(L, LUA_REGISTRYINDEX, + subplan->override_encode_ref); + lua_pushvalue(L, val_idx); + lua_call(L, 1, 1); + if (lua_type(L, -1) != LUA_TSTRING) + luaL_error(L, + "WKT encode for '%s' returned non-string", + subplan->name != NULL ? subplan->name : "?"); + size_t blen; + const char *bp = lua_tolstring(L, -1, &blen); + ebuf_reserve(L, b, f->tag_len + 10 + blen); + ebuf_put_tag(b, f); + ebuf_put_varint(b, (uint64_t)blen); + if (blen > 0) + ebuf_put_bytes(b, (const uint8_t *)bp, blen); + lua_settop(L, saved_top); + return; + } + + if (lua_type(L, val_idx) != LUA_TTABLE) + luaL_error(L, "message field requires a table value"); enc_buf sub; ebuf_init(&sub); @@ -1570,19 +1594,41 @@ /* Value. Messages always emit (presence is meaningful); other * kinds proto3-elide on default. */ if (f->map_value_kind == PB_KIND_MESSAGE) { - if (lua_type(L, v_idx) != LUA_TTABLE) - luaL_error(L, - "map<,message> value must be a table"); - enc_buf vbody; - ebuf_init(&vbody); - encode_body(L, &vbody, value_subplan, v_idx); - ebuf_reserve(L, &entry, - 1 + 10 + vbody.used); - ebuf_put_byte(&entry, val_tag); - ebuf_put_varint(&entry, (uint64_t)vbody.used); - if (vbody.used > 0) - ebuf_put_bytes(&entry, ebuf_base(&vbody), - vbody.used); + if (value_subplan->override_encode_ref != LUA_NOREF) { + /* WKT-typed map value: override owns body. */ + lua_rawgeti(L, LUA_REGISTRYINDEX, + value_subplan->override_encode_ref); + lua_pushvalue(L, v_idx); + lua_call(L, 1, 1); + if (lua_type(L, -1) != LUA_TSTRING) + luaL_error(L, + "WKT encode for '%s' returned non-string", + value_subplan->name != NULL + ? value_subplan->name : "?"); + size_t blen; + const char *bp = lua_tolstring(L, -1, &blen); + ebuf_reserve(L, &entry, 1 + 10 + blen); + ebuf_put_byte(&entry, val_tag); + ebuf_put_varint(&entry, (uint64_t)blen); + if (blen > 0) + ebuf_put_bytes(&entry, + (const uint8_t *)bp, blen); + lua_pop(L, 1); + } else { + if (lua_type(L, v_idx) != LUA_TTABLE) + luaL_error(L, + "map<,message> value must be a table"); + enc_buf vbody; + ebuf_init(&vbody); + encode_body(L, &vbody, value_subplan, v_idx); + ebuf_reserve(L, &entry, + 1 + 10 + vbody.used); + ebuf_put_byte(&entry, val_tag); + ebuf_put_varint(&entry, (uint64_t)vbody.used); + if (vbody.used > 0) + ebuf_put_bytes(&entry, ebuf_base(&vbody), + vbody.used); + } } else if (!value_is_default_kind(L, f->map_value_kind, v_idx)) { ebuf_reserve(L, &entry, 1); @@ -1609,9 +1655,12 @@ 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) { + if (plan->override_encode_ref != LUA_NOREF) { + /* Defensive: callers must dispatch via the override Lua-ref + * directly. Reaching encode_body here means a code-path bug. */ luaL_error(L, - "C encode on plan with override is unsupported (bd-rmf)"); + "internal: encode_body invoked on override plan '%s'", + plan->name != NULL ? plan->name : "?"); } if (plan->field_names_ref == LUA_NOREF || plan->n_fields == 0) @@ -1696,12 +1745,22 @@ 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)"); + /* WKT override: desc.encode(value) returns the body bytes verbatim. + * The value is any Lua type the override accepts (datetime cdata, + * Lua number, string, table, etc.) — don't pre-check for TTABLE. */ + if (plan->override_encode_ref != LUA_NOREF) { + lua_rawgeti(L, LUA_REGISTRYINDEX, plan->override_encode_ref); + lua_pushvalue(L, 2); + lua_call(L, 1, 1); + if (lua_type(L, -1) != LUA_TSTRING) + return luaL_error(L, + "WKT encode for '%s' returned non-string", + plan->name != NULL ? plan->name : "?"); + return 1; } + + luaL_checktype(L, 2, LUA_TTABLE); enc_buf b; ebuf_init(&b); @@ -1959,6 +2018,18 @@ luaL_error(L, "sub-plan at index %d is not a userdata", f->sub_plan_idx); lua_pop(L, 1); + /* WKT override: feed the body slice to desc.decode(buf), push whatever + * Lua representation the override returns. */ + if (subplan->override_decode_ref != LUA_NOREF) { + lua_rawgeti(L, LUA_REGISTRYINDEX, + subplan->override_decode_ref); + lua_pushlstring(L, (const char *)(c->buf + c->pos), + (size_t)plen); + lua_call(L, 1, 1); + c->pos += (size_t)plen; + return; + } + lua_createtable(L, 0, subplan->n_fields); int sub_result_idx = lua_gettop(L); @@ -2036,16 +2107,28 @@ uint64_t sub_len = dec_varint(c); if (c->len - c->pos < sub_len) luaL_error(L, "truncated nested map<,message> value"); - lua_createtable(L, 0, vsub->n_fields); - int new_val = lua_gettop(L); - size_t saved2 = c->len; - c->len = c->pos + (size_t)sub_len; - decode_body(c, vsub, new_val); - if (c->pos != c->len) - luaL_error(L, - "nested map<,message> body underflow"); - c->len = saved2; - lua_replace(L, val_idx); + if (vsub->override_decode_ref != LUA_NOREF) { + /* WKT-typed map value: override consumes body. */ + lua_rawgeti(L, LUA_REGISTRYINDEX, + vsub->override_decode_ref); + lua_pushlstring(L, + (const char *)(c->buf + c->pos), + (size_t)sub_len); + lua_call(L, 1, 1); + c->pos += (size_t)sub_len; + lua_replace(L, val_idx); + } else { + lua_createtable(L, 0, vsub->n_fields); + int new_val = lua_gettop(L); + size_t saved2 = c->len; + c->len = c->pos + (size_t)sub_len; + decode_body(c, vsub, new_val); + if (c->pos != c->len) + luaL_error(L, + "nested map<,message> body underflow"); + c->len = saved2; + lua_replace(L, val_idx); + } } else { dec_push_kind(c, f->map_value_kind); lua_replace(L, val_idx); @@ -2092,9 +2175,12 @@ static void decode_body(dec_ctx *c, pb_plan *plan, int result_idx) { lua_State *L = c->L; - if (plan->has_override) { + if (plan->override_decode_ref != LUA_NOREF) { + /* Defensive: callers must dispatch via the override Lua-ref + * directly. Reaching decode_body here means a code-path bug. */ luaL_error(L, - "C decode on plan with override is unsupported (bd-rmf)"); + "internal: decode_body invoked on override plan '%s'", + plan->name != NULL ? plan->name : "?"); } result_idx = abs_idx(L, result_idx); @@ -2319,9 +2405,13 @@ 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)"); + /* WKT override: desc.decode(buf) consumes the entire body and returns + * whatever Lua representation the override picks (e.g. datetime). */ + if (plan->override_decode_ref != LUA_NOREF) { + lua_rawgeti(L, LUA_REGISTRYINDEX, plan->override_decode_ref); + lua_pushlstring(L, buf, buf_len); + lua_call(L, 1, 1); + return 1; } lua_createtable(L, 0, plan->n_fields); diff --git a/test/c_runtime_decode_test.lua b/test/c_runtime_decode_test.lua index 077688a9503ea73d4df6b8cfdef8c6e8fba10532..afe77be71959bb5f0cb148a2070efe491f013dbf 100644 --- a/test/c_runtime_decode_test.lua +++ b/test/c_runtime_decode_test.lua @@ -275,12 +275,12 @@ t.assert_equals(c_decoded, {name = 'x', _unknown_fields = unknown_tag}) end - function g.test_wkt_override_rejected() - -- has_override plans don't expose field arrays; mirror 3b's gate. + function g.test_wkt_override_passthrough() + -- bd-rmf / ra6 3k: has_override plans dispatch to desc.decode(buf). local plan = c_runtime.compile_plan(pb.wkt.Timestamp_descriptor) - t.assert_error_msg_contains('override', function() - c_runtime.decode(plan, '') - end) + local bytes = pb.wkt.Timestamp_encode({seconds = 1700000000, nanos = 42}) + t.assert_equals(c_runtime.decode(plan, bytes), + pb.wkt.Timestamp_decode(bytes)) end function g.test_truncated_input_errors() diff --git a/test/c_runtime_encode_test.lua b/test/c_runtime_encode_test.lua index bdbef646cc981b8453a690c96cb528985d16bdcf..97c125c3a879480036385b2753f9ada820734d3f 100644 --- a/test/c_runtime_encode_test.lua +++ b/test/c_runtime_encode_test.lua @@ -292,12 +292,13 @@ t.assert_equals(c_runtime.encode(plan, msg), full_hello.Person_encode(msg)) end - function g.test_wkt_override_rejected() - -- has_override plans skip field-walk; 3b does not handle them. + function g.test_wkt_override_passthrough() + -- bd-rmf / ra6 3k: has_override plans now dispatch to + -- desc.encode(value) and return its bytes verbatim. local plan = c_runtime.compile_plan(pb.wkt.Timestamp_descriptor) - t.assert_error_msg_contains('override', function() - c_runtime.encode(plan, {seconds = 1}) - end) + local v = {seconds = 1, nanos = 2} + t.assert_equals(c_runtime.encode(plan, v), + pb.wkt.Timestamp_encode(v)) end -- ---------- Repeated + packed (bd-jc9 / ra6 3e) ---------- diff --git a/test/c_runtime_wkt_test.lua b/test/c_runtime_wkt_test.lua new file mode 100644 index 0000000000000000000000000000000000000000..4d2ac3462d6e0e6935daf3479be97305b3e31bf3 --- /dev/null +++ b/test/c_runtime_wkt_test.lua @@ -0,0 +1,158 @@ +-- Tests for bd-rmf / ra6 3k: WKT override-hook passthrough. +-- +-- A plan whose descriptor carries desc.encode / desc.decode dispatches +-- straight to those overrides instead of walking fields. The Event +-- message exercises every WKT shape we ship — Timestamp, Duration, +-- Empty, the Value wrappers, Struct, Value, ListValue, Any, +-- FieldMask — so byte-equality with mode=full proves the override is +-- live at both the top level and when nested as a sub-message field. +-- +-- Acceptance per bd-rmf: hello.Event with WKT sub-messages round-trips +-- byte-equal to mode=full pure-Lua, runtime/pb/wkt.lua is unmodified. + +local t = require('luatest') +local datetime = require('datetime') + +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 full_hello + +for _, mode in ipairs({'full', 'runtime'}) do + local g = t.group('c_runtime_wkt.' .. 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) + + -- ---------- Top-level override dispatch ---------- + + function g.test_timestamp_top_level_encode_decode() + local plan = c_runtime.compile_plan(pb.wkt.Timestamp_descriptor) + local dt = datetime.new({timestamp = 1700000000, nsec = 123}) + local c_bytes = c_runtime.encode(plan, dt) + t.assert_equals(c_bytes, pb.wkt.Timestamp_encode(dt)) + local out = c_runtime.decode(plan, c_bytes) + t.assert(datetime.is_datetime(out)) + t.assert_equals(out.epoch, 1700000000) + t.assert_equals(out.nsec, 123) + end + + function g.test_duration_top_level_round_trip() + local plan = c_runtime.compile_plan(pb.wkt.Duration_descriptor) + local v = {seconds = 7200, nanos = 500} + local c_bytes = c_runtime.encode(plan, v) + t.assert_equals(c_bytes, pb.wkt.Duration_encode(v)) + t.assert_equals(c_runtime.decode(plan, c_bytes), + pb.wkt.Duration_decode(c_bytes)) + end + + function g.test_empty_top_level_encode_is_zero_bytes() + local plan = c_runtime.compile_plan(pb.wkt.Empty_descriptor) + t.assert_equals(c_runtime.encode(plan, {}), '') + end + + function g.test_int32value_wrapper_top_level() + local plan = c_runtime.compile_plan(pb.wkt.Int32Value_descriptor) + -- The wrapper accepts the bare scalar; presence is meaningful. + t.assert_equals(c_runtime.encode(plan, 42), + pb.wkt.Int32Value_encode(42)) + end + + -- ---------- Nested override dispatch (sub-message field) ---------- + + function g.test_event_with_timestamp_byte_equal() + local plan = c_runtime.compile_plan(hello.Event_descriptor) + local msg = { + title = 'hi', + created_at = datetime.new( + {timestamp = 1700000000, nsec = 123456789}), + } + t.assert_equals(c_runtime.encode(plan, msg), + full_hello.Event_encode(msg)) + end + + function g.test_event_with_duration_and_empty_byte_equal() + local plan = c_runtime.compile_plan(hello.Event_descriptor) + local msg = { + title = 'hi', + duration = {seconds = 60, nanos = 0}, + ack = {}, + } + t.assert_equals(c_runtime.encode(plan, msg), + full_hello.Event_encode(msg)) + end + + function g.test_event_with_wrappers_byte_equal() + local plan = c_runtime.compile_plan(hello.Event_descriptor) + local msg = { + retry_count = 5, + note = 'remember', + is_admin = true, + } + t.assert_equals(c_runtime.encode(plan, msg), + full_hello.Event_encode(msg)) + end + + function g.test_event_with_wrapper_zero_preserved() + local plan = c_runtime.compile_plan(hello.Event_descriptor) + -- Wrappers preserve presence at zero — the override must still + -- emit tag + len(0). A naive "skip default" path would lose it. + local msg = {retry_count = 0} + t.assert_equals(c_runtime.encode(plan, msg), + full_hello.Event_encode(msg)) + end + + function g.test_event_with_fieldmask_byte_equal() + local plan = c_runtime.compile_plan(hello.Event_descriptor) + local msg = {update_mask = {paths = {'foo', 'bar.baz'}}} + t.assert_equals(c_runtime.encode(plan, msg), + full_hello.Event_encode(msg)) + end + + -- ---------- Round-trip parity through the C path ---------- + -- + -- Encode in C, decode in C, compare against mode=full's decode of + -- the same wire bytes. Asserts the override decode wrapper hands + -- back the same Lua representation the pure-Lua codec does. + + function g.test_event_round_trip_through_c() + local plan = c_runtime.compile_plan(hello.Event_descriptor) + local dt = datetime.new({timestamp = 42, nsec = 500}) + local msg = { + title = 'x', + created_at = dt, + duration = {seconds = 1, nanos = 2}, + ack = {}, + retry_count = 7, + note = 'hello', + update_mask = {paths = {'a', 'b'}}, + } + local bytes = c_runtime.encode(plan, msg) + local c_dec = c_runtime.decode(plan, bytes) + local lua_dec = full_hello.Event_decode(bytes) + + t.assert_equals(c_dec.title, lua_dec.title) + t.assert(datetime.is_datetime(c_dec.created_at)) + t.assert_equals(c_dec.created_at.epoch, lua_dec.created_at.epoch) + t.assert_equals(c_dec.created_at.nsec, lua_dec.created_at.nsec) + t.assert_equals(tonumber(c_dec.duration.seconds), + tonumber(lua_dec.duration.seconds)) + t.assert_equals(c_dec.duration.nanos, lua_dec.duration.nanos) + t.assert_equals(type(c_dec.ack), 'table') + t.assert_equals(c_dec.retry_count, lua_dec.retry_count) + t.assert_equals(c_dec.note, lua_dec.note) + t.assert_equals(c_dec.update_mask.paths, lua_dec.update_mask.paths) + end +end