ERRORS.md 4.8 KB

balancer-lite-lua — Lessons Learned

Three recurring footguns caught during the routing/wg/ctl wiring (commit daf240f). All three were silent: code parsed, tests passed, but end-to-end boot was broken. Per-module unit tests alone don't catch any of them — they only surface when you try to actually use the module from another module or boot the daemon.


1. gsub patterns treat \0 as zero-width match (Lua 5.1)

Symptom: JSON output looked like {"kind":"\u0000s\u0000w\u0000i\u0000t\u0000c\u0000h\u0000",...} — the \u0000 text was injected between every character of every string field.

Cause: In Lua 5.1, the pattern character \0 matches a zero-width position between every character (it's the "match anywhere" anchor, like \b in some regex flavors). So s:gsub('\0', 'X') on "abc" returns "XaXbXcX", not "abc".

Same trap applies to: \0 as a character class, and possibly other \d-style shortcuts. The null byte (0x00) is treated as the special "any position" anchor.

Fix: Don't use \0 in patterns. To match a literal null byte, use string.find(s, '\0', i, true) with the plain=true flag (which disables pattern matching), then rebuild the string manually. Example from src/balancerlite/json.lua:

-- BAD: silent zero-width match
v:gsub('\0', '\\u0000')

-- GOOD: explicit find + manual rebuild
local out, i = {}, 1
while true do
  local p = string.find(v, '\0', i, true)
  if not p then out[#out+1] = v:sub(i); break end
  out[#out+1] = v:sub(i, p-1) .. '\\u0000'
  i = p + 1
end
table.concat(out)

Reproduction:

lua5.1 -e "print(('abc'):gsub('\0', 'X'))"
-- Output: XaXbXcX  4   (NOT 'abc')

2. s and get_opt(...) or default silently coerces false → default

Symptom: UCI option option icmp_enabled '0' was loaded back as true (the default), so the smoke test's "all probes disabled" config actually ran all probes.

Cause: Lua's and/or short-circuit semantics: A and B or C evaluates to B when A is truthy, else C. When B itself is false (a valid bool opt value), the expression returns C instead — so an explicit false becomes the default.

Fix: Never use X and Y or Z when Y can be falsy. Use an explicit helper:

-- BAD: silently breaks for false / 0 / ""
icmp_enabled = sh and get_opt(sh, "icmp_enabled", true, "bool") or true
-- When sh is truthy AND get_opt returns false, expression returns the right-side true

-- GOOD: explicit branch
local function opt(s, k, default, conv)
  if not s then return default end
  return get_opt(s, k, default, conv)
end
icmp_enabled = opt(sh, "icmp_enabled", true, "bool")

Rule of thumb: A and B or C is safe only when B is guaranteed truthy (string, table, non-zero number, or true). For all other types use an explicit if.

Reproduction:

lua5.1 -e "local sh = {_options={enabled='0'}}
local get_opt = function() return false end
print(sh and get_opt() or true)"
-- Output: true  (the default, NOT the configured false)

3. Module new() defined as local but accessed via the module table

Symptom: lua5.1 src/balancerlite/main.lua --dry-run crashed with attempt to call field 'new' (a nil value) at the first mod.new(...) call.

Cause: Two of the existing modules had local function new(cfg) instead of function mod.new(cfg). The local function existed but was invisible to the return table — only mod.foo style functions were exposed.

-- src/balancerlite/probes.lua  (BEFORE)
local function new(cfg) ... end
function probes.all(...) ... end
return probes          -- probes.new is nil!

-- AFTER
local function new(cfg) ... end
function probes.all(...) ... end
probes.new = new
probes.all = probes.all  -- self-redundant but makes exports explicit
return probes

Fix: Either (a) define new as function mod.new(cfg) like the other exported functions, or (b) explicitly assign all exports before return mod. Option (a) is shorter; option (b) gives you a single "public API" section at the bottom.

Rule: When defining a module, every function intended to be called from outside must be reachable via the return table. local function foo is invisible to callers unless you re-bind it: mod.foo = foo or function mod.foo(...).


General lesson

End-to-end boot/parse/run is the only test that catches cross-module wiring bugs. Per-module unit tests catch logic bugs inside the module; lint catches syntax; smoke tests catch everything else. Always do an end-to-end run before committing.

The wiring work that exposed all three bugs here was: "run the actual daemon in dry-run mode after adding the new modules." That single command — lua5.1 main.lua --dry-run — found three silent failures in ~10 seconds. Cheaper than any amount of code review.