From 8235d76db33dfaaef3d098dadbb8adc581da8946 Mon Sep 17 00:00:00 2001 From: Derek Miller <1340081+derek-miller@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:01:14 -0500 Subject: [PATCH] fix: stop selecting the native path on 32-bit-semantics hosts `_compat` decided it had Lua 5.3+ integer semantics by testing whether `a & b` parses. LuaJIT rolling releases from 2026 accept that syntax while keeping Lua 5.1 semantics, so the probe now succeeds there and selects a branch that assumes 64-bit integers: it sets `is_luajit = false` and skips the `to_unsigned()` normalisation, so signed 32-bit results leak out. Parsing was never the right question. The probe already builds the function, so ask the value instead: a host where `0xFFFFFFFF` is a 64-bit integer and `&` returns 64-bit semantics answers `0xFFFFFFFF`, and one returning 32-bit semantics answers `-1`. That needs no list of which runtimes exist, so it stays correct for whatever grows the syntax next rather than needing another name added to a gate. Hosts built with `LUA_32BITS=1` are out of scope either way: both sides of that comparison wrap to `-1`, and the library's own constants wrap too (`0x100000000` is `0`), so no probe rescues them. Two distinct failure modes, not one. `band`, `bor`, `bxor` and `mask` return the right low bits with sign extension above them, which a caller masking to 32 bits would repair. `arshift` is worse: it derives `is_negative` from `a >= 0x80000000`, never true once the host has already returned a negative, so it gives plain wrong answers that masking does not fix. `arshift(0x80000000, 1)` gave `0x40000000` instead of `0xC0000000`, and `arshift(0x80000000, 31)` gave `1` instead of `0xFFFFFFFF`. bit32 lost 24 of 98 vectors and bit64 2 of 116. bit16 passed 74/74 because masking to `0xFFFF` truncates the sign extension away, which is why the breakage was easy to under-read. Not introduced here. CI last ran green in January 2026 and `leafo/gh-actions-lua` now builds LuaJIT 2.1.1785763465, which crossed the threshold. Confirmed by running an empty commit on unmodified `main`: the same `luajit-2.1` failure with 5.3 and 5.4 passing, and its log shows that build printing `Using: native operators (Lua 5.3+)`. Control4 is not exposed today, but for a version reason rather than a structural one. Controllers run LuaJIT 2.1.1700206165 with `jit` defined, and land on Implementation 2 with `to_unsigned` applied; that build predates the syntax change, so the native branch is unreachable there for now. A LuaJIT bump in a future OS update would put them straight onto the broken path under the old code, which argues for landing this. Note that `jit` is only visible from unencrypted drivers, so probing the runtime from a signed Control4 driver reports no LuaJIT and is not representative. `lua-crypto` is also unaffected, though not because it avoids the wrapped API: `utils/bytes.lua` calls `bit32.mask` in `u32_to_le_bytes` and `u32_to_be_bytes`. Both callers immediately decompose with `% 256` and `floor(n / 256^k) % 256`, which is sign-agnostic over the low 32 bits, so the unsigned value and the signed twin a broken `mask` returns produce identical bytes. Its suite ran green on 2.1.1785763465, the build that breaks this repo. Verified on LuaJIT 2.1.1748459687, which predates the syntax change and so exercises the unchanged path: 3/3 modules, `is_luajit` stays true, and both `arshift` cases above are correct. The build that reproduces the failure exists only in CI. --- src/bitn/_compat.lua | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/bitn/_compat.lua b/src/bitn/_compat.lua index 9b2afe7..17b2ac6 100644 --- a/src/bitn/_compat.lua +++ b/src/bitn/_compat.lua @@ -33,10 +33,18 @@ local MASK32 = 0xFFFFFFFF -- Implementation 1: Native operators (Lua 5.3+) -------------------------------------------------------------------------------- +-- Parsing `a & b` does not mean the result has 64-bit integer semantics. LuaJIT +-- rolling releases from 2026 accept the syntax and return a signed 32-bit number, +-- as from its `bit` library, while this branch assumes 5.3+ integers and skips the +-- to_unsigned() normalisation such a host needs. +-- +-- So test the value, not the runtime: 5.3+ answers 0xFFFFFFFF and anything with +-- 32-bit semantics answers -1. Asking the question this way needs no list of which +-- runtimes exist, so it stays correct for whatever grows the syntax next. local ok, result = pcall(load, "return function(a,b) return a & b end") if ok and result then local fn = result() - if fn then + if fn and fn(0xFFFFFFFF, 0xFFFFFFFF) == 0xFFFFFFFF then -- Native operators available - define all functions using them local native_band = fn local native_bor = assert(load("return function(a,b) return a | b end"))()