Engineering 9 min
The fuzzer that found a hang
CI stalled and got cancelled. It was not flaky infrastructure — it was an unbounded loop that had been in both engines since the kernel was written.
A continuous integration job hung and was cancelled by the runner. The natural read is infrastructure: a slow runner, a network hiccup, one of the thousand things that make CI flaky. Re-run it and move on.
It was not flaky. The fuzz step had found a genuine hang, and the hang had been sitting in both engines since the palette solver was written.
The bug
The palette solver picks N maximally-separated colors. It starts by building a candidate pool through rejection sampling: draw a random color, keep it if it satisfies the constraints, repeat until the pool is full.
while (pool.length < POOL_SIZE) {
const c = randomColor();
if (satisfies(c, constraints)) pool.push(c);
}
This terminates as long as the acceptance rate is meaningfully above zero. For every input anyone had tried, it was.
One constraint is lightness_range — a band the palette must sit within. It had no bounds in the schema. Nothing stopped a caller from asking for a band outside the gamut’s achievable lightness, or one hugging a pole where almost no colors qualify.
Ask for that, and the acceptance rate goes to approximately zero. The loop has no iteration cap. It spins forever, in a CPU-bound loop, on a machine with no way to interrupt it.
The fuzzer found it because a fuzzer does not know that lightness ranges are supposed to be sensible. It read the schema, saw an unbounded number, and asked for one.
Three fixes, because one is not enough
Bound the input. lightness_range now has schema bounds and a refinement requiring low < high. Most bad values are now rejected before any code runs, with a clear validation error.
Bound the loop. Schema bounds do not prove the acceptance rate is workable — a valid narrow band near a gamut edge can still be nearly unsatisfiable. So the sampler now has an attempt cap proportional to the pool size, and when it exhausts that cap it fails loudly: RangeError in TypeScript, MARY_E_INVALID_ARG across the C ABI. It reports that it could not build a pool rather than trying forever.
Fix the error mapping. Making that fail correctly exposed a smaller bug: the FFI guard mapped std::invalid_argument to MARY_E_INTERNAL. An internal error means “the library is broken”; this was a bad argument, and the caller can act on that distinction. The guard now maps it to MARY_E_INVALID_ARG.
The greedy selection loop nearby had a matching flaw — it iterated to the nominal pool size rather than the pool’s actual length, which would read past the end if the pool ever came up short. Now that the pool can legitimately come up short, that was a live bug rather than a latent one.
None of this changed the RNG draw order for normal inputs, so all 401 golden vectors were unaffected. Verified, not assumed.
The lesson that took longer to learn
A per-tool time cap was already in the fuzzer, and it did not help.
The cap checks elapsed time between calls. If a single call never returns, the check never runs. A timeout that can only fire between iterations cannot save you from one iteration that does not end.
The fix that actually works is to bound the inputs that scale the work. A FUZZ_CHEAP=1 mode now clamps wide integer ranges — resolutions, iteration counts, lattice steps — so no single call can take minutes on a shared runner. CI uses it. Local deep runs do not.
Generalized: a watchdog outside the loop cannot bound a loop that never yields. Either the loop bounds itself, or you bound what you feed it. Preferably both.
Finding it at all
The fuzzer only found this because it fuzzes schemas rather than examples. It walks the zod schema for each tool, generates values that satisfy it, and calls the handler — asserting only that the handler either returns a finite, well-formed result or throws a declared error. Undeclared throws and non-finite outputs are findings.
That is a low bar deliberately. It makes no claim about correctness, only about not lying and not falling over. A tool cannot pass by being wrong quietly.
Two diagnostic details earned their keep. FUZZ_TRACE=1 prints each input before the call, which is the only way to identify the input that wedges a process — a post-hoc log never gets written. And when a runaway process needs killing on Windows, Get-Process node | Select Id, CPU, StartTime finds it by CPU burn.
What it has found since
Deepening the generator so it reached every tool — nine were being generated but never executed, because random values satisfied the schemas while missing the domain — turned up a second real bug immediately.
searchRecords returned { total: 21, records: [] } when a caller omitted paging: offset undefined makes offset + limit NaN, and slice(0, NaN) returns nothing. Typed TypeScript callers were safe because both fields are required, but the node and WASM bindings call engine functions from plain JavaScript. It reported hits and returned none of them — a confidently wrong answer, which is the failure mode this project exists to avoid.
Current state: 39 tools, all reached, roughly 15,000 generated inputs across six seeds, and a coverage floor that fails the run if any tool executes zero valid inputs. Because “all green” on a suite that silently skipped a quarter of the surface is exactly the kind of reassurance worth distrusting.