Skip to content

Instantly share code, notes, and snippets.

@mizchi
Last active August 13, 2026 17:29
Show Gist options
  • Select an option

  • Save mizchi/ce2c0a7d8cf49725ea065258f3350c9e to your computer and use it in GitHub Desktop.

Select an option

Save mizchi/ce2c0a7d8cf49725ea065258f3350c9e to your computer and use it in GitHub Desktop.
oxc_minifier fuzzing report: four fuzzing modes, one fix, and open findings with reproductions

oxc_minifier — fuzzing report

Follow-up to #25594 (the ufuzz-style semantic fuzzer). This extends tasks/minifier_fuzz with four more ways of asking whether the minifier preserves meaning, and reports what they found.

Nothing here is a PR yet. Reproductions are included so each finding can be filed or dismissed independently.

Code: mizchi/oxc@feat/minifier-fuzz-expand (tasks/minifier_fuzz)

The branch stacks on the three PRs already open — #25594 (the fuzzer), #25595 and #25596 (the two fixes it found) — so only the top nine commits are new. Reviewing those in order tracks this report: the ** fix first, then roughly one commit per fuzzing mode. The ** fix will be split into its own PR rather than landing with the tooling.

What was added

Mode Oracle Modelled on
default run the program and the compressed program, compare observable behavior Terser ufuzz.js
--contexts one binding pattern bound in all 13 contexts that accept one; all must agree esbuild destructuring-fuzzer.js
--scopes three names reused down deep nested scopes; every read logs which binding it resolved to
--corpus Terser's test/compress cases run through oxc, each compared with its own input esbuild terser-tests.js
--invariants no Node.js: output parses and binds, a second pass never grows it, output is a fixed point

--mangle applies to the first four. Mismatches are reduced by delta debugging before being saved (--no-shrink disables it), so reproductions no longer need to be minimised by hand.

The generator also gained scope tracking — generated code now reads the bindings it declares, and an inner declaration shadows an outer one — plus let/const, destructuring, labels, try/finally, for-in/for-of, generators, classes with a private field and an accessor pair, named function expressions that reference themselves, arrows, optional chaining, **, template literals, typeof/delete/in/instanceof, default and rest parameters, arguments, and closures captured in one place and called in another.

--scopes exists because the program generator is close to the easiest possible input for a mangler: with every binding uniquely named, no reference can resolve to the wrong one however the renaming goes, so --mangle had nothing to catch. It draws from a pool of three names instead, reuses them down a deep chain of nested scopes, and logs every read as the name it was written as paired with the identity of what it resolved to. It also covers the places where a name is not an ordinary reference and so must not be renamed with one — shorthand properties, property keys, labels, private class fields, catch parameters, named function expressions, and the scope a direct eval can see — plus scopes of 80 bindings, which push the name allocator past the 54 single-character identifiers into two-character ones.

Results

Measured on the branch, so with the ** patch below applied. Run against main the first campaign stops earlier, on that instead.

  • generated programs — two mismatches, at seeds 2963 and 12397, both below. Both reproduce with and without --mangle.
  • --contexts — seeds 0..49,999 with and without --mangle: all equivalent, 0 skipped.
  • --scopes — seeds 0..29,999 with and without --mangle: all equivalent, 0 skipped, and 200,000 seeds satisfy the invariants. Nothing found.
  • --corpus — 1274 runnable cases: 1101 equivalent, 155 do not complete under the sandbox, 0 rejected by the parser, 18 behavioral differences.
  • --invariants — seeds 0..199,999 in release: no violation. 245 seeds are not a fixed point, together 1321 bytes a second pass would still remove (worst single seed: 425 bytes). In a debug build, roughly 7 seeds in 20,000 trip debug_assert_no_under_prune.

Findings

None of these are fixed upstream. The first has a patch on the branch above, waiting to be split into its own PR; the rest have reproductions only.

1. ** constant folding uses IEEE pow instead of Number::exponentiate

f64::powf implements IEEE 754 pow, which returns 1 for a base of 1 whatever the exponent. The abstract operation returns NaN when the exponent is NaN, and when the base has magnitude 1 and the exponent is infinite.

1 ** Infinity      // spec: NaN    oxc folded to: 1
(-1) ** Infinity   // spec: NaN    oxc folded to: 1
1 ** NaN           // spec: NaN    oxc folded to: 1
2 ** Infinity      // Infinity     (correct)

1 is truthy and NaN is not, so an if guarded by such an expression takes the wrong branch. The patch is one branch in crates/oxc_ecmascript/src/constant_evaluation/mod.rs; just minsize shows no snapshot change.

2. Reading a binding in its temporal dead zone is treated as pure

switch (1) {
  case 0:
    let v = 1;
  default:
    try { [0, v][0]; console.log("no throw"); } catch (e) { console.log("TDZ"); }
}

Node prints TDZ. case 0 is never entered, so let v never runs, and reading v must throw a ReferenceError. oxc drops the read as an unused array element and prints no throw.

Constant propagation reaches the same root cause from the other side:

switch (1) {
  case 0:
    const v = 7;
  default:
    try { console.log(v); } catch (e) { console.log("TDZ", e.constructor.name); }
}
// oxc: console.log(7)

Collapsing the constant switch into its default clause is correct in both. What is not correct is treating a reference to a let/const binding as side-effect free: until the declaration has run, evaluating it throws.

3. Dropping a unary + changes when its operand is coerced

var xs = [];
console.log((+xs) - (xs.push(1), 0));  // 0

// oxc emits:
console.log(xs - (xs.push(1), 0));     // 1

(+x) - y and x - y are interchangeable only when nothing observes when x is coerced. ApplyStringOrNumericBinaryOperator evaluates both operands first and calls ToNumeric afterwards, so in x - y the coercion of x happens after y has run — and y here mutates x. The explicit + pins the coercion to before y.

Found by the generated-program mode at seed 12397, once shadowing was added to the generator.

4. debug_assert_no_under_prune fires on generated programs

crates/oxc_minifier/src/compression_pass.rs:

incremental scoping under-prune: reference {idx} is still in a symbol's
resolved-references list but its node is gone from the program — a drop site
bypassed the `drop_*` / `replace_*` helpers, or the caller passed a `scoping`
inconsistent with `program`

About 7 of every 20,000 generated programs. It is behind debug_assertions, so release campaigns never see it — --invariants catches panics and keeps going rather than aborting the sweep. The smallest automatic reduction so far is 38 lines; happy to attach it.

5. From Terser's compress suite

Of the 18 differences, these look like genuine defects:

(0, o?.f)(x) loses the sequence wrapper, and with it the stripped this binding (issue-t1372.js: issue_t1372_maintain_this_binding, issue-t1371.js: issue_t1371_call_parentheses). The non-optional form (0, o.f)(x) is preserved correctly, so only the optional-chaining path is affected.

(function (o) {
  console.log((0, o.f)("PASS"), (0, o?.f)("PASS"));
})({ a: "FAIL", f(b) { return this.a || b; } });
// oxc emits: console.log((0,o.f)("PASS"), (o?.f)("PASS"))  -> second is "FAIL"

A class name is dropped although .name observes it (hoist_props.js: hoist_class). { p: class Foo {} } becomes { p: class {} }, so o.p.name changes from "Foo" to "p" by named evaluation.

delete (0/0) becomes delete NaN (evaluate.js: delete_expr_*, sequences.js: delete_seq_*, conditionals.js: delete_conditional_*). Folding the operand turns a value expression into a reference, so the result changes from true to false.

var arguments plus inlining (collapse_vars.js: collapse_vars_arguments) — an inner function's intrinsic arguments is replaced by an outer var arguments after inlining.

Two more were not triaged and may well be intended: pure_globals.js: window_access_is_impure (a bare undeclared global read is removed, so a try/catch around it never fires) and the two with-scope cases in issue-1105.js.

Where nothing turned up

The mangler came out clean. --scopes covers deep shadowing chains over a three-name pool, direct eval (which oxc correctly stops from renaming anything the eval can see), labels sharing a name with a binding, private class fields, shorthand properties, catch parameters, named function expressions, and scopes wide enough to force two-character generated names. 30,000 seeds with --mangle and 200,000 invariant seeds found nothing.

That is a real negative result rather than an untested area: mangling was verified to actually happen on every seed, and the allocator was verified to emit 53 single-character names followed by two-character ones on a wide scope.

--contexts is likewise clean over 50,000 seeds, so binding patterns are bound identically in all thirteen contexts that accept one.

One trap worth recording

oxc_minifier deliberately treats the ToPrimitive that ==, != and the relational operators perform on an object operand as side-effect free. The cases are pinned in crates/oxc_minifier/tests/ecmascript/may_have_side_effects.rs with the comment "These actually have a side effect, but this treated as side-effect free". + is handled correctly, so the asymmetry is deliberate.

This is easy to mistake for a bug when building a differential fuzzer. The generator's valueOf/toString probes are therefore deliberately silent — a loud one reports the accepted trade-off on nearly every seed and drowns out everything else.

Not covered

async/await and promises. vm.runInNewContext returns before microtasks settle, so the oracle would need a drain step first.

None of these modes are wired into CI. Campaigns are long-running and a mismatch needs a human to look at it, so they are manual tools.


Tooling written with AI assistance (Claude Code); reviewed, and every finding above verified by hand against Node.js.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment