A flaky failure in Lua's sort.lua test turned out to be a compiler bug in pcc, not a Lua bug.
The real issue was not the quicksort algorithm itself. It was a signedness-propagation bug in pcc:
- an expression involving unsigned XOR produced the correct bit pattern
- but the resulting IR value was no longer marked as unsigned
- a later
%therefore used signed remainder semantics - Lua's random pivot selection chose an out-of-range pivot
- the sort implementation eventually compared
nilwith numbers or reported an invalid ordering
The concrete broken expression was inside Lua's choosePivot:
(rnd ^ lo ^ up) % (r4 * 2) + (lo + r4)With the failing input:
- native result:
731 - old
pccresult:475
That one wrong pivot was enough to corrupt quicksort's assumptions.
The fix was to preserve unsignedness for:
^results- unsigned
>>results - integer compound-assignment results
- unsigned prefix
++/--results
Regression coverage was added for all of these paths.
This bug was a good example of why real-program failures are valuable:
- most of the Lua test suite already passed
sort.luaonly failed for some random seeds or input shapes- the data structures were large and the failure appeared far away from the real cause
- the raw values often looked plausible
At first glance it looked like:
- a stack corruption bug
- a bad aggregate copy bug
- a struct layout bug
- a comparator bug
- or a Lua runtime issue
It was none of those. It was a semantic bug in integer expression lowering.
The public integration test sometimes failed with a sort mismatch:
tests/test_lua.py::test_pcc_runtime_matches_native[sort.lua]
The most visible failure form was:
pcc-compiledonelua.cexited non-zero onsort.lua- native
cc-compiledonelua.cpassed
This already gave the first important constraint:
- same source
- same Lua version
- different compiler behavior
So the bug belonged to the compiler or its fake-libc/runtime assumptions.
The first useful move was not reading code. It was removing randomness.
Two successful reductions were:
- seed the Lua random generator
- build array shapes that fail deterministically
Examples that failed under the buggy compiler:
math.randomseed(15)with a large array of random numbers- a reversed array of integers
Eventually a much smaller deterministic Lua-level shape was identified:
- reversed input
- custom comparator
- minimum failing size around
1921
This was already much better than "sometimes sort.lua fails".
Instead of debugging the entire Lua script harness, the next step was to call the real internal sorting code from a small C harness.
The temporary harness did this:
#define main pcc_onelua_main#include "onelua.c"- create a Lua state
- build a table with reversed integers
- call the real internal
auxsort - verify the table is sorted
This harness had two critical properties:
- native
ccpassed pccfailed deterministically
That meant:
- the bug still existed without the full Lua test suite
- we were still exercising the real Lua implementation
Before chasing codegen, several plausible hypotheses were tested and rejected.
Lua relies heavily on:
TValueStackValueCallInfoTableNodelua_State
If layout were wrong, many sorts of stack and table bugs would appear.
A dedicated layout probe compared:
sizeof(...)offsetof(...)
between native cc and pcc.
They matched for the critical structures. So this was not a gross data-layout bug.
Because the failure correlated with random pivoting, luaL_makeseed(L) was a suspect.
A probe checked:
- stack top before
luaL_makeseed - stack top after
luaL_makeseed - visible types in the stack slots
Result:
- stack shape stayed unchanged
So luaL_makeseed was not directly corrupting the stack.
Temporary diagnostics showed the comparator sometimes received:
niland number
That looked like a comparator or stack-copy bug. However, replacing pieces one at a time showed:
sort_compitself was not the root causepartitionitself was not the root cause
Those functions only became wrong after an earlier mistake violated quicksort's invariants.
A very effective technique was to copy the relevant sort helpers into a temporary harness and reintroduce the original behavior piece by piece.
This isolated four functions:
sort_comppartitionchoosePivotauxsort
Key findings:
- copied
sort_compplus copiedpartitioncould work - the failure reappeared when the random-pivot path was restored
- a simplified
auxsortwith no randomization passed - a fixed tiny
rndvalue passed - a large real
rndvalue failed
This narrowed the search from "Lua sort is wrong" to:
- something about random pivot arithmetic
- especially with large unsigned values
Once the failure was clearly tied to random pivot arithmetic, the next step was to remove Lua entirely.
A pure C reproducer implemented the same formula used by Lua's choosePivot:
typedef unsigned int IdxT;
static IdxT choosePivot(IdxT lo, IdxT up, unsigned int rnd) {
IdxT r4 = (up - lo) / 4;
IdxT p = (rnd ^ lo ^ up) % (r4 * 2) + (lo + r4);
return p;
}With:
lo = 1up = 1921rnd = 3426782842u
Results:
- native:
r4=480 p=731 low=481 high=1441 - old
pcc:r4=480 p=475 low=481 high=1441
This was the decisive reduction.
At that point the bug was no longer:
- a Lua bug
- a stack bug
- a runtime bug
- or even a sort bug
It was an unsigned arithmetic bug in the compiler.
The incorrect result 475 was especially informative because it was just outside the legal pivot range.
The legal range was:
- low bound:
lo + r4 = 481 - high bound:
up - r4 = 1441
Native produced 731, which is valid.
pcc produced 475, which is 6 below the lower bound.
That pattern matched signed remainder behavior.
Python arithmetic confirmed it:
- unsigned interpretation gave the expected pivot
- signed 32-bit interpretation gave a remainder of
-6 - adding that to
lo + r4produced475
So the wrong path was:
- bitwise XOR computed the right bits
- the resulting value lost unsignedness metadata
%used signed remainder
pcc lowers both signed and unsigned 32-bit integers to LLVM i32.
That means LLVM type alone is not enough. The compiler must keep track of whether an integer value should be treated as signed or unsigned when later operators run.
The repository already had a metadata mechanism for this:
_tag_unsigned_clear_unsigned_is_unsigned_val
The bug was that some operators returned new integer values without preserving the unsigned tag.
The key broken path was in pcc/codegen/c_codegen.py:
^returnedbuilder.xor(...)- but did not re-tag the result when the C result type was unsigned
That was enough to break a later %.
Once the main bug was understood, nearby expression paths were audited for the same pattern: "right bits, lost unsignedness".
That audit found another real bug:
- prefix
++x/--xon unsigned integers
The stored variable value was fine, but the expression result itself was not re-tagged as unsigned. This caused failures in expressions such as:
(++x % 960)
(--x % 960)This bug was separate from Lua but belonged to the same semantic family.
The final fix in pcc/codegen/c_codegen.py does four things:
- preserve unsignedness on
^ - preserve unsignedness on unsigned
>> - preserve unsignedness on integer compound-assignment results
- preserve unsignedness on unsigned prefix
++/--
The important point is not the specific operators. The important point is:
- any expression node that manufactures a new integer IR value must decide whether that result is signed or unsigned in C terms
Regression tests were added to tests/test_unsigned_loads.py for:
- unsigned XOR result followed by modulo
- unsigned XOR compound-assignment result followed by modulo
- unsigned prefix increment result followed by modulo
- unsigned prefix decrement result followed by modulo
- unsigned right-shift result followed by modulo
- unsigned ternary result followed by modulo
These tests intentionally use a signed constant modulus in several cases. That is important because it catches the exact class of bug where the expression result silently stops being unsigned.
Before this fix, the repository already had many tests for:
- unsigned loads
- unsigned comparisons
- pointer arithmetic
- compound operators
What was missing was a specific kind of downstream-sensitive test:
- create an unsigned-producing expression
- immediately feed it into an operator where signedness matters
- make the other operand a plain signed constant when possible
Without that shape, a lot of signedness bugs stay invisible because the bit pattern alone still looks correct.
These lessons are useful for humans and AI agents alike.
A failure in Lua sorting looked like a container or runtime bug. It was really a small integer-semantics bug.
The winning path was:
- flaky
sort.lua - fixed random seed
- smaller failing Lua input
- C harness calling internal sort code
- pure C
choosePivotreproducer
That sequence made the root cause obvious.
The struct-layout probe was valuable because it removed a large class of hypotheses early. Once layout matched, attention could move to expression semantics.
If one integer expression form loses signedness metadata, adjacent forms are suspicious:
- bitwise operators
- shifts
- prefix operators
- compound assignments
- ternary and assignment expressions
Never stop at the first green test if the failure mode is a metadata-propagation bug.
The strongest tests do not merely assert a final constant. They force the compiler to carry the correct semantic meaning from one operator into the next.
If another real-world program fails in this repository, use this sequence:
- Reproduce with native and
pccfrom the same source. - Remove randomness and environmental noise.
- Create the smallest integration-level failing harness.
- Split layout/ABI hypotheses from expression-semantics hypotheses.
- Reduce to the smallest pure-C reproducer possible.
- Identify whether the failure is:
- wrong bits immediately
- or right bits with wrong later semantics
- Inspect
c_codegen.pyfor metadata propagation, not just arithmetic instructions. - Add one micro test and re-run the original integration case.
After the fix:
- the pure
choosePivotreproducer returned the correct pivot - the pure C sort reproducer passed
- the Lua internal sort harness passed
tests/test_lua.py::test_pcc_runtime_matches_native[sort.lua]passed- the full suite passed
This was a textbook example of why a good compiler bug investigation should end with:
- a minimal reproducer
- a precise semantic explanation
- and regression tests that lock down the bug class, not just the one original symptom