mirror of
https://github.com/brazilofmux/tinymux
synced 2026-08-13 00:23:11 -04:00
Three consecutive defects escaped this fuzzer for the same reason: the
corpus was string and list functions over literal operands.
#1143 float PHI arms collapsed to a string PHI, copying raw double
bits as a C string
#1157 a bare %N used as an ifelse() condition folded to 0
#1159 FP slot addresses pinned at 0 in nested compiles, so every
float temporary aliased guest address 0
None of the three is reachable with literal operands: a folded float
allocates no FP slot and a folded condition emits no branch. So runtime
values come from v(fz.*) attributes, defined in a per-batch preamble.
v() is an ECALL the compiler cannot see through, whereas compile-time %q
tracking can fold %q back to a constant -- the trap that made
ifelse(%q0,...) look healthy while ifelse(%0,...) was broken.
New default-on shapes, roughly 15% float / 15% branch / 14% nested:
- float arithmetic (fdiv, mul, sqrt, power, fmod, round, ...) with at
least one runtime operand guaranteed
- ifelse()/switch() over a runtime condition with type-MIXED arms,
which is the PHI-typing surface
- u()/ulocal() of a generated attribute body, always consumed by an
outer call -- the wrapper is the point, since a bare u() compiles
through the one-shot compiler and was always correct while a u()
inside a compiled expression routes through the shared heap
Also fixes two minimizer defects that made the new shapes unusable:
- run_chunk and show_value never replayed the corpus setup, so
v(fz.*) was empty and u(me/fz.uN) undefined in every minimized
run. Every nested finding reduced to "no divergence" and was
misfiled as STATE-DEPENDENT. gen.py now writes setup.txt and the
minimizer replays it.
- show_value ran BOTH sides in the toggle-on process, so its INTERP
column was really the JIT value and it printed two identical
numbers under a heading claiming they differ. It now splits across
exp.conf/int.conf exactly as run_chunk does.
Before: 5 bogus reproducers with equal values, 23 STATE-DEPENDENT.
After: 28 distinct minimal reproducers, 0 STATE-DEPENDENT.
Verified against 681b1bc66 (all three defects present): 28 LOGIC, with
minimal reproducers landing on each class -- u(me/fz.uN) for #1159,
ifelse(ab cd,...) for #1157, switch() with float arms returning empty
for #1143. Against current master: 0 LOGIC at SEED=1 and SEED=7, and
--brackets/--utf8/--longreg all still clean.
SEED=42 surfaces #1171, a real pre-existing mul()/power() precision
divergence this corpus found on a clean master. sqrt/power are left in
deliberately rather than trimmed to keep runs green; it is recorded
under Known divergences in the README.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
299 lines
11 KiB
Python
Executable file
299 lines
11 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Shrink JIT-vs-interpreter divergent expressions to minimal reproducers.
|
|
|
|
Reads divergent expressions (one per line) on stdin, greedily replaces
|
|
function-call subtrees with typed leaves while the divergence persists
|
|
(testing candidates by actually running them through muxscript), then prints
|
|
the deduplicated minimal reproducers with their JIT and interpreter values.
|
|
|
|
Isolation discipline (#784): candidates are screened in shared batches for
|
|
speed, but every ACCEPTED reduction is confirmed in a fresh single-expression
|
|
muxscript process, and the original expression is confirmed in isolation
|
|
before shrinking. An expression that diverges only with other expressions'
|
|
JIT state in the process (the #778 class) is reported as STATE-DEPENDENT
|
|
with its original form instead of a misleading "minimal" reproducer whose
|
|
displayed values are equal.
|
|
|
|
Driven by run.sh; needs env JITDIFF_WORK (scratch dir with bin/ + exp.conf)
|
|
and JITDIFF_MUX_BIN (path to muxscript; may contain spaces). Optional
|
|
JITDIFF_TIMEOUT (seconds per muxscript run, default 90). The legacy
|
|
JITDIFF_MUX prefix string is still accepted as a fallback.
|
|
"""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from gen import FUNCS # noqa: E402 (typed arg signatures)
|
|
|
|
WORK = os.environ["JITDIFF_WORK"]
|
|
|
|
# Attribute definitions shared by the corpus (written by gen.py). Every
|
|
# script this module runs must replay them: a minimized expression is run
|
|
# in a FRESH process, so without these v(fz.*) is empty and u(me/fz.uN) is
|
|
# undefined, and any float/nested finding collapses to "no divergence".
|
|
def _load_setup():
|
|
try:
|
|
with open(os.path.join(WORK, "setup.txt")) as f:
|
|
return [ln.rstrip("\n") for ln in f if ln.strip()]
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
SETUP = _load_setup()
|
|
if os.environ.get("JITDIFF_MUX_BIN"):
|
|
MUX = [os.environ["JITDIFF_MUX_BIN"]]
|
|
else:
|
|
# Legacy: a command-prefix string. Splitting breaks on paths with
|
|
# spaces; prefer JITDIFF_MUX_BIN.
|
|
MUX = os.environ["JITDIFF_MUX"].split()
|
|
TIMEOUT = float(os.environ.get("JITDIFF_TIMEOUT", "90"))
|
|
|
|
# Candidates per muxscript process. Mirrors run.sh's batching: large
|
|
# batches overflow the command queue ("Run away") and silently drop
|
|
# results, which reads as "does not diverge".
|
|
BATCH = 50
|
|
|
|
|
|
def split_args(s):
|
|
"""Split a comma-separated arg list at paren depth 0."""
|
|
out, depth, start = [], 0, 0
|
|
for i, c in enumerate(s):
|
|
if c == '(':
|
|
depth += 1
|
|
elif c == ')':
|
|
depth -= 1
|
|
elif c == ',' and depth == 0:
|
|
out.append(s[start:i])
|
|
start = i + 1
|
|
out.append(s[start:])
|
|
return out
|
|
|
|
|
|
def parse_call(s):
|
|
"""Return (name, [args]) for 'name(...)', else (None, None)."""
|
|
s = s.strip()
|
|
p = s.find('(')
|
|
if p <= 0 or not s.endswith(')'):
|
|
return None, None
|
|
name = s[:p]
|
|
if not name.replace('_', '').isalnum():
|
|
return None, None
|
|
return name, split_args(s[p + 1:-1])
|
|
|
|
|
|
def typed_leaf(t):
|
|
return {"I": "1", "i": "1", "W": "x"}.get(t, "ab cd")
|
|
|
|
|
|
def subtrees(expr):
|
|
"""Yield every nested function-call sub-expression (for hoisting)."""
|
|
name, args = parse_call(expr)
|
|
if name is None:
|
|
return
|
|
for a in args:
|
|
if parse_call(a)[0] is not None:
|
|
yield a.strip()
|
|
yield from subtrees(a)
|
|
|
|
|
|
def variants(expr):
|
|
"""Yield simpler forms: (a) hoist each call-valued subtree to the root,
|
|
and (b) replace each call-valued argument with a typed leaf (recursively).
|
|
Hoisting collapses wrappers like repeat(cat(...,rjust(ab,1)),3) to
|
|
rjust(ab,1); leaf-replacement strips a buggy call's complex arguments."""
|
|
# (a) Hoist subtrees — the biggest reductions.
|
|
yield from subtrees(expr)
|
|
|
|
# (b) Replace call-valued arguments with typed leaves.
|
|
name, args = parse_call(expr)
|
|
if name is None:
|
|
return
|
|
sig = FUNCS.get(name.lower())
|
|
argtypes = sig[1] if sig else ["S"] * len(args)
|
|
for i, a in enumerate(args):
|
|
t = argtypes[i] if i < len(argtypes) else "S"
|
|
if parse_call(a)[0] is not None:
|
|
na = args[:]
|
|
na[i] = typed_leaf(t)
|
|
yield f"{name}({','.join(na)})"
|
|
for sub in variants(a):
|
|
na = args[:]
|
|
na[i] = sub
|
|
yield f"{name}({','.join(na)})"
|
|
|
|
|
|
def run_mux(script_path, conf="exp.conf"):
|
|
"""Run one fresh muxscript process on a script file; return stdout."""
|
|
for fn in os.listdir(os.path.join(WORK, "data")):
|
|
if fn.startswith("exp.sqlite"):
|
|
os.remove(os.path.join(WORK, "data", fn))
|
|
with open(script_path) as inp:
|
|
try:
|
|
proc = subprocess.run(MUX + ["-g", WORK, "-c", conf],
|
|
stdin=inp, capture_output=True, text=True,
|
|
timeout=TIMEOUT)
|
|
out = proc.stdout
|
|
except subprocess.TimeoutExpired as e:
|
|
out = e.stdout.decode("utf-8", "replace") if e.stdout else ""
|
|
print("WARNING: muxscript timed out during minimization; "
|
|
"treating missing results as non-diverging.",
|
|
file=sys.stderr)
|
|
if "Run away" in out:
|
|
# Queue overflow: results past the limit were dropped and would
|
|
# silently read as "does not diverge".
|
|
print("WARNING: queue overflow ('Run away') in a minimizer batch — "
|
|
"some candidates were not evaluated.", file=sys.stderr)
|
|
return out
|
|
|
|
|
|
def run_chunk(exprs, base):
|
|
"""Run one chunk through fresh muxscript processes; return
|
|
{global_idx: (j, i, differs)}. base offsets the indices so chunks
|
|
merge cleanly.
|
|
|
|
J and I sides run in SEPARATE processes with separate confs,
|
|
mirroring run.sh: the J side under exp.conf (which may enable
|
|
jit_eval_brackets), the I side under int.conf (toggle always off,
|
|
so the eval-bracket bail keeps the production interpreter route).
|
|
Running both in one toggle-on process compared JIT against JIT and
|
|
misclassified deterministic divergences as STATE-DEPENDENT."""
|
|
jlines = list(SETUP)
|
|
ilines = list(SETUP)
|
|
for i, e in enumerate(exprs):
|
|
gi = base + i
|
|
jlines.append(
|
|
f"@if strlen(setr(0,{e}))="
|
|
f"{{@pemit #1=J~{gi}~[sha1(stripansi(r(0)))]~}},"
|
|
f"{{@pemit #1=J~{gi}~[sha1(stripansi(r(0)))]~}}")
|
|
ilines.append(f"@pemit #1=I~{gi}~[sha1(stripansi({e}))]~")
|
|
jlines.append("@wait 4=@shutdown")
|
|
ilines.append("@wait 4=@shutdown")
|
|
jf = os.path.join(WORK, "min_batch_j.txt")
|
|
iff = os.path.join(WORK, "min_batch_i.txt")
|
|
with open(jf, "w") as f:
|
|
f.write("\n".join(jlines) + "\n")
|
|
with open(iff, "w") as f:
|
|
f.write("\n".join(ilines) + "\n")
|
|
out = run_mux(jf) + run_mux(iff, conf="int.conf")
|
|
res = {}
|
|
for ln in out.splitlines():
|
|
for tag in ("J", "I"):
|
|
pre = tag + "~"
|
|
if pre in ln:
|
|
seg = ln[ln.index(pre):]
|
|
parts = seg.split("~")
|
|
# Validate the index: stray J~/I~ text in evaluated output
|
|
# must not crash the minimizer.
|
|
if len(parts) >= 3 and parts[1].isdigit():
|
|
res.setdefault(parts[1], {})[tag] = parts[2]
|
|
diff = {}
|
|
for idx, d in res.items():
|
|
if "J" in d and "I" in d:
|
|
diff[int(idx)] = (d["J"], d["I"], d["J"] != d["I"])
|
|
return diff
|
|
|
|
|
|
def run_batch(exprs):
|
|
"""Run exprs in chunks of BATCH, each in a fresh muxscript process;
|
|
return {idx: (jit_sha, int_sha, differs)}."""
|
|
diff = {}
|
|
for base in range(0, len(exprs), BATCH):
|
|
diff.update(run_chunk(exprs[base:base + BATCH], base))
|
|
return diff
|
|
|
|
|
|
def diverges(expr):
|
|
"""Standalone check: does expr diverge alone in a fresh process?"""
|
|
d = run_batch([expr])
|
|
return 0 in d and d[0][2]
|
|
|
|
|
|
def shrink(expr):
|
|
"""Greedy reduction. Candidates are screened in shared batches, but a
|
|
reduction is only ACCEPTED after it diverges standalone, so batch-state
|
|
artifacts cannot steer the result (#784)."""
|
|
cur = expr
|
|
improved = True
|
|
while improved:
|
|
improved = False
|
|
cands = list(dict.fromkeys(variants(cur))) # unique, keep order
|
|
if not cands:
|
|
break
|
|
res = run_batch(cands)
|
|
diverging = sorted((len(cands[i]), cands[i])
|
|
for i in res if res[i][2])
|
|
for ln, cand in diverging:
|
|
if ln >= len(cur):
|
|
break
|
|
if diverges(cand):
|
|
cur = cand
|
|
improved = True
|
|
break
|
|
# Batch artifact: diverged with neighbors, not alone — try the
|
|
# next-shortest candidate.
|
|
return cur
|
|
|
|
|
|
def show_value(expr):
|
|
"""Return the stripansi'd JIT and interp values for display.
|
|
|
|
The two sides run in SEPARATE processes with separate confs, for the
|
|
same reason run_chunk does: jit_eval_brackets defaults ON, so an
|
|
eval-bracket evaluated in the J-side process is compiled too, and the
|
|
"INTERP" column would just be the JIT value again -- printing two
|
|
identical values under a heading that claims they differ."""
|
|
jf = os.path.join(WORK, "min_val_j.txt")
|
|
iff = os.path.join(WORK, "min_val_i.txt")
|
|
with open(jf, "w") as f:
|
|
f.write("\n".join(SETUP) + ("\n" if SETUP else ""))
|
|
f.write(
|
|
f"@if strlen(setr(0,{expr}))="
|
|
f"{{@pemit #1=VJ~<[stripansi(r(0))]>}},"
|
|
f"{{@pemit #1=VJ~<[stripansi(r(0))]>}}\n")
|
|
f.write("@wait 3=@shutdown\n")
|
|
with open(iff, "w") as f:
|
|
f.write("\n".join(SETUP) + ("\n" if SETUP else ""))
|
|
f.write(f"@pemit #1=VI~<[stripansi({expr})]>\n")
|
|
f.write("@wait 3=@shutdown\n")
|
|
out = run_mux(jf) + run_mux(iff, conf="int.conf")
|
|
vj = vi = "?"
|
|
for ln in out.splitlines():
|
|
if "VJ~<" in ln:
|
|
vj = ln[ln.index("VJ~<") + 4:].rsplit(">", 1)[0]
|
|
elif "VI~<" in ln:
|
|
vi = ln[ln.index("VI~<") + 4:].rsplit(">", 1)[0]
|
|
return vj, vi
|
|
|
|
|
|
def main():
|
|
seen = {}
|
|
state_dep = []
|
|
for line in sys.stdin:
|
|
e = line.strip()
|
|
if not e:
|
|
continue
|
|
# Confirm the original diverges in ISOLATION before shrinking.
|
|
# If it only diverges alongside the rest of its fuzz batch, the
|
|
# bug is order/state-dependent (the #778 class): shrinking in
|
|
# isolated processes cannot reproduce it and would print a
|
|
# "minimal" form whose JIT and interp values are equal.
|
|
if not diverges(e):
|
|
state_dep.append(e)
|
|
continue
|
|
seen.setdefault(shrink(e), e)
|
|
print(f"{len(seen)} distinct minimal reproducer(s):")
|
|
for m in sorted(seen, key=len):
|
|
vj, vi = show_value(m)
|
|
print(f" {m}")
|
|
print(f" JIT=<{vj}> INTERP=<{vi}>")
|
|
if state_dep:
|
|
print(f"{len(state_dep)} STATE-DEPENDENT expression(s) — diverged "
|
|
"in the fuzz batch but NOT in isolation; reproduce by "
|
|
"replaying the whole batch (b*.txt in the work dir):")
|
|
for e in state_dep:
|
|
print(f" {e}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|