diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index b791f1c00d2b6950d399a930e40fa3a750c45bbd..65d63ac3e2a7274ea6acdba86e188ba48b431da6 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -18,3 +18,4 @@ {"id":"int-a4bcd105","kind":"field_change","created_at":"2026-05-18T20:23:01.540904Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-pf6","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Architecture chosen and documented in docs/c-accel.md (commit pending). Ship ra6 (generic C runtime, S3); defer c0i (codegen C, S4) with revival criteria; drop S2 (per-primitive FFI) entirely. PB_ENABLE_C=1 opt-in, default off. Parity via existing-suite re-runs with C enabled. Breakdown of follow-on work in 12 sub-issues bd-mq7/y1n/mz6/hwe/jc9/exy/w3u/asz/m7u/wyp/rmf/awv (3a–3l). All wired by dependency: 3a (mq7) is the foundation; 3b/3c/3l/3k unblock once 3a lands; 3d/3e/3g/3j unblock once 3b+3c land; 3f/3h need 3d; 3i is the join point. ra6 itself depends on all twelve. c0i blocked-by ra6."}} {"id":"int-ba4ac242","kind":"field_change","created_at":"2026-05-19T04:23:56.245983Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-47e","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Compatibility contract formalized in docs/specs/c_accel_compat.md. Spec covers: PB_ENABLE_C=1 single activation switch, silent fallback, full public surface preservation (pb.encode/decode/decode_lazy/parse/from_pb/json/text/grpc), generated module wrappers, 64-bit cdata invariant, WKT shapes, unknown fields, extensions, error compatibility, install paths (pure-Lua and C-enabled), ABI versioning, conformance via existing-suite re-run, explicit out-of-scope list. Lazy view stays Lua-only per spike rationale. Downstream tasks (mq7, ra6, 43t, c0i) reference this spec for compat boundaries."}} {"id":"int-951d03f7","kind":"field_change","created_at":"2026-05-19T04:24:12.141983Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-z7x","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"C-side strategy formalized in docs/specs/c_accel_strategy.md. Spec covers: pb_plan / pb_plan_field struct layout, field-name luaL_ref caching (spike-validated), 4 KB stack-backed pb_buf with malloc promotion, lua_createtable(0, n_fields) result pre-sizing, cached per-field stack indices for repeated/packed (spike Phase B: 2x speedup at 100 KB), 64-bit cdata via luaT_pushint64/checkint64, sub-buffer (chosen) vs backpatching (deferred) for sub-messages, two-pass packed encode, map iteration via lua_next, oneof grouping, unknown field passthrough, deferred micro-opts (manual varint inlining, SIMD scan), C99/-O2 build environment, deferred PB_C_TRACE/PB_C_CHECK_PARITY env knobs. ra6/mq7 references this spec for low-level decisions."}} {"id":"int-7ce7c344","kind":"field_change","created_at":"2026-05-19T04:24:13.236096Z","actor":"Eugene Blikh","issue_id":"tarantool-protobuf-wky","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Build and packaging scaffolding landed: docs/specs/c_accel_build_packaging.md spec, runtime/pb/init.lua PB_ENABLE_C=1 opt-in pcall hook (exposed as pb.c_runtime field), Justfile build-c/clean-c recipes (stub erroring cleanly until bd-ra6 lands runtime/pb/c/), Justfile lua_cpath constant + LUA_CPATH on test/test-one, .gitignore for compiled .so/.dylib, sourcehut .builds/{pure-lua,c-enabled}.yml manifests (ubuntu/noble, parallel jobs via separate files since sourcehut has no matrix), .sourcehut/conformance.yml for manual conformance triggers. Rockspec changes deferred to first C-source landing (described in spec). All 745 tests pass with PB_ENABLE_C unset and PB_ENABLE_C=1 (silent fallback verified)."}} +{"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)."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 72940b1e4aa8523eda449934ff933974fdb6c701..ec341f4b9a95a57910715dd0457465249df71b71 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -4,7 +4,7 @@ {"_type":"issue","id":"tarantool-protobuf-hwe","title":"ra6 3d: sub-message encode/decode (recursion)","description":"Nested-message support. Encode: open a sub-buffer, recurse with the submsg plan, prefix parent buffer with tag + length. Decode: bound the byte range, recurse, lua_setfield the resulting table. Sub-buffer stack-backed (4KB) and malloc-promoted, same pattern as the outer buffer. Recursion is by C function call into the same encode/decode routine with a different plan, so depth is bounded by C stack. Depends on 3b + 3c. Acceptance: Person.address (1-level) round-trips; deeper nesting via a 5-level test fixture round-trips byte-equal to mode=full.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:24Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:24Z","dependencies":[{"issue_id":"tarantool-protobuf-hwe","depends_on_id":"tarantool-protobuf-mz6","type":"blocks","created_at":"2026-05-18T23:22:06Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-hwe","depends_on_id":"tarantool-protobuf-y1n","type":"blocks","created_at":"2026-05-18T23:22:05Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":2,"dependent_count":4,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-awv","title":"ra6 3l: 64-bit cdata fidelity","description":"int64/uint64/fixed64/sfixed64 must round-trip as LuaJIT cdata in both encode and decode — never narrowed to a Lua double. Same convention as msgpackffi, net.box, box.tuple, the built-in protobuf. Use luaT_pushuint64/luaT_pushint64 from Tarantool's module.h for the C side. Independent transverse concern — can land in parallel with 3b/3c since it's a per-kind fix rather than a new pipeline stage. Acceptance: a value \u003e 2^53 round-trips byte-equal and remains cdata after decode; luatest test/protobuf_test.lua int64-cdata cases pass with PB_ENABLE_C=1.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:10Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:10Z","dependencies":[{"issue_id":"tarantool-protobuf-awv","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:55Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-mz6","title":"ra6 3c: decode scalars","description":"C-side decode for scalar wire types. Pre-sized result table via lua_createtable(0, n_fields) using descriptor stats; per-field plan-driven dispatch on wire type and kind; cached luaL_ref-based lua_setfield. Wire→table output must equal mode=full Lua output for the same input bytes. Depends on 3a. Acceptance: Person decode round-trip works for the bytes produced by 3b; the decoded table is shape-identical to pure-Lua decode (verified by running test/interop tests with PB_ENABLE_C=1).","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:06Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:06Z","dependencies":[{"issue_id":"tarantool-protobuf-mz6","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:54Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-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":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:20:03Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:20:03Z","dependencies":[{"issue_id":"tarantool-protobuf-y1n","depends_on_id":"tarantool-protobuf-mq7","type":"blocks","created_at":"2026-05-18T23:21:53Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0} -{"_type":"issue","id":"tarantool-protobuf-mq7","title":"ra6 3a: descriptor → C plan compiler","description":"Compile finalized Lua descriptors into a C-side plan userdata, stashed on desc.c_plan. The plan carries per-field records (field number, wire type, kind tag, presence offset, default index), pre-encoded tag bytes, luaL_ref slots for cached field names, sub-descriptor pointers, oneof grouping metadata (parallel array, matches desc.oneofs_list), extension range hooks, and WKT override pointers (desc.encode/desc.decode passthrough). Rebuilt at pb.finalize_message time when PB_ENABLE_C=1. Not serialized, not shared across processes. Foundation for all other ra6 sub-issues — no other 3* issue can start until this lands. Acceptance: pb.c_runtime.compile_plan(desc) returns a userdata; plan-\u003en_fields and plan-\u003efields[i].tag are readable from a C-level smoke test; plans for hello.Person and Address build without error.","status":"open","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:19:58Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:19:58Z","dependencies":[{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-47e","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-z7x","type":"blocks","created_at":"2026-05-18T23:21:53Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":3,"dependent_count":5,"comment_count":0} +{"_type":"issue","id":"tarantool-protobuf-mq7","title":"ra6 3a: descriptor → C plan compiler","description":"Compile finalized Lua descriptors into a C-side plan userdata, stashed on desc.c_plan. The plan carries per-field records (field number, wire type, kind tag, presence offset, default index), pre-encoded tag bytes, luaL_ref slots for cached field names, sub-descriptor pointers, oneof grouping metadata (parallel array, matches desc.oneofs_list), extension range hooks, and WKT override pointers (desc.encode/desc.decode passthrough). Rebuilt at pb.finalize_message time when PB_ENABLE_C=1. Not serialized, not shared across processes. Foundation for all other ra6 sub-issues — no other 3* issue can start until this lands. Acceptance: pb.c_runtime.compile_plan(desc) returns a userdata; plan-\u003en_fields and plan-\u003efields[i].tag are readable from a C-level smoke test; plans for hello.Person and Address build without error.","status":"in_progress","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-18T20:19:58Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:26:53Z","started_at":"2026-05-19T04:26:53Z","dependencies":[{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-47e","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-18T23:21:52Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-mq7","depends_on_id":"tarantool-protobuf-z7x","type":"blocks","created_at":"2026-05-18T23:21:53Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":3,"dependent_count":5,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-1eq","title":"C accel spike: generic one-call C codec variant (strategy 3)","description":"Sub-issue of tarantool-protobuf-04c. Implement strategy 3: a generic C codec that gets a descriptor + Lua table once per top-level encode/decode call and owns the inner loop. Internally dispatches per-field on descriptor kind (the work pf6 / ra6 ends up shipping in production). Measure with bench/c_accel/spike_bench.lua. Acceptance: numbers in bench/c_accel/README.md showing the gap to strategy 4 (hand-written, no dispatch).","notes":"Phase A measured strategy 4 ceiling at 3-10x over pure Lua. The 4 vs 3 gap is the key signal for pf6 architecture: \u003c20-40% gap means generic C runtime (ra6) alone is sufficient; \u003e40% gap means we need codegen-emitted C (c0i). For the spike, dispatch can be simple (switch on protoreflect.Kind); no need to design the compiled-plan format here.","status":"closed","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T19:28:08Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T19:44:07Z","closed_at":"2026-05-18T19:44:07Z","close_reason":"Measured as part of the 04c spike; numbers and architecture conclusion captured in 04c close, bench/c_accel/README.md, and bd memory c-accel-spike-04c-final-2026-05-18.","dependencies":[{"issue_id":"tarantool-protobuf-1eq","depends_on_id":"tarantool-protobuf-04c","type":"blocks","created_at":"2026-05-18T22:28:15Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-80t","title":"C accel spike: per-primitive FFI variant (strategy 2)","description":"Sub-issue of tarantool-protobuf-04c. Implement strategy 2 of the C-acceleration benchmark: replace wire.lua's encode_varint / decode_varint / encode_string / decode_string / fixed32/64 / double with ffi.C.\u003cfn\u003e calls into a small C shim. The Lua dispatch loop stays; only the primitives cross. Measure with bench/c_accel/spike_bench.lua across the same 5 sizes. Acceptance: numbers added to bench/c_accel/README.md results table.","notes":"Phase A landed strategies 1 + 4. Strategy 4 (hand-written Person C codec) sets the upper bound at 3-10x over pure Lua. Strategy 2 must clear ~3x at every size to be competitive with 4; otherwise FFI per-primitive boundary cost is the wrong place to spend complexity. See bench/c_accel/README.md for full Phase A results.","status":"closed","priority":1,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-18T19:28:02Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T19:44:07Z","closed_at":"2026-05-18T19:44:07Z","close_reason":"Measured as part of the 04c spike; numbers and architecture conclusion captured in 04c close, bench/c_accel/README.md, and bd memory c-accel-spike-04c-final-2026-05-18.","dependencies":[{"issue_id":"tarantool-protobuf-80t","depends_on_id":"tarantool-protobuf-04c","type":"blocks","created_at":"2026-05-18T22:28:15Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-04c","title":"C accel: benchmark spike for Lua C module vs FFI boundaries","description":"Build a small benchmark spike before committing to a full backend. Compare: pure Lua current path, per-primitive FFI calls, one Lua C module call per top-level encode/decode, and a hand-written C codec for hello.Person. Measure 10B/100B/1KB/10KB/100KB Person and proto2 BenchPayload. Acceptance: numbers show which boundary wins and quantify C-call overhead for tiny messages where current Lua decode already beats starwing.","status":"closed","priority":1,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:31Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T19:43:59Z","started_at":"2026-05-18T19:21:27Z","closed_at":"2026-05-18T19:43:59Z","close_reason":"Spike complete across all 4 boundaries (Lua / S2 FFI prims / S3 generic C / S4 hand C) at 10B-100KB. Bench: bench/c_accel/. Decisive results. S3 ≈ S4 within 15% (S3 often faster on encode at scale). S2 LOSES vs pure-Lua at every size 1KB+ on encode (0.60-0.99x) and at every size on decode (0.29-0.39x). FFI cost decomposition (bench/c_accel/ffi_probe.lua, memory luajit-ffi-boundary-cost-2026-05-18): bare FFI = 33ns; pointer-return FFI = 73ns; ffi.cast on string = 156ns; libc memcmp = 60ns; pure-Lua varint decode = 75ns. Per-primitive boundary cost equals pure-Lua dispatch cost — only message-level C crossing wins. ARCHITECTURE for pf6: ship ra6 (generic C runtime, descriptor-walking, one C call per message); drop c0i (≤15% headroom over ra6); drop per-primitive FFI. Required ra6 impl pattern: cache per-field stack indices for repeated/packed arrays during decode_message (naive lazy-getfield 2x slower at 100KB). Full details in bench/c_accel/README.md and bd memory c-accel-spike-04c-final-2026-05-18 + luajit-ffi-boundary-cost-2026-05-18.","dependency_count":0,"dependent_count":3,"comment_count":0} @@ -32,7 +32,7 @@ {"_type":"issue","id":"tarantool-protobuf-2nl","title":"Repo: unstealth Beads tracking files","description":"Make the repo-local Beads state visible to Git by removing the local .git/info/exclude rule for .beads/. Keep .beads/.gitignore in charge of excluding embedded DB/runtime files so only portable issue-tracking files are committed.","status":"closed","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T18:39:50Z","created_by":"Eugene Blikh","updated_at":"2026-05-17T18:42:26Z","closed_at":"2026-05-17T18:42:26Z","close_reason":"Removed the local .git/info/exclude rule that hid .beads/ from Git. Portable .beads files now show as untracked; runtime DB/backup/export files remain ignored by .beads/.gitignore.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-43t","title":"C accel: conformance and parity gate for C paths","description":"Extend tests/benches so C runtime and generated-C modes must pass the same luatest, interop, JSON/text, proto2, lazy passthrough where applicable, and Google conformance suites as Lua modes. Add bench output columns for lua-full, lua-runtime, c-runtime, c-generated, starwing. C acceleration must remain byte-equal with current encoders and preserve unknown fields/extensions/groups.","notes":"Architecture finalized in docs/c-accel.md. Parity strategy: REUSE existing test suites — no separate Lua-vs-C diff harness. (1) just test (luatest, 639 tests already parameterized over full/runtime modes) runs once with PB_ENABLE_C unset and once with PB_ENABLE_C=1. Both must pass. (2) just conformance (Google proto3 + proto2) runs both modes. (3) Interop fixtures (test/interop/fixtures/*.bin) cover both modes via the existing parity.full_vs_runtime tests; extend the matrix to also cover c-runtime. Logic: every assert is against a reference output (golden bytes, txtpb, conformance result). If Lua passes and C passes, both equal the reference, so Lua == C by transitivity. No new test infrastructure required. bench/bench.lua gains a c-runtime column.","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:53Z","created_by":"Eugene Blikh","updated_at":"2026-05-18T20:19:36Z","dependencies":[{"issue_id":"tarantool-protobuf-43t","depends_on_id":"tarantool-protobuf-c0i","type":"blocks","created_at":"2026-05-17T19:33:44Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-43t","depends_on_id":"tarantool-protobuf-ra6","type":"blocks","created_at":"2026-05-17T19:33:44Z","created_by":"Eugene Blikh","metadata":"{}"},{"issue_id":"tarantool-protobuf-43t","depends_on_id":"tarantool-protobuf-wky","type":"blocks","created_at":"2026-05-17T19:33:44Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-z7x","title":"C accel: C table and string handling strategy","description":"Design and implement the low-level C helpers for Lua table access and string/buffer handling. Cache field-name strings/registry refs in descriptor plans, pre-size result tables where possible, build output strings with luaL_Buffer or exact-size allocation, avoid per-field Lua stack churn, and handle 64-bit cdata consistently with the current Lua API.","notes":"Architecture finalized in docs/c-accel.md. Strategy decisions validated by bench/c_accel/ spike: (1) Field-name strings cached as luaL_ref slots at plan-compile time; each decode does lua_rawgeti from the cached ref instead of re-interning. (2) Output buffer: 4KB stack-backed cap with malloc promotion on overflow (pattern in bench/c_accel/person_codec.c — validates against tiny-msg overhead and big-msg correctness). (3) Result tables pre-sized via lua_createtable(0, n_fields) from descriptor stats. (4) Per-field stack indices cached for repeated/packed array fields for the duration of decode_message — naive lazy-getfield was 2x slower at 100KB (spike Phase B). (5) 64-bit cdata via luaT_pushuint64/luaT_pushint64 (Tarantool extensions in module.h). (6) Sub-messages use separate buf with stack-allocated header; backpatching avoided (the bench showed sub-buf approach is fast enough).","status":"closed","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:53Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:24:12Z","started_at":"2026-05-19T04:10:45Z","closed_at":"2026-05-19T04:24:12Z","close_reason":"C-side strategy formalized in docs/specs/c_accel_strategy.md. Spec covers: pb_plan / pb_plan_field struct layout, field-name luaL_ref caching (spike-validated), 4 KB stack-backed pb_buf with malloc promotion, lua_createtable(0, n_fields) result pre-sizing, cached per-field stack indices for repeated/packed (spike Phase B: 2x speedup at 100 KB), 64-bit cdata via luaT_pushint64/checkint64, sub-buffer (chosen) vs backpatching (deferred) for sub-messages, two-pass packed encode, map iteration via lua_next, oneof grouping, unknown field passthrough, deferred micro-opts (manual varint inlining, SIMD scan), C99/-O2 build environment, deferred PB_C_TRACE/PB_C_CHECK_PARITY env knobs. ra6/mq7 references this spec for low-level decisions.","dependency_count":0,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-47e","title":"C accel: preserve pure-Lua fallback and public API compatibility","description":"Define compatibility boundaries for C acceleration. Existing generated Lua modules and runtime pb APIs must keep working without a compiler or C module. New generated-C mode should be opt-in initially. Errors, 64-bit cdata behavior, WKT shapes, unknown fields, extension representation, lazy decode API, and text/JSON integration must remain compatible.","notes":"Architecture finalized in docs/c-accel.md. Concrete contract: (1) PB_ENABLE_C=1 env var is the only activation switch, default off. (2) require('pb') returns the same Lua surface in both modes — no API change. (3) 64-bit ints stay LuaJIT cdata (int64_t/uint64_t) in both modes. (4) WKT shapes unchanged. (5) Unknown fields round-trip identically. (6) Errors land as the same Lua error types. (7) Pure-Lua install must work without a C compiler — rockspec opt-builds the C module, install never fails on a host without cc. (8) When C module fails to load or PB_ENABLE_C is unset, runtime/pb/init.lua transparently uses the pure-Lua path.","status":"closed","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:52Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:23:56Z","started_at":"2026-05-19T04:10:44Z","closed_at":"2026-05-19T04:23:56Z","close_reason":"Compatibility contract formalized in docs/specs/c_accel_compat.md. Spec covers: PB_ENABLE_C=1 single activation switch, silent fallback, full public surface preservation (pb.encode/decode/decode_lazy/parse/from_pb/json/text/grpc), generated module wrappers, 64-bit cdata invariant, WKT shapes, unknown fields, extensions, error compatibility, install paths (pure-Lua and C-enabled), ABI versioning, conformance via existing-suite re-run, explicit out-of-scope list. Lazy view stays Lua-only per spike rationale. Downstream tasks (mq7, ra6, 43t, c0i) reference this spec for compat boundaries.","dependency_count":0,"dependent_count":3,"comment_count":0} -{"_type":"issue","id":"tarantool-protobuf-wky","title":"C accel: build and packaging support for C modules","description":"Add build-system and packaging support for C acceleration. Needs Justfile targets, rockspec support for compiled modules, platform naming, local dev build, CI matrix integration, and fallback when the C module is absent. Generated C backend must not make pure-Lua install impossible unless explicitly selected.","notes":"Architecture finalized in docs/c-accel.md. Concrete deliverables: (1) Justfile recipe 'just build-c' that builds the C runtime module (mirror bench/c_accel/Makefile auto-detection of TT_INC). (2) Rockspec optionally builds the C module — install on host without cc must succeed and produce a pure-Lua install. (3) runtime/pb/init.lua does pcall(require, 'pb.c_runtime') only when os.getenv('PB_ENABLE_C') == '1'. (4) Sourcehut CI build manifest gains one extra job that runs the full test+conformance suite with PB_ENABLE_C=1 set — single .build.yml, both modes covered on every push to master. (5) CI also runs one variant without the C module compiled to confirm pure-Lua install works.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:52Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:10:45Z","started_at":"2026-05-19T04:10:45Z","dependencies":[{"issue_id":"tarantool-protobuf-wky","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-17T19:33:11Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"tarantool-protobuf-wky","title":"C accel: build and packaging support for C modules","description":"Add build-system and packaging support for C acceleration. Needs Justfile targets, rockspec support for compiled modules, platform naming, local dev build, CI matrix integration, and fallback when the C module is absent. Generated C backend must not make pure-Lua install impossible unless explicitly selected.","notes":"Architecture finalized in docs/c-accel.md. Concrete deliverables: (1) Justfile recipe 'just build-c' that builds the C runtime module (mirror bench/c_accel/Makefile auto-detection of TT_INC). (2) Rockspec optionally builds the C module — install on host without cc must succeed and produce a pure-Lua install. (3) runtime/pb/init.lua does pcall(require, 'pb.c_runtime') only when os.getenv('PB_ENABLE_C') == '1'. (4) Sourcehut CI build manifest gains one extra job that runs the full test+conformance suite with PB_ENABLE_C=1 set — single .build.yml, both modes covered on every push to master. (5) CI also runs one variant without the C module compiled to confirm pure-Lua install works.","status":"closed","priority":2,"issue_type":"task","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-05-17T16:32:52Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T04:24:13Z","started_at":"2026-05-19T04:10:45Z","closed_at":"2026-05-19T04:24:13Z","close_reason":"Build and packaging scaffolding landed: docs/specs/c_accel_build_packaging.md spec, runtime/pb/init.lua PB_ENABLE_C=1 opt-in pcall hook (exposed as pb.c_runtime field), Justfile build-c/clean-c recipes (stub erroring cleanly until bd-ra6 lands runtime/pb/c/), Justfile lua_cpath constant + LUA_CPATH on test/test-one, .gitignore for compiled .so/.dylib, sourcehut .builds/{pure-lua,c-enabled}.yml manifests (ubuntu/noble, parallel jobs via separate files since sourcehut has no matrix), .sourcehut/conformance.yml for manual conformance triggers. Rockspec changes deferred to first C-source landing (described in spec). All 745 tests pass with PB_ENABLE_C unset and PB_ENABLE_C=1 (silent fallback verified).","dependencies":[{"issue_id":"tarantool-protobuf-wky","depends_on_id":"tarantool-protobuf-pf6","type":"blocks","created_at":"2026-05-17T19:33:11Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-801","title":"Release: tagged releases + rockspec + Go plugin binaries","description":"Once the CI pipeline is green, ship tagged releases. Rockspec for the Lua runtime (publishable via tt rocks). Pre-built protoc-gen-tarantool binaries for darwin/arm64, darwin/amd64, linux/amd64, linux/arm64. Sourcecraft has 'sc release' tooling (see sc-release skill) for the release workflow itself.","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T15:47:16Z","created_by":"Eugene Blikh","updated_at":"2026-05-17T15:47:16Z","labels":["release"],"dependencies":[{"issue_id":"tarantool-protobuf-801","depends_on_id":"tarantool-protobuf-1eu","type":"blocks","created_at":"2026-05-17T18:47:20Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-1eu","title":"Release: Sourcecraft.dev project + CI pipeline","description":"Set up the canonical sourcecraft.dev project for the repo and a CI pipeline. Matrix: Tarantool 2.11 (CE+EE) and 3.x (CE+EE), Linux + macOS. Targets to run: just gen, just test, just bench-compare (alloc regression gate), just conformance (gated on the cached Docker image — see related CI wire-up bead). The repo lives under ~/data/home which by convention publishes to sourcecraft.dev (not github).","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T15:47:15Z","created_by":"Eugene Blikh","updated_at":"2026-05-17T15:47:15Z","labels":["ci","release"],"dependencies":[{"issue_id":"tarantool-protobuf-1eu","depends_on_id":"tarantool-protobuf-7lf","type":"blocks","created_at":"2026-05-17T18:47:20Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"tarantool-protobuf-7lf","title":"CI: wire up conformance suite with cached Docker image","description":"The Docker image build (docker/conformance.Dockerfile) is the long pole at ~10-15 min on a clean cache. A registry push from a scheduled job would let CI runs reuse a warm image. Today the conformance suite runs locally via 'just conformance' but isn't gated on pushes. Goal: every push to master runs the binary+JSON and text-format suites; PRs run the same. Pre-requisite for M8 sourcecraft setup (this defines what the CI pipeline runs).","status":"open","priority":2,"issue_type":"task","owner":"bigbes@gmail.com","created_at":"2026-05-17T15:47:15Z","created_by":"Eugene Blikh","updated_at":"2026-05-17T15:47:15Z","labels":["ci","conformance"],"dependency_count":0,"dependent_count":1,"comment_count":0} diff --git a/Justfile b/Justfile index 1f93548f219cb73dd015ccdb72598e05d9ebd7b1..f55b59e6d32fbdbab75e9e1988c5936062a1a54f 100644 --- a/Justfile +++ b/Justfile @@ -74,12 +74,12 @@ echo " The C runtime arrives with bd-ra6 (generic C codec)."; \ echo " See docs/specs/c_accel_build_packaging.md."; \ exit 1; \ fi - $(MAKE) -C runtime/pb/c + make -C runtime/pb/c # Remove built C-runtime artifacts. clean-c: rm -f runtime/pb/c_runtime.so runtime/pb/c_runtime.dylib - @if [ -d runtime/pb/c ]; then $(MAKE) -C runtime/pb/c clean; fi + @if [ -d runtime/pb/c ]; then make -C runtime/pb/c clean; fi # --------------------------------------------------------------------------- # Codegen diff --git a/runtime/pb/c/Makefile b/runtime/pb/c/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..b0b07b5e02f03b5f7be18b4b0a8ba3f65679b9d7 --- /dev/null +++ b/runtime/pb/c/Makefile @@ -0,0 +1,55 @@ +# Makefile for pb.c_runtime — the optional C-acceleration runtime. +# +# Output sits at ../c_runtime.{so,dylib} so that +# require('pb.c_runtime') +# resolves it via the LUA_CPATH the Justfile sets for `just test` +# (see Justfile lua_cpath: "./runtime/?.so;./runtime/?.dylib;..."). +# +# Built only when PB_ENABLE_C=1 is part of the user's workflow. +# Pure-Lua install ignores this directory entirely. + +# Locate . Order: env override, brew prefix, common system dirs. +# Mirrors bench/c_accel/Makefile. +TT_INC ?= $(shell \ + if [ -n "$$TARANTOOL_INCLUDE" ] && [ -f "$$TARANTOOL_INCLUDE/module.h" ]; then \ + echo "$$TARANTOOL_INCLUDE"; exit 0; \ + fi; \ + for d in \ + $$(brew --prefix tarantool 2>/dev/null)/include/tarantool \ + /opt/homebrew/include/tarantool \ + /usr/local/include/tarantool \ + /usr/include/tarantool; do \ + if [ -f "$$d/module.h" ]; then echo "$$d"; exit 0; fi; \ + done) + +ifeq ($(TT_INC),) +$(error Cannot find ; set TARANTOOL_INCLUDE=/path/to/include/tarantool or install tarantool-dev) +endif + +UNAME_S := $(shell uname -s) + +CFLAGS := -O2 -fPIC -Wall -Wextra -std=c99 -I$(TT_INC) +ifeq ($(UNAME_S),Darwin) + LIB_EXT := dylib + LDFLAGS := -bundle -undefined dynamic_lookup +else + LIB_EXT := so + LDFLAGS := -shared +endif + +SRCS := c_runtime.c +OBJS := $(SRCS:.c=.o) +OUTPUT := ../c_runtime.$(LIB_EXT) + +all: $(OUTPUT) + +$(OUTPUT): $(OBJS) + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(OBJS) + +%.o: %.c + $(CC) $(CFLAGS) -c -o $@ $< + +clean: + rm -f *.o $(OUTPUT) + +.PHONY: all clean diff --git a/runtime/pb/c/c_runtime.c b/runtime/pb/c/c_runtime.c new file mode 100644 index 0000000000000000000000000000000000000000..e97ff3bdbe174b89d3866ad75d43df39fdedeae8 --- /dev/null +++ b/runtime/pb/c/c_runtime.c @@ -0,0 +1,840 @@ +/* + * c_runtime.c -- C-acceleration runtime for tarantool-protobuf. + * + * Phase 1 (bd-mq7): descriptor -> plan compiler. Walks a finalized + * Lua descriptor table and produces an opaque `pb_plan` userdata. + * Encode/decode entry points arrive with bd-3b/3c/etc.; this file + * only carries the plan compilation surface and enough introspection + * to make a smoke test possible. + * + * Conventions follow docs/specs/c_accel_strategy.md. + */ + +#include +#include + +#include +#include +#include + +#define PB_PLAN_MT "pb.plan" +#define PB_ABI_VERSION "1" + +/* ---------------------------------------------------------------- * + * Kind / wire-type taxonomy. * + * * + * Mirrors runtime/pb/wire.lua's TYPE_INFO. The numbering is * + * internal — only the C runtime needs to agree with itself. * + * ---------------------------------------------------------------- */ + +enum { + PB_KIND_NONE = 0, + PB_KIND_INT32, + PB_KIND_INT64, + PB_KIND_UINT32, + PB_KIND_UINT64, + PB_KIND_SINT32, + PB_KIND_SINT64, + PB_KIND_FIXED32, + PB_KIND_FIXED64, + PB_KIND_SFIXED32, + PB_KIND_SFIXED64, + PB_KIND_FLOAT, + PB_KIND_DOUBLE, + PB_KIND_BOOL, + PB_KIND_STRING, + PB_KIND_BYTES, + PB_KIND_ENUM, + PB_KIND_MESSAGE, + PB_KIND_MAP, +}; + +/* Wire types per proto3 spec. */ +enum { + PB_WIRE_VARINT = 0, + PB_WIRE_I64 = 1, + PB_WIRE_LEN = 2, + PB_WIRE_I32 = 5, +}; + +struct kind_info { + const char *proto_type; + uint8_t kind; + uint8_t wire_type; +}; + +/* Lookup table keyed by proto3 field type string. Linear scan is fine — + * a finalized descriptor calls this once per field at plan-compile time, + * not per encode/decode. */ +static const struct kind_info kind_table[] = { + {"int32", PB_KIND_INT32, PB_WIRE_VARINT}, + {"int64", PB_KIND_INT64, PB_WIRE_VARINT}, + {"uint32", PB_KIND_UINT32, PB_WIRE_VARINT}, + {"uint64", PB_KIND_UINT64, PB_WIRE_VARINT}, + {"sint32", PB_KIND_SINT32, PB_WIRE_VARINT}, + {"sint64", PB_KIND_SINT64, PB_WIRE_VARINT}, + {"bool", PB_KIND_BOOL, PB_WIRE_VARINT}, + {"fixed32", PB_KIND_FIXED32, PB_WIRE_I32}, + {"sfixed32", PB_KIND_SFIXED32, PB_WIRE_I32}, + {"float", PB_KIND_FLOAT, PB_WIRE_I32}, + {"fixed64", PB_KIND_FIXED64, PB_WIRE_I64}, + {"sfixed64", PB_KIND_SFIXED64, PB_WIRE_I64}, + {"double", PB_KIND_DOUBLE, PB_WIRE_I64}, + {"string", PB_KIND_STRING, PB_WIRE_LEN}, + {"bytes", PB_KIND_BYTES, PB_WIRE_LEN}, +}; + +static const struct kind_info * +lookup_kind(const char *proto_type) +{ + if (proto_type == NULL) + return NULL; + for (size_t i = 0; i < sizeof(kind_table) / sizeof(kind_table[0]); i++) { + if (strcmp(kind_table[i].proto_type, proto_type) == 0) + return &kind_table[i]; + } + return NULL; +} + +/* ---------------------------------------------------------------- * + * Plan struct layout. * + * * + * Spec: docs/specs/c_accel_strategy.md § The plan userdata. * + * Simplifications from the spec for mq7: * + * - oneofs[] populated but member dispatch lives in 3i * + * - extension_range_* populated but dispatch lives in 3i * + * - sub_plan_idx points into `sub_plans_ref` table (1-based) * + * - Field name strings live in a Lua table keyed by 1..n; * + * lookup via `lua_rawgeti(L, names, i+1)` per spec. * + * ---------------------------------------------------------------- */ + +typedef struct pb_plan_field { + uint32_t field_number; + uint8_t wire_type; + uint8_t kind; + uint8_t packed; + uint8_t repeated; + uint8_t optional; + uint8_t tag_len; + uint8_t tag_bytes[5]; + int sub_plan_idx; /* 1-based into sub_plans table; 0 if none */ + uint8_t map_key_kind; + uint8_t map_value_kind; + int map_value_sub_plan_idx; /* 1-based; 0 if value is scalar */ + int oneof_idx; /* 0-based into plan->oneofs; -1 if none */ + int enum_ref; /* LUA_REGISTRYINDEX ref for enum desc; LUA_NOREF if none */ +} pb_plan_field; + +typedef struct pb_plan_oneof { + char *name; /* malloc'd */ + int n_members; + int *member_indices; /* indices into plan->fields */ +} pb_plan_oneof; + +typedef struct pb_plan { + char *name; /* malloc'd descriptor name */ + int n_fields; + pb_plan_field *fields; + int n_oneofs; + pb_plan_oneof *oneofs; + int extension_range_start; + int extension_range_end; + uint8_t has_override; + int override_encode_ref; /* LUA_NOREF if absent */ + int override_decode_ref; + int field_names_ref; /* table { [1]=name1, ... } */ + int sub_plans_ref; /* table { [1]=plan_userdata, ... } */ +} pb_plan; + +/* ---------------------------------------------------------------- * + * Tag encoding. * + * * + * Pre-encodes the (field_number << 3) | wire_type varint so the * + * hot encode path emits a fixed memcpy instead of recomputing. * + * Up to 5 bytes for any legal field number (2^29 - 1 max). * + * ---------------------------------------------------------------- */ + +static void +encode_tag(uint32_t field_number, uint8_t wire_type, + uint8_t *out, uint8_t *out_len) +{ + uint64_t v = ((uint64_t)field_number << 3) | wire_type; + uint8_t i = 0; + while (v >= 0x80) { + out[i++] = (uint8_t)(v | 0x80); + v >>= 7; + } + out[i++] = (uint8_t)v; + *out_len = i; +} + +/* ---------------------------------------------------------------- * + * Plan lifecycle: alloc / free. * + * ---------------------------------------------------------------- */ + +static void +plan_free(lua_State *L, pb_plan *p) +{ + if (p->name != NULL) + free(p->name); + if (p->fields != NULL) { + for (int i = 0; i < p->n_fields; i++) { + if (p->fields[i].enum_ref != LUA_NOREF) + luaL_unref(L, LUA_REGISTRYINDEX, p->fields[i].enum_ref); + } + free(p->fields); + } + if (p->oneofs != NULL) { + for (int i = 0; i < p->n_oneofs; i++) { + free(p->oneofs[i].name); + free(p->oneofs[i].member_indices); + } + free(p->oneofs); + } + if (p->override_encode_ref != LUA_NOREF) + luaL_unref(L, LUA_REGISTRYINDEX, p->override_encode_ref); + if (p->override_decode_ref != LUA_NOREF) + luaL_unref(L, LUA_REGISTRYINDEX, p->override_decode_ref); + if (p->field_names_ref != LUA_NOREF) + luaL_unref(L, LUA_REGISTRYINDEX, p->field_names_ref); + if (p->sub_plans_ref != LUA_NOREF) + luaL_unref(L, LUA_REGISTRYINDEX, p->sub_plans_ref); + + memset(p, 0, sizeof(*p)); + p->override_encode_ref = LUA_NOREF; + p->override_decode_ref = LUA_NOREF; + p->field_names_ref = LUA_NOREF; + p->sub_plans_ref = LUA_NOREF; +} + +static int +plan_gc(lua_State *L) +{ + pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT); + plan_free(L, p); + return 0; +} + +static int +plan_tostring(lua_State *L) +{ + pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT); + lua_pushfstring(L, "pb.plan: %s (n_fields=%d)", + p->name != NULL ? p->name : "(unnamed)", + p->n_fields); + return 1; +} + +/* ---------------------------------------------------------------- * + * Field compilation. * + * * + * Reads one descriptor-field table (at the top of the stack) and * + * fills the corresponding pb_plan_field. Recurses into sub-message * + * plans by calling compile_plan_impl. * + * * + * Stack expectations on entry: * + * -1: field-descriptor table (e.g. {name="foo", id=1, kind=...})* + * sub_plans_stack_idx: the sub-plans table being filled * + * * + * Stack on exit: same (we pop everything we push). * + * ---------------------------------------------------------------- */ + +static int compile_plan_impl(lua_State *L, int desc_idx); + +/* Append a sub-plan userdata (at -1) to the sub-plans table at + * sub_plans_idx; return its 1-based index. Pops the userdata. */ +static int +push_sub_plan(lua_State *L, int sub_plans_idx) +{ + int n = (int)lua_objlen(L, sub_plans_idx); + lua_rawseti(L, sub_plans_idx, n + 1); + return n + 1; +} + +/* Resolve a sub-message descriptor (at stack idx `sub_desc_idx`) into a + * 1-based index into the sub-plans table. Stack-neutral: pushes/pops the + * intermediate desc copy and plan userdata internally. */ +static int +resolve_sub_plan(lua_State *L, int sub_desc_idx, int sub_plans_idx) +{ + int saved_top = lua_gettop(L); + /* compile_plan_impl is idempotent — if desc.c_plan exists it just + * leaves the cached userdata on top. */ + lua_pushvalue(L, sub_desc_idx); + int dup_idx = lua_gettop(L); + compile_plan_impl(L, dup_idx); + if (!lua_isuserdata(L, -1)) + luaL_error(L, "compile_plan_impl did not return a userdata"); + int idx = push_sub_plan(L, sub_plans_idx); /* pops plan userdata */ + lua_settop(L, saved_top); /* drop sub_desc copy */ + return idx; +} + +/* Compile one field. `f_desc_idx` is the absolute stack index of the + * field descriptor table. `field` points at the pb_plan_field slot to + * fill. `field_names_idx`, `sub_plans_idx` are absolute indices of the + * field-names and sub-plans tables being populated. `field_idx_1based` + * is the 1-based position used for the field-names lookup table. */ +static void +compile_field(lua_State *L, int f_desc_idx, pb_plan_field *field, + int field_names_idx, int sub_plans_idx, int field_idx_1based) +{ + memset(field, 0, sizeof(*field)); + field->enum_ref = LUA_NOREF; + field->oneof_idx = -1; + + /* field number */ + lua_getfield(L, f_desc_idx, "id"); + if (!lua_isnumber(L, -1)) + luaL_error(L, "field descriptor missing 'id'"); + field->field_number = (uint32_t)lua_tointeger(L, -1); + lua_pop(L, 1); + + /* name (interned in field_names_ref under field_idx_1based) */ + lua_getfield(L, f_desc_idx, "name"); + if (!lua_isstring(L, -1)) + luaL_error(L, "field descriptor missing 'name'"); + lua_pushvalue(L, -1); /* dup */ + lua_rawseti(L, field_names_idx, field_idx_1based); + lua_pop(L, 1); + + /* repeated / packed / optional */ + lua_getfield(L, f_desc_idx, "repeated"); + field->repeated = lua_toboolean(L, -1) ? 1 : 0; + lua_pop(L, 1); + lua_getfield(L, f_desc_idx, "packed"); + field->packed = lua_toboolean(L, -1) ? 1 : 0; + lua_pop(L, 1); + lua_getfield(L, f_desc_idx, "optional"); + field->optional = lua_toboolean(L, -1) ? 1 : 0; + lua_pop(L, 1); + + /* kind dispatch on desc.kind */ + lua_getfield(L, f_desc_idx, "kind"); + const char *kind_str = lua_tostring(L, -1); + if (kind_str == NULL) + luaL_error(L, "field descriptor missing 'kind'"); + + uint8_t element_wire_type; + + if (strcmp(kind_str, "scalar") == 0) { + lua_pop(L, 1); /* pop kind */ + lua_getfield(L, f_desc_idx, "proto_type"); + const char *pt = lua_tostring(L, -1); + const struct kind_info *ki = lookup_kind(pt); + if (ki == NULL) + luaL_error(L, "unknown scalar proto_type: %s", + pt != NULL ? pt : "(nil)"); + field->kind = ki->kind; + element_wire_type = ki->wire_type; + lua_pop(L, 1); + } else if (strcmp(kind_str, "enum") == 0) { + lua_pop(L, 1); + field->kind = PB_KIND_ENUM; + element_wire_type = PB_WIRE_VARINT; + lua_getfield(L, f_desc_idx, "enum"); + if (lua_istable(L, -1)) { + field->enum_ref = luaL_ref(L, LUA_REGISTRYINDEX); + } else { + lua_pop(L, 1); + } + } else if (strcmp(kind_str, "message") == 0) { + lua_pop(L, 1); + field->kind = PB_KIND_MESSAGE; + element_wire_type = PB_WIRE_LEN; + lua_getfield(L, f_desc_idx, "message"); + if (!lua_istable(L, -1)) + luaL_error(L, "message field '%s' missing 'message' descriptor", + "?"); + int sub_desc = lua_gettop(L); + field->sub_plan_idx = resolve_sub_plan(L, sub_desc, sub_plans_idx); + lua_pop(L, 1); /* sub-desc table */ + } else if (strcmp(kind_str, "map") == 0) { + lua_pop(L, 1); + field->kind = PB_KIND_MAP; + element_wire_type = PB_WIRE_LEN; + + /* key */ + lua_getfield(L, f_desc_idx, "key"); + if (!lua_istable(L, -1)) + luaL_error(L, "map field missing 'key' descriptor"); + lua_getfield(L, -1, "proto_type"); + const struct kind_info *ki = lookup_kind(lua_tostring(L, -1)); + if (ki == NULL) + luaL_error(L, "map key has unknown proto_type"); + field->map_key_kind = ki->kind; + lua_pop(L, 2); /* proto_type + key */ + + /* value */ + lua_getfield(L, f_desc_idx, "value"); + if (!lua_istable(L, -1)) + luaL_error(L, "map field missing 'value' descriptor"); + lua_getfield(L, -1, "kind"); + const char *vk = lua_tostring(L, -1); + lua_pop(L, 1); + if (vk != NULL && strcmp(vk, "scalar") == 0) { + lua_getfield(L, -1, "proto_type"); + const struct kind_info *vki = lookup_kind(lua_tostring(L, -1)); + if (vki == NULL) + luaL_error(L, "map value has unknown scalar proto_type"); + field->map_value_kind = vki->kind; + lua_pop(L, 1); + } else if (vk != NULL && strcmp(vk, "message") == 0) { + field->map_value_kind = PB_KIND_MESSAGE; + lua_getfield(L, -1, "message"); + if (lua_istable(L, -1)) { + int sub_desc = lua_gettop(L); + field->map_value_sub_plan_idx = + resolve_sub_plan(L, sub_desc, sub_plans_idx); + } + lua_pop(L, 1); + } else if (vk != NULL && strcmp(vk, "enum") == 0) { + field->map_value_kind = PB_KIND_ENUM; + } else { + luaL_error(L, "map value has unknown kind: %s", + vk != NULL ? vk : "(nil)"); + } + lua_pop(L, 1); /* value table */ + } else { + luaL_error(L, "unknown field kind: %s", kind_str); + return; /* unreachable */ + } + + /* Wire type: repeated+packed → LEN regardless of element type; + * repeated unpacked → element type per tag; singular → element. */ + if (field->repeated && field->packed) { + field->wire_type = PB_WIRE_LEN; + } else { + field->wire_type = element_wire_type; + } + + encode_tag(field->field_number, field->wire_type, + field->tag_bytes, &field->tag_len); +} + +/* ---------------------------------------------------------------- * + * Oneof compilation. * + * * + * desc.oneofs is the hash table {[name]=members}. We walk it and * + * build pb_plan_oneof[]. For each member field, set its oneof_idx * + * to point back at the plan's oneof entry. * + * ---------------------------------------------------------------- */ + +static int +find_field_by_name(pb_plan *p, lua_State *L, int field_names_idx, + const char *name) +{ + for (int i = 0; i < p->n_fields; i++) { + lua_rawgeti(L, field_names_idx, i + 1); + const char *fn = lua_tostring(L, -1); + int match = (fn != NULL && strcmp(fn, name) == 0); + lua_pop(L, 1); + if (match) return i; + } + return -1; +} + +static void +compile_oneofs(lua_State *L, pb_plan *p, int desc_idx, int field_names_idx) +{ + lua_getfield(L, desc_idx, "oneofs"); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + return; + } + + /* First pass: count. */ + int count = 0; + lua_pushnil(L); + while (lua_next(L, -2) != 0) { + count++; + lua_pop(L, 1); + } + if (count == 0) { + lua_pop(L, 1); + return; + } + + p->oneofs = (pb_plan_oneof *)calloc(count, sizeof(pb_plan_oneof)); + p->n_oneofs = count; + + int idx = 0; + lua_pushnil(L); + while (lua_next(L, -2) != 0) { + /* key at -2 (oneof name), value at -1 (members array) */ + const char *oname = lua_tostring(L, -2); + p->oneofs[idx].name = strdup(oname != NULL ? oname : ""); + + int n_members = (int)lua_objlen(L, -1); + p->oneofs[idx].n_members = n_members; + p->oneofs[idx].member_indices = (int *)calloc(n_members, sizeof(int)); + + for (int j = 0; j < n_members; j++) { + lua_rawgeti(L, -1, j + 1); + const char *mname = lua_tostring(L, -1); + int fi = find_field_by_name(p, L, field_names_idx, + mname != NULL ? mname : ""); + lua_pop(L, 1); + p->oneofs[idx].member_indices[j] = fi; + if (fi >= 0) + p->fields[fi].oneof_idx = idx; + } + + lua_pop(L, 1); /* value */ + idx++; + } + lua_pop(L, 1); /* oneofs table */ +} + +/* ---------------------------------------------------------------- * + * Main compile entry. * + * * + * Idempotent. If desc.c_plan exists and is a pb.plan userdata, * + * returns it. Otherwise allocates a new one, stashes it on * + * desc.c_plan BEFORE recursing into sub-plans (breaks cycles for * + * self-referencing messages like Person.friends → Person), then * + * walks fields and oneofs. * + * ---------------------------------------------------------------- */ + +/* Lua 5.1 lacks lua_absindex; LuaJIT's compatibility layer too. */ +static inline int +abs_idx(lua_State *L, int idx) +{ + if (idx < 0 && idx > LUA_REGISTRYINDEX) + return lua_gettop(L) + idx + 1; + return idx; +} + +static int +compile_plan_impl(lua_State *L, int desc_idx) +{ + desc_idx = abs_idx(L, desc_idx); + + /* Idempotency check. */ + lua_getfield(L, desc_idx, "c_plan"); + if (lua_isuserdata(L, -1)) { + /* Already compiled; leave on top of stack and pop the original + * desc-table push pattern wasn't done — caller still owns. */ + return 1; + } + lua_pop(L, 1); + + /* Allocate the plan userdata and stash it immediately. */ + pb_plan *p = (pb_plan *)lua_newuserdata(L, sizeof(pb_plan)); + memset(p, 0, sizeof(*p)); + p->override_encode_ref = LUA_NOREF; + p->override_decode_ref = LUA_NOREF; + p->field_names_ref = LUA_NOREF; + p->sub_plans_ref = LUA_NOREF; + + luaL_getmetatable(L, PB_PLAN_MT); + lua_setmetatable(L, -2); + int plan_idx = lua_gettop(L); + + /* Stash on desc.c_plan first to break sub-message cycles. */ + lua_pushvalue(L, plan_idx); + lua_setfield(L, desc_idx, "c_plan"); + + /* desc.name */ + lua_getfield(L, desc_idx, "name"); + const char *dname = lua_tostring(L, -1); + p->name = strdup(dname != NULL ? dname : ""); + lua_pop(L, 1); + + /* WKT override pointers — desc.encode / desc.decode. */ + lua_getfield(L, desc_idx, "encode"); + if (lua_isfunction(L, -1)) { + p->has_override = 1; + p->override_encode_ref = luaL_ref(L, LUA_REGISTRYINDEX); + } else { + lua_pop(L, 1); + } + lua_getfield(L, desc_idx, "decode"); + if (lua_isfunction(L, -1)) { + p->has_override = 1; + p->override_decode_ref = luaL_ref(L, LUA_REGISTRYINDEX); + } else { + lua_pop(L, 1); + } + + /* Extension ranges (proto2). desc.extension_ranges = {{start, end}, ...} */ + lua_getfield(L, desc_idx, "extension_ranges"); + if (lua_istable(L, -1) && lua_objlen(L, -1) >= 1) { + lua_rawgeti(L, -1, 1); + if (lua_istable(L, -1)) { + lua_rawgeti(L, -1, 1); + p->extension_range_start = (int)lua_tointeger(L, -1); + lua_pop(L, 1); + lua_rawgeti(L, -1, 2); + p->extension_range_end = (int)lua_tointeger(L, -1); + lua_pop(L, 1); + } + lua_pop(L, 1); + } + lua_pop(L, 1); + + /* If this descriptor has an override, we skip field-walk entirely — + * the override owns encode/decode and the field array is unused. */ + if (p->has_override) { + return 1; + } + + /* Create the field-names and sub-plans tables. */ + lua_newtable(L); + int field_names_idx = lua_gettop(L); + lua_newtable(L); + int sub_plans_idx = lua_gettop(L); + + /* Walk desc.fields. */ + lua_getfield(L, desc_idx, "fields"); + if (!lua_istable(L, -1)) + luaL_error(L, "descriptor '%s' has no 'fields'", + p->name != NULL ? p->name : "?"); + int n_fields = (int)lua_objlen(L, -1); + int fields_table_idx = lua_gettop(L); + + p->n_fields = n_fields; + p->fields = (pb_plan_field *)calloc( + n_fields > 0 ? n_fields : 1, sizeof(pb_plan_field)); + + for (int i = 0; i < n_fields; i++) { + lua_rawgeti(L, fields_table_idx, i + 1); + int f_desc_idx = lua_gettop(L); + compile_field(L, f_desc_idx, &p->fields[i], + field_names_idx, sub_plans_idx, i + 1); + lua_pop(L, 1); + } + + lua_pop(L, 1); /* fields table */ + + /* Compile oneofs after fields so oneof_idx back-pointers can be set. */ + compile_oneofs(L, p, desc_idx, field_names_idx); + + /* Stash the field-names + sub-plans tables in the registry. */ + lua_pushvalue(L, sub_plans_idx); + p->sub_plans_ref = luaL_ref(L, LUA_REGISTRYINDEX); + lua_pop(L, 1); /* sub_plans_idx (now unreferenced from stack) */ + + lua_pushvalue(L, field_names_idx); + p->field_names_ref = luaL_ref(L, LUA_REGISTRYINDEX); + lua_pop(L, 1); + + /* Stack now has just the plan userdata on top. */ + return 1; +} + +static int +compile_plan_lua(lua_State *L) +{ + luaL_checktype(L, 1, LUA_TTABLE); + lua_settop(L, 1); + return compile_plan_impl(L, 1); +} + +/* ---------------------------------------------------------------- * + * Introspection (for the smoke test and future debugging). * + * * + * These are intentionally lean — enough to let a Lua test assert * + * the plan-build produced sensible values without a C-side test * + * harness. * + * ---------------------------------------------------------------- */ + +static int +plan_n_fields(lua_State *L) +{ + pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT); + lua_pushinteger(L, p->n_fields); + return 1; +} + +static int +plan_name(lua_State *L) +{ + pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT); + lua_pushstring(L, p->name != NULL ? p->name : ""); + return 1; +} + +static int +plan_field_info(lua_State *L) +{ + pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT); + int i = luaL_checkint(L, 2); + if (i < 1 || i > p->n_fields) + return luaL_error(L, "field index %d out of range [1, %d]", + i, p->n_fields); + pb_plan_field *f = &p->fields[i - 1]; + + lua_createtable(L, 0, 9); + + lua_pushinteger(L, f->field_number); + lua_setfield(L, -2, "field_number"); + + lua_pushinteger(L, f->wire_type); + lua_setfield(L, -2, "wire_type"); + + lua_pushinteger(L, f->kind); + lua_setfield(L, -2, "kind"); + + lua_pushboolean(L, f->repeated); + lua_setfield(L, -2, "repeated"); + + lua_pushboolean(L, f->packed); + lua_setfield(L, -2, "packed"); + + lua_pushboolean(L, f->optional); + lua_setfield(L, -2, "optional"); + + lua_pushinteger(L, f->sub_plan_idx); + lua_setfield(L, -2, "sub_plan_idx"); + + lua_pushinteger(L, f->oneof_idx); + lua_setfield(L, -2, "oneof_idx"); + + lua_pushlstring(L, (const char *)f->tag_bytes, f->tag_len); + lua_setfield(L, -2, "tag_bytes"); + + /* Field name from cached table. */ + lua_rawgeti(L, LUA_REGISTRYINDEX, p->field_names_ref); + lua_rawgeti(L, -1, i); + lua_remove(L, -2); + lua_setfield(L, -2, "name"); + + /* Map kinds, if any. */ + if (f->kind == PB_KIND_MAP) { + lua_pushinteger(L, f->map_key_kind); + lua_setfield(L, -2, "map_key_kind"); + lua_pushinteger(L, f->map_value_kind); + lua_setfield(L, -2, "map_value_kind"); + lua_pushinteger(L, f->map_value_sub_plan_idx); + lua_setfield(L, -2, "map_value_sub_plan_idx"); + } + + return 1; +} + +static int +plan_n_oneofs(lua_State *L) +{ + pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT); + lua_pushinteger(L, p->n_oneofs); + return 1; +} + +static int +plan_oneof_info(lua_State *L) +{ + pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT); + int i = luaL_checkint(L, 2); + if (i < 1 || i > p->n_oneofs) + return luaL_error(L, "oneof index %d out of range [1, %d]", + i, p->n_oneofs); + pb_plan_oneof *o = &p->oneofs[i - 1]; + + lua_createtable(L, 0, 2); + lua_pushstring(L, o->name != NULL ? o->name : ""); + lua_setfield(L, -2, "name"); + + lua_createtable(L, o->n_members, 0); + for (int j = 0; j < o->n_members; j++) { + lua_pushinteger(L, o->member_indices[j]); + lua_rawseti(L, -2, j + 1); + } + lua_setfield(L, -2, "member_indices"); + + return 1; +} + +static int +plan_has_override(lua_State *L) +{ + pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT); + lua_pushboolean(L, p->has_override); + return 1; +} + +static int +plan_sub_plan(lua_State *L) +{ + pb_plan *p = (pb_plan *)luaL_checkudata(L, 1, PB_PLAN_MT); + int i = luaL_checkint(L, 2); + if (p->sub_plans_ref == LUA_NOREF || i < 1) { + lua_pushnil(L); + return 1; + } + lua_rawgeti(L, LUA_REGISTRYINDEX, p->sub_plans_ref); + lua_rawgeti(L, -1, i); + lua_remove(L, -2); + return 1; +} + +/* ---------------------------------------------------------------- * + * Module entry. * + * ---------------------------------------------------------------- */ + +static const struct luaL_Reg c_runtime_methods[] = { + {"compile_plan", compile_plan_lua}, + {"plan_n_fields", plan_n_fields}, + {"plan_name", plan_name}, + {"plan_field_info", plan_field_info}, + {"plan_n_oneofs", plan_n_oneofs}, + {"plan_oneof_info", plan_oneof_info}, + {"plan_has_override", plan_has_override}, + {"plan_sub_plan", plan_sub_plan}, + {NULL, NULL}, +}; + +static const struct luaL_Reg plan_mt_methods[] = { + {"__gc", plan_gc}, + {"__tostring", plan_tostring}, + {NULL, NULL}, +}; + +LUA_API int +luaopen_pb_c_runtime(lua_State *L) +{ + /* Register the plan metatable. */ + luaL_newmetatable(L, PB_PLAN_MT); + luaL_register(L, NULL, plan_mt_methods); + lua_pop(L, 1); + + /* Build the module table. */ + lua_newtable(L); + luaL_register(L, NULL, c_runtime_methods); + + lua_pushliteral(L, PB_ABI_VERSION); + lua_setfield(L, -2, "_abi_version"); + + /* Kind constants — exported so Lua tests can compare without + * duplicating the enum. */ + lua_createtable(L, 0, 19); + lua_pushinteger(L, PB_KIND_NONE); lua_setfield(L, -2, "NONE"); + lua_pushinteger(L, PB_KIND_INT32); lua_setfield(L, -2, "INT32"); + lua_pushinteger(L, PB_KIND_INT64); lua_setfield(L, -2, "INT64"); + lua_pushinteger(L, PB_KIND_UINT32); lua_setfield(L, -2, "UINT32"); + lua_pushinteger(L, PB_KIND_UINT64); lua_setfield(L, -2, "UINT64"); + lua_pushinteger(L, PB_KIND_SINT32); lua_setfield(L, -2, "SINT32"); + lua_pushinteger(L, PB_KIND_SINT64); lua_setfield(L, -2, "SINT64"); + lua_pushinteger(L, PB_KIND_FIXED32); lua_setfield(L, -2, "FIXED32"); + lua_pushinteger(L, PB_KIND_FIXED64); lua_setfield(L, -2, "FIXED64"); + lua_pushinteger(L, PB_KIND_SFIXED32); lua_setfield(L, -2, "SFIXED32"); + lua_pushinteger(L, PB_KIND_SFIXED64); lua_setfield(L, -2, "SFIXED64"); + lua_pushinteger(L, PB_KIND_FLOAT); lua_setfield(L, -2, "FLOAT"); + lua_pushinteger(L, PB_KIND_DOUBLE); lua_setfield(L, -2, "DOUBLE"); + lua_pushinteger(L, PB_KIND_BOOL); lua_setfield(L, -2, "BOOL"); + lua_pushinteger(L, PB_KIND_STRING); lua_setfield(L, -2, "STRING"); + lua_pushinteger(L, PB_KIND_BYTES); lua_setfield(L, -2, "BYTES"); + lua_pushinteger(L, PB_KIND_ENUM); lua_setfield(L, -2, "ENUM"); + lua_pushinteger(L, PB_KIND_MESSAGE); lua_setfield(L, -2, "MESSAGE"); + lua_pushinteger(L, PB_KIND_MAP); lua_setfield(L, -2, "MAP"); + lua_setfield(L, -2, "KIND"); + + lua_createtable(L, 0, 4); + lua_pushinteger(L, PB_WIRE_VARINT); lua_setfield(L, -2, "VARINT"); + lua_pushinteger(L, PB_WIRE_I64); lua_setfield(L, -2, "I64"); + lua_pushinteger(L, PB_WIRE_LEN); lua_setfield(L, -2, "LEN"); + lua_pushinteger(L, PB_WIRE_I32); lua_setfield(L, -2, "I32"); + lua_setfield(L, -2, "WIRE"); + + return 1; +} diff --git a/test/c_runtime_plan_test.lua b/test/c_runtime_plan_test.lua new file mode 100644 index 0000000000000000000000000000000000000000..d8d86bb6e4267f341ae8ab7d98cdb0087927aaba --- /dev/null +++ b/test/c_runtime_plan_test.lua @@ -0,0 +1,210 @@ +-- Smoke test for bd-mq7: descriptor → C plan compiler. +-- +-- Only runs when PB_ENABLE_C=1 is set in the environment AND the C +-- runtime module is loadable. Otherwise the group is skipped, which +-- keeps `just test` green on hosts without the C module built. +-- +-- Acceptance per bd-mq7: +-- (1) pb.c_runtime.compile_plan(desc) returns a userdata +-- (2) plan->n_fields and plan->fields[i].tag are readable +-- (3) Plans for hello.Person and hello.Address build without error + +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 + +-- Run each test against both codegen modes — c_plan is attached to the +-- descriptor regardless of mode, so both pick up the same compile path. +for _, mode in ipairs({'full', 'runtime'}) do + local g = t.group('c_runtime_plan.' .. mode) + local hello + + g.before_all(function() + skip_if_no_c() + hello = require(mode .. '.hello.hello_pb') + end) + + g.before_each(skip_if_no_c) + + function g.test_module_surface() + t.assert_equals(type(c_runtime.compile_plan), 'function') + t.assert_equals(type(c_runtime._abi_version), 'string') + t.assert(c_runtime.KIND ~= nil, 'KIND table exposed') + t.assert(c_runtime.WIRE ~= nil, 'WIRE table exposed') + t.assert_equals(c_runtime.WIRE.LEN, 2) + t.assert_equals(c_runtime.WIRE.VARINT, 0) + end + + function g.test_compile_address_returns_userdata() + local plan = c_runtime.compile_plan(hello.Address_descriptor) + t.assert_equals(type(plan), 'userdata') + t.assert_equals(c_runtime.plan_name(plan), 'hello.Address') + t.assert_equals(c_runtime.plan_n_fields(plan), 4) + end + + function g.test_address_field_shapes() + local plan = c_runtime.compile_plan(hello.Address_descriptor) + -- {name="street", id=1, kind=scalar/string} + local f1 = c_runtime.plan_field_info(plan, 1) + t.assert_equals(f1.name, 'street') + t.assert_equals(f1.field_number, 1) + t.assert_equals(f1.wire_type, c_runtime.WIRE.LEN) + t.assert_equals(f1.kind, c_runtime.KIND.STRING) + t.assert_equals(f1.tag_bytes:byte(1, 1), 0x0A) -- (1<<3)|2 + -- {name="apartment", id=4, kind=scalar/string, optional=true} + local f4 = c_runtime.plan_field_info(plan, 4) + t.assert_equals(f4.name, 'apartment') + t.assert_equals(f4.optional, true) + t.assert_equals(f4.field_number, 4) + end + + function g.test_compile_person_returns_userdata() + local plan = c_runtime.compile_plan(hello.Person_descriptor) + t.assert_equals(type(plan), 'userdata') + t.assert_equals(c_runtime.plan_name(plan), 'hello.Person') + -- Person has 14 fields per examples/proto/hello.proto + t.assert_equals(c_runtime.plan_n_fields(plan), 14) + end + + function g.test_person_scalar_fields() + local plan = c_runtime.compile_plan(hello.Person_descriptor) + -- name = string @1 + local f = c_runtime.plan_field_info(plan, 1) + t.assert_equals(f.name, 'name') + t.assert_equals(f.kind, c_runtime.KIND.STRING) + t.assert_equals(f.wire_type, c_runtime.WIRE.LEN) + -- age = int32 @2 + f = c_runtime.plan_field_info(plan, 2) + t.assert_equals(f.name, 'age') + t.assert_equals(f.kind, c_runtime.KIND.INT32) + t.assert_equals(f.wire_type, c_runtime.WIRE.VARINT) + -- user_id = fixed64 @9 + f = c_runtime.plan_field_info(plan, 9) + t.assert_equals(f.name, 'user_id') + t.assert_equals(f.kind, c_runtime.KIND.FIXED64) + t.assert_equals(f.wire_type, c_runtime.WIRE.I64) + -- balance = sint32 @10 + f = c_runtime.plan_field_info(plan, 10) + t.assert_equals(f.kind, c_runtime.KIND.SINT32) + -- weight_kg = double @11 + f = c_runtime.plan_field_info(plan, 11) + t.assert_equals(f.kind, c_runtime.KIND.DOUBLE) + t.assert_equals(f.wire_type, c_runtime.WIRE.I64) + end + + function g.test_person_repeated_packed() + local plan = c_runtime.compile_plan(hello.Person_descriptor) + -- emails = repeated string @3 (not packed) + local f = c_runtime.plan_field_info(plan, 3) + t.assert_equals(f.name, 'emails') + t.assert_equals(f.repeated, true) + t.assert_equals(f.packed, false) + t.assert_equals(f.wire_type, c_runtime.WIRE.LEN) + -- lucky_numbers = repeated int32, packed @7 + f = c_runtime.plan_field_info(plan, 7) + t.assert_equals(f.name, 'lucky_numbers') + t.assert_equals(f.repeated, true) + t.assert_equals(f.packed, true) + -- packed flips wire type to LEN regardless of element type + t.assert_equals(f.wire_type, c_runtime.WIRE.LEN) + t.assert_equals(f.kind, c_runtime.KIND.INT32) + end + + function g.test_person_enum_field() + local plan = c_runtime.compile_plan(hello.Person_descriptor) + -- status = enum @4 + local f = c_runtime.plan_field_info(plan, 4) + t.assert_equals(f.name, 'status') + t.assert_equals(f.kind, c_runtime.KIND.ENUM) + t.assert_equals(f.wire_type, c_runtime.WIRE.VARINT) + end + + function g.test_person_message_field_resolves_sub_plan() + local plan = c_runtime.compile_plan(hello.Person_descriptor) + -- address = message Address @5 + local f = c_runtime.plan_field_info(plan, 5) + t.assert_equals(f.name, 'address') + t.assert_equals(f.kind, c_runtime.KIND.MESSAGE) + t.assert(f.sub_plan_idx > 0, 'sub_plan_idx populated') + local sub = c_runtime.plan_sub_plan(plan, f.sub_plan_idx) + t.assert_equals(type(sub), 'userdata') + t.assert_equals(c_runtime.plan_name(sub), 'hello.Address') + end + + function g.test_self_reference_cycle() + local plan = c_runtime.compile_plan(hello.Person_descriptor) + -- friends = repeated Person @6 (self-reference) + local f = c_runtime.plan_field_info(plan, 6) + t.assert_equals(f.name, 'friends') + t.assert_equals(f.kind, c_runtime.KIND.MESSAGE) + t.assert_equals(f.repeated, true) + t.assert(f.sub_plan_idx > 0) + local sub = c_runtime.plan_sub_plan(plan, f.sub_plan_idx) + t.assert_equals(c_runtime.plan_name(sub), 'hello.Person') + -- Cycle resolves to the same userdata, not a fresh one + t.assert(sub == plan, + 'self-reference returns the same plan userdata (cycle broken)') + end + + function g.test_person_map_field() + local plan = c_runtime.compile_plan(hello.Person_descriptor) + -- ages_by_nickname: map @13 + local f = c_runtime.plan_field_info(plan, 12) -- 12th field + t.assert_equals(f.name, 'ages_by_nickname') + t.assert_equals(f.kind, c_runtime.KIND.MAP) + t.assert_equals(f.map_key_kind, c_runtime.KIND.STRING) + t.assert_equals(f.map_value_kind, c_runtime.KIND.INT32) + -- addresses_by_label: map @15 + f = c_runtime.plan_field_info(plan, 14) + t.assert_equals(f.name, 'addresses_by_label') + t.assert_equals(f.map_value_kind, c_runtime.KIND.MESSAGE) + t.assert(f.map_value_sub_plan_idx > 0) + local sub = c_runtime.plan_sub_plan(plan, f.map_value_sub_plan_idx) + t.assert_equals(c_runtime.plan_name(sub), 'hello.Address') + end + + function g.test_idempotent_compile() + local p1 = c_runtime.compile_plan(hello.Person_descriptor) + local p2 = c_runtime.compile_plan(hello.Person_descriptor) + t.assert(p1 == p2, 'second compile returns cached plan') + end + + function g.test_result_oneof() + local plan = c_runtime.compile_plan(hello.Result_descriptor) + t.assert_equals(c_runtime.plan_n_fields(plan), 4) + t.assert_equals(c_runtime.plan_n_oneofs(plan), 1) + local o = c_runtime.plan_oneof_info(plan, 1) + t.assert_equals(o.name, 'outcome') + t.assert_equals(#o.member_indices, 3) + -- Members are 0-based indices into plan->fields[]; text/code/details + -- are fields 2, 3, 4 in Result (id-ordered) → indices 1, 2, 3. + local idxs = {} + for _, i in ipairs(o.member_indices) do idxs[i] = true end + t.assert(idxs[1] and idxs[2] and idxs[3], + 'oneof members map to text/code/details indices') + -- Each member field gets oneof_idx = 0 (the only oneof in Result). + for _, fi in ipairs({2, 3, 4}) do + local f = c_runtime.plan_field_info(plan, fi) + t.assert_equals(f.oneof_idx, 0, + ('field %d oneof_idx'):format(fi)) + end + -- id (field 1) is NOT in a oneof. + local f1 = c_runtime.plan_field_info(plan, 1) + t.assert_equals(f1.oneof_idx, -1) + end + + function g.test_wkt_override_detection() + local wkt = pb.wkt + local plan = c_runtime.compile_plan(wkt.Timestamp_descriptor) + t.assert_equals(c_runtime.plan_has_override(plan), true) + -- has_override means the plan does not walk fields; field count is 0. + t.assert_equals(c_runtime.plan_n_fields(plan), 0) + end +end