Repository navigation
fix: sign the 32-bit codecs and cover the wire format against the reference - #27
Conversation
…ementation
FL-16 Part 2. Six protobuf defects have been found in the field or in review and
none by the test suite, and five of the six were wire-format or schema bugs
rather than bit twiddling. Everything built so far tested the scalar codec, the
one layer that produced the fewest of them.
Vendors the upstream conformance message and drives encode and decode against
the reference protobuf implementation as an oracle, replacing hand-written
literals. Both the golden bytes and the expected tables are derived by walking
the same reference message, which matters because three hand-written
expectations in the Part 1 work were themselves wrong.
The two directions are asserted differently on purpose. Reference to Lua is
strict, since the input is byte exact. Lua to reference is semantic and never
bytewise: this encoder never packs, so its output cannot match protoc's for any
repeated scalar even when it is correct.
The goldens are checked in, so make test still runs on a bare clone with no
Python. gen-wire-vectors and check-wire-vectors stay out of make check for the
reason check-types already documents.
Found three defects, each filed rather than fixed here so this stays a coverage
change:
FL-17 the schema generator drops the google.protobuf imports, so any
well-known-type subschema reference dangles. The vendored proto is
trimmed for that reason.
FL-18 sfixed32 is unsigned in both directions and encoding a negative one
raises.
FL-19 a negative int32 or enum decodes to 1.8e19 on 5.1, 5.2 and LuaJIT, and
is correct on 5.3+ only because signed integer arithmetic wraps.
FL-19 is why known_gaps has two categories. A strict entry demands a failure,
which would only move which half of the CI matrix is red, so version-dependent
cases are reported rather than asserted.
Refs FL-16
A negative int32 or enum decoded to 1.8446744073709552e19 on Lua 5.1, 5.2 and LuaJIT, and to the right answer on 5.3 and 5.4 only because the unsigned `value[1] * 0x100000000 + value[2]` in bit64.to_number overflows signed 64-bit integers and wraps. Nothing implemented sign extension; the value was aliased. The fix is not sign extension either. Measured against the reference, the operation is truncation to the field's declared width: the five-byte payload 0xFFFFFFFF is -1 as an int32 despite no sign bit being set at 64 bits, and 0xFFFFFFFF00000001 is 1 rather than a large negative. So 5.3 and 5.4 were not merely right for the wrong reason, they were wrong on those inputs too, as were all six matrix entries. int32 and uint32 are wire compatible by design, so a peer writing uint32 = 4294967295 into a field a driver reads as int32 produces exactly that five-byte form. The same 64-bit payload has to decode to -1 as an int32 and 4294967295 as a uint32, so no definition of bit64.to_number could serve both. The operation is type directed and belongs here rather than in vendor/bitn.lua, which is left untouched and needs no re-vendor. INT32 and ENUM now read the full pair and interpret the low word signed, UINT32 reads it unsigned, and SINT32 zigzags the low word rather than a double that has already rounded: a ten-byte sint32 payload previously reached zigzag_decode32 as 1.8446744073709552e19 and came back 0 on LuaJIT. Six vectors cover the widths the reference's own serializer cannot emit, built through the existing raw_case oracle so the expectations stay derived rather than asserted by construction. Five of them fail on Lua 5.5 before this change, which is what stops the suite accepting wraparound as a pass; the sint32 one fails only on LuaJIT. The three version_dependent entries in test/known_gaps.lua are gone, leaving that tier empty. Fixes FL-19
Adding a suite meant picking one of three reporting idioms and editing run_tests.sh in three places. Three modules had drifted to three ways of signalling failure and two different failure caps, and the newest generated artifact had no CI drift gate at all. Modules are now discovered rather than registered: any test/<name>_test.lua is a module, keyed by filename, and `make test-<name>` follows from the existing test-% rule. Per-module behaviour the runner used to hardcode moves into the file as `@test-name` and `@test-modes` directives, so everything about a suite lives in the suite. Adding one is dropping in a file. test/testlib.lua is the shared harness. Both existing suites lose their private failure lists, comparison helpers and exit paths and get smaller for it; the embedded selftest() gains test/protobuf_test.lua as a wrapper so the runner no longer needs a special case for it. selftest() itself stays in src/, since shipping inside the amalgamated module is what lets it run on a controller against the real LuaJIT. check-wire-vectors now runs in CI as its own step. Keeping it out of `make check` is about staying runnable on a bare clone, which is not a reason to leave it out of CI, where the venv is already provisioned for check-types. Without it the wire goldens were the one generated artifact with no drift gate, and the encode direction was never verified against the reference implementation at all. No src/ change. Verified on 5.1, 5.2, 5.3, 5.4 and LuaJIT 2.1: same modules, same assertion counts, same output. Both known-gap failure directions still bite -- removing a strict entry for a live defect fails the run, and listing a passing case fails it as a stale entry.
|
Verified CI is 8/8 green, and the new step actually ran. A CI step that no-ops is green for the wrong reason, so I read the The recipe is fail safe if the venv is ever absent: the guard is The refactored harness still discriminates. Folding the assertion plumbing into
Both known-gap directions bite, checked by manipulating the list rather than by re-running your check.
Local runs on Lua 5.5 and LuaJIT 2.1 agree: 5/5 modules each, 62 assertions over 31 vectors both, and I updated the PR body. This repo is squash only, so the body is the commit message that lands on On the stack. #28 merges cleanly onto
Not approving: this needs your review, and I cannot approve an agent-authored PR regardless. |
`pull_request: branches: [main, master]` meant a PR stacked on another branch never triggered the workflow. #28 carries the FL-19 sign-extension fix, touches src/, and has zero check runs against this branch: the first time it would have been tested is after its parent merged and GitHub retargeted it, which is after the review it was supposed to inform. Dropping the filter verifies a pull request whatever it targets. `push` stays pinned to main/master, so a branch gets one run per push rather than two.
|
Re-reviewed at the new head The change is right, and it is the fix for the gap I flagged on #28. One thing is worth doing before it merges, though. This PR's own CI cannot test the change#27 targets The only input that discriminates the change is a pull request targeting something other than No workflow run has ever existed for So the fix is unverified until #28 gets one more event. Reopening #28, or any push to its head, both triggers that run and doubles as the end to end proof that this commit does what it says. Doing it before #28 is reviewed is also the point of the change: the alternative is that #28 is first tested after this merges and GitHub retargets it, which is the ordering the commit message is objecting to. I have not touched #28. Say the word and I will reopen it. Checked and clean
Still |
Puts `pull_request: branches: [ main, master ]` back. Trims comments across the PR to what prevents a wrong edit: history, ticket narration and restatements of the adjacent code are removed, and the unused testlib.script_dir helper goes with them. No behaviour change.
Round 3:
|
|
Correcting one paragraph of the above. I wrote that the missing run had no pinned-down cause and floated an unmergeable-PR hypothesis. Both are wrong, and the answer was already on the record in #28 as The cause: the My hypothesis is dead on its own terms too: #28 was What this changes for the revert: Unaffected: the trim is still behaviour neutral, CI is still 8/8, and the |
|
Plan change: #28 folds into this PR, along with the FL-18 fix, a refactor onto bitn's byte helpers, and a re-vendor of lua-bitn v0.6.3 (adds I'm doing this work on this branch. Please don't push to |
… fl-16-wire-format # Conflicts: # test/known_gaps.lua
|
Standing down. No pushes to Confirmed nothing is in flight from this side before saying so: Two things I checked because they bear on work you are about to start, then I am out of the way. The re-vendor has no artifact yetlua-bitn's latest tag and latest release are both I reviewed #21 as a comment rather than an approve, on the auto-merge precedent from lua-bitn#14 rather than anything wrong with the change. If that review is what the merge is waiting on, say so and I will convert it to an approve. Dropping the version tier is genuinely asserted, and I would not have assumed itThis is the part of the plan that could have quietly gone wrong, so I traced it rather than trusting the shape.
It does, and the path is not obvious:
So an FL-19 fix that is only correct on 5.3+ integer builds fails the matrix once the tier is gone, which is the behaviour you want. Worth knowing which job to read when it goes red, because the summary line you would instinctively check is the 5.4 one. Same check for the other tier: the six The
|
…op the known-gap list
Copied verbatim from the release asset. Brings bit32.to_signed, which the signed 32-bit codecs here use.
|
This is now the combined PR: the wire-format coverage, #28 (FL-19), the FL-18 sfixed32 fix, the codecs on bitn's byte helpers, and Please review the whole PR at the new head rather than the delta from |
|
Reviewed the whole PR at Independently verified1. 2. Old-vs-new codec differential, my own corpus. 1,792 enumerated cases per interpreter covering
Every other case byte-identical on both interpreters. That is the same conclusion as your 390,483-case run, reached from a different corpus and a different harness. 3. Mutation testing: the suite discriminates on every fix. Eight mutations, each applied to a scratch copy and reverted with a clean-tree assert.
The sint32 row is the one worth keeping. Reverting that fix is invisible on an integer-model interpreter and only fails where the number model differs, so that vector's coverage rests on the 5.1, 5.2 and LuaJIT matrix entries. Dropping any of them would retire it silently rather than loudly. Measured on 5.5 and LuaJIT 2.1 only; I could not run 5.1 through 5.4 here. 4. 5. Bytewise comparison of Lua's encodings against the reference, all 37 vectors. 21 identical, 16 differ, fully accounted for:
Spot-checked the cases most likely to hide an interop bug behind a message-level comparison, and they are byte-exact against the reference: One narrowing: the PR body gives "this encoder never packs" as the reason the encode arm is semantic. That is the reason for 4 of the 16. CLAUDE.md's wording is the complete one ("normalises packing, field order and map order away"). Only the PR body is narrow, and only for this thread's benefit. 6. Emission order is not merely different from the reference, it is not stable. 7. Fuzz beyond the enumerated corpus. 400 randomised messages over the full field set (nested, corecursive, recursive, maps, packed and unpacked repeated, oneofs, every scalar at its edges), serialised by the reference, decoded by Lua, re-encoded by Lua, re-parsed by the reference, compared as messages. 73 of 400 mismatch on both Lua 5.5 and LuaJIT 2.1, and every single one is the documented subnormal flush-to-zero, categorised mechanically rather than by eye. Zero failures from any other cause. That is consistent with the generator's "floats are exactly representable: rounding belongs to the codec tests, not here", so the corpus excludes subnormals deliberately. Saying it out loud anyway: the one open wire-format defect the repo documents is by construction the one thing the new differential suite cannot see. 8. Provenance of the vendored proto. Diffed against upstream 9. No 10. Generated artifacts are not stale, on a toolchain CI does not use. 11. Local gates. Three things I liked enough to record: Two doc nits, neither blocking(a) "Neither side is hand-written" is not quite true for 11 of the 37 vectors. The claim appears in three places: the PR body, (b) CLAUDE.md's "reports 32 What I did not do
I could not post this as a formal APPROVE: the PR is authored by |
derek-miller
left a comment
There was a problem hiding this comment.
Approving. The bot's review at 9d3ed76 is the substantive one; GitHub rejects it as a formal approval because the PR author is the same app identity, so this is the formal half.
Its two doc nits are taken in the head commit: the three one-line summaries no longer claim every golden is the reference's own serialization (eleven are assembled payloads the reference is asserted to parse), and CLAUDE.md's typecheck note gives the tool's total instead of one code's count. Generated header regenerated; check-wire-vectors still agrees both ways.
Its item 6, pb.encode iterating pairs(fields) so emission order is not stable under LuaJIT, is real and pre-existing; leaving it out of this PR and taking it as a follow-up.
No v0.6.8 from this merge: the release is held for FL-16 Part 1.
FL-16 Part 2, FL-18 and FL-19 as one change, so every wire vector agrees with the reference implementation and there is no list of expected failures to maintain.
Wire-format coverage
test/test_messages_proto3.proto, the upstream conformance message vendored from v29.3 and trimmed of its well-known-type fields (FL-17).test/generated/test_messages_proto3_schema.lua, wired intomake check-schema. Generating it is itself the DRV-104 / DRV-105 regression test: 24 messages, 28 subschema references, all resolving.test/generated/wire_vectors.lua, 37 vectors of reference bytes paired with the table they decode to, produced bytools/gen_wire_vectors. A golden is the reference implementation's own serialization, or an assembled payload the reference is asserted to parse to the declared message; the expected table is derived from that message, never written by hand.test/wire_vectors_test.luaasserts both directions. Reference → Lua is strict. Lua → reference is semantic: this encoder never packs, so its bytes are re-decoded rather than compared bytewise, andmake check-wire-vectorscloses the loop by parsing Lua's bytes with the reference implementation. That target runs in CI as its own step.Test architecture
Modules under
test/*_test.luaare discovered byrun_tests.sh, not registered. Each declares@test-nameand@test-modesin its header, usestest/testlib.luato report, and exits 0 or 1. The embeddedselftest()stays insrc/so it ships and can run on a controller;test/protobuf_test.luawraps it.Codec fixes
int32,enum,uint32andsint32read the full varint pair and use the low word, so a ten-byte-1decodes as-1on every interpreter instead of1.8446744073709552e19on 5.1, 5.2 and LuaJIT.sfixed32decodes signed, andencode_fixed32accepts theint32range instead of raising on a negative value.Both go through
bit32.to_signed, new in lua-bitn v0.6.3, which is re-vendored here from the release asset.bitn
Byte packing, the 64-bit splits and the float/double word assembly use bitn's
u32_to_le_bytes,le_bytes_to_u32,u64_to_le_bytes,le_bytes_to_u64andfrom_numberinstead of inline arithmetic. Output is byte-identical tomain: a 390,483-case old-vs-new differential across every codec found exactly the two intended changes, negativesfixed32encoding instead of raising.On LuaJIT every codec is within a few percent of
mainexceptencode_doubleat about +25%; on Lua 5.4 the fixed-width codecs are 10–48% faster anddecode_doubleabout 55% slower; on Lua 5.1 float encode and decode are 33% and 58% faster. Measured with results consumed, one process per version.Verified
make checkandmake check-wire-vectorsclean; the amalgamated portable build passes its own selftest.