mirror of
https://github.com/radareorg/r2sleigh.git
synced 2026-11-07 06:36:00 -10:00
DecBench run: stop on a lost evaluation, refuse parallel workers for a big binary
The 2026-10-17 run lost every bash evaluation to the OOM killer, evaluate_project
returned {} and the scoreboard dropped the rows without a word. run_gate.py
checks, after the driver, that each binary a decompiler rendered in this run has
a score key of that decompiler in evaluated/<binary>.toml; the 16-byte
'binary = "bash"' file, a missing file and a file that scores only other
decompilers exit 71. On the 2026-10-17 tree it reports 55 of 192 cells, all bash.
More than one worker is refused (exit 72) for a binary whose decompiled C is over
8 MB: before the run for C already in the tree, by evaluate_checkpointed.py, and
inside the driver before evaluate_project, as SystemExit because the driver
catches Exception there and goes on with {}. run_decbench.sh also expands its
empty arrays in a way bash 3.2 accepts.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01456qmqCGpCWJkrg6JUZNNM
This commit is contained in:
1 parent
f71bfcd366
commit
499180ed21
6 files changed
+462
-12
No files matched your search
@@ -107,14 +107,27 @@ Evaluating r2s's `bash` (a 13 MB C file for 1,540 functions) takes 5.2 GB in one
|
||||
`evaluate_project` worker. Four workers on the 8 GB benchmark host are killed by
|
||||
the OOM killer, `evaluate_project` then returns `{}` for the binary, and the
|
||||
scoreboard silently drops it: the 2026-10-17 run scored no bash row for any
|
||||
decompiler (angr kept 692 rows of one build). Run `bash` with `--workers 1`, check
|
||||
`evaluated/<binary>.toml` is not 16 bytes, and read the scoreboard only over rows
|
||||
every compared decompiler has (`compare_runs.py --exclude-project bash`): the
|
||||
scoreboard's denominator is every row *anyone* scored, so a row only one decompiler
|
||||
has counts as a miss for the others. A re-run of one decompiler is
|
||||
decompiler (angr kept 692 rows of one build). Two gates in `run_gate.py` now stop
|
||||
that, and `run_decbench.sh` runs both:
|
||||
|
||||
* `--workers` above 1 is refused for a binary whose decompiled C exceeds 8 MB
|
||||
(exit 72): before the run, for C already in `--out`; inside the driver, before
|
||||
`evaluate_project` (`decbench_cli.py` installs the guard, and it raises
|
||||
`SystemExit` because the driver catches `Exception` there and carries on with
|
||||
`{}`); and by `evaluate_checkpointed.py`. Run `bash` with `--workers 1`.
|
||||
* After the driver, every binary it decompiled in this run needs an
|
||||
`evaluated/<binary>.toml` that carries a score key of each decompiler that
|
||||
rendered functions of it; a missing file, the 16-byte `binary = "bash"` and a
|
||||
file that scores only other decompilers stop the run (exit 71) and name the
|
||||
cells. Checked on the 2026-10-17 tree it reports 55 of 192 cells, all bash.
|
||||
|
||||
Read the scoreboard only over rows every compared decompiler has
|
||||
(`compare_runs.py --exclude-project bash` for a tree that has none): its
|
||||
denominator is every row *anyone* scored, so a row only one decompiler has counts
|
||||
as a miss for the others. A re-run of one decompiler is
|
||||
`DECBENCH_REDO_DECOMPILERS=r2sleigh_native` on a copy of the tree; DecBench's
|
||||
`finalize_results.py --allow-drops` is needed when functions that used to be declined
|
||||
(rows named by their address) are now rendered.
|
||||
`finalize_results.py --allow-drops` is needed when functions that used to be
|
||||
declined (rows named by their address) are now rendered.
|
||||
|
||||
Analysis tools
|
||||
--------------
|
||||
|
||||
@@ -29,6 +29,7 @@ from decbench.metrics.base import Metric
|
||||
from decbench.metrics.registry import MetricRegistry, register_metric
|
||||
from decbench.models.metrics import AggregationType, MetricValue
|
||||
from phase_timing import install_phase_timing
|
||||
from run_gate import install_workers_guard
|
||||
|
||||
|
||||
def register_vj_ged_if_needed() -> None:
|
||||
@@ -99,6 +100,7 @@ def run_benchmark(arguments: list[str]) -> None:
|
||||
raise SystemExit(f"no {script}: DecBench must be installed from a checkout (pip -e)")
|
||||
sys.argv = [str(script), *arguments]
|
||||
sys.path.insert(0, str(script.parent))
|
||||
install_workers_guard() # before the driver imports `evaluate_project`
|
||||
runpy.run_path(str(script), run_name="__main__")
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ re-scored without decompiling it again. This takes the stored `DecompilationResu
|
||||
`project_source_functions`, `extract_source_cfgs` and DecBench's `evaluate_project` exactly as `main` does, writes the
|
||||
scores into the checkpoint, and leaves `finalize_results.py` to rebuild `scoreboard.toml`. Nothing is decompiled, and
|
||||
the per-binary `evaluated/*.toml` files are not touched (the output directory of `evaluate_project` is a scratch one).
|
||||
`--workers` above 1 is refused when a binary's stored C is over 8 MB (`run_gate.py`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,6 +22,8 @@ import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from run_gate import refuse_unsafe_workers
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
@@ -45,6 +48,9 @@ def main() -> int:
|
||||
decs = args.decompilers.split(",")
|
||||
ckpt = args.tree / "checkpoints" / f"{args.project}.pkl"
|
||||
ck = pickle.loads(ckpt.read_bytes())
|
||||
for opt_decompiled in ck["decompile"].values():
|
||||
refuse_unsafe_workers({stem: {d: per[d] for d in decs if d in per}
|
||||
for stem, per in opt_decompiled.items()}, args.workers)
|
||||
project = Project.from_toml(next(t for t in gather_project_tomls() if t.stem == args.project))
|
||||
rb.discover(project, args.tree)
|
||||
scratch = Path(tempfile.mkdtemp(prefix="evaluate_checkpointed_"))
|
||||
|
||||
@@ -16,8 +16,14 @@
|
||||
# 4. optionally compile the projects (DecBench's compile_all.py);
|
||||
# 5. run scripts/run_benchmark.py through decbench_cli.py, which registers
|
||||
# the vj_ged metric when DecBench does not;
|
||||
# 6. check every r2sleigh_native result names that sha256, then report
|
||||
# against baseline.json (report_decbench.py).
|
||||
# 6. check every binary the run decompiled has its scores in evaluated/
|
||||
# (run_gate.py: a run that loses rows stops here with status 71), check
|
||||
# every r2sleigh_native result names that sha256, then report against
|
||||
# baseline.json (report_decbench.py).
|
||||
#
|
||||
# More than one worker is refused for a binary whose decompiled C exceeds 8 MB
|
||||
# (r2s's bash: 13 MB, 5.2 GB per evaluation worker; run_gate.py): up front for
|
||||
# C already in --out, and inside the driver before it evaluates.
|
||||
#
|
||||
# The driver needs cgroup v2 and a user systemd manager (`systemd-run --user`)
|
||||
# for its per-binary limits; it says so and stops when they are missing.
|
||||
@@ -51,7 +57,7 @@ usage: tests/decbench/run_decbench.sh [options]
|
||||
--opt-level OPT O0, O2 or O2_noinline; repeatable (default: O0 O2)
|
||||
--decompilers LIST (default: r2sleigh_native,angr)
|
||||
--metrics LIST (default: byte_match,ged,vj_ged,type_match)
|
||||
--workers N DecBench workers (default: 4)
|
||||
--workers N DecBench workers (default: 4); 1 for bash
|
||||
--r2s PATH measure this r2s instead of building target/release/r2s
|
||||
--compile compile the projects first (DecBench's compile_all.py)
|
||||
--accept-baseline start or extend baseline.json with this run
|
||||
@@ -78,6 +84,8 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
(( ${#opts[@]} )) || opts=(O0 O2)
|
||||
[[ $workers =~ ^[1-9][0-9]*$ ]] || { echo "--workers wants a positive integer, not '$workers'" >&2; exit 64; }
|
||||
started=$(date +%s)
|
||||
out=${out:-$root/tests/decbench/artifacts/run-$(date -u +%Y%m%dT%H%M%SZ)}
|
||||
opt_list=$(IFS=,; echo "${opts[*]}")
|
||||
|
||||
@@ -88,6 +96,11 @@ run() {
|
||||
(( plan )) || "$@"
|
||||
}
|
||||
|
||||
if (( workers > 1 )); then
|
||||
python3 "$root/tests/decbench/run_gate.py" workers --tree "$out" --workers "$workers" \
|
||||
--decompilers "$decompilers" || exit $?
|
||||
fi
|
||||
|
||||
if [[ -z $r2s ]]; then
|
||||
run cargo build --release -p r2s --features sleigh --manifest-path "$root/Cargo.toml"
|
||||
r2s=${CARGO_TARGET_DIR:-$root/target}/release/r2s
|
||||
@@ -113,7 +126,7 @@ fi
|
||||
export R2SLEIGH_R2S_BIN=$r2s
|
||||
export R2SLEIGH_REFUSAL_CENSUS_DIR=$out/refusals
|
||||
if (( compile )); then
|
||||
run env -C "$decbench" "$python" scripts/compile_all.py "$out" "$workers" "${projects[@]}"
|
||||
run env -C "$decbench" "$python" scripts/compile_all.py "$out" "$workers" ${projects[@]+"${projects[@]}"}
|
||||
fi
|
||||
declare -a only=()
|
||||
(( ${#projects[@]} )) && only=(-- "${projects[@]}")
|
||||
@@ -123,8 +136,10 @@ run env -C "$decbench" \
|
||||
DECBENCH_METRICS="$metrics" \
|
||||
DECBENCH_WORKERS="$workers" \
|
||||
R2SLEIGH_DECBENCH_PHASE_LOG="$out/phases.tsv" \
|
||||
"$python" "$root/tests/decbench/decbench_cli.py" run-benchmark "$out" "${only[@]}"
|
||||
"$python" "$root/tests/decbench/decbench_cli.py" run-benchmark "$out" ${only[@]+"${only[@]}"}
|
||||
|
||||
run python3 "$root/tests/decbench/run_gate.py" evaluated --tree "$out" \
|
||||
--decompilers "$decompilers" --since "$started"
|
||||
(( plan )) && exit 0
|
||||
results=$out/function_results.json
|
||||
python3 - "$results" "$witness" <<'PY'
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail a DecBench run that lost rows, and refuse an evaluation that the host will kill.
|
||||
|
||||
run_gate.py evaluated --tree <results tree> --decompilers a,b [--since EPOCH]
|
||||
run_gate.py workers --tree <results tree> --workers N --decompilers a,b
|
||||
|
||||
On 2026-10-17 the host's OOM killer took the evaluation worker of every bash binary, `evaluate_project` returned `{}`,
|
||||
and the driver went on: kuna, ghidra and r2s had no bash row, and the scoreboard dropped them without a word.
|
||||
|
||||
* `evaluated`: every binary a decompiler rendered functions for in this run (its `decompiled/<dec>_<binary>.toml` is
|
||||
newer than `--since` and says `function_count > 0`) needs an `evaluated/<binary>.toml` that carries a score key of that
|
||||
decompiler. A missing file, a file of just `binary = "bash"` (16 bytes) and a file that scores only other
|
||||
decompilers all stop the run with exit status 71.
|
||||
* `workers`: one evaluation worker needs 5.2 GB for the 13 MB of C that r2s writes for bash, so more than one worker on
|
||||
a binary whose decompiled C exceeds 8 MB is refused (exit status 72). The same check runs inside the driver through
|
||||
`install_workers_guard`, before `evaluate_project`, where the C is known; it raises `SystemExit` because the driver
|
||||
catches `Exception` around the evaluation and carries on with `{}`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import functools
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
LOST = 71
|
||||
REFUSED = 72
|
||||
MAX_PARALLEL_C_BYTES = 8 * 1024 * 1024
|
||||
# Read without a TOML parser, so the shell can run this under any python3: DecBench writes
|
||||
# `function_count = N` and quotes the dotted score keys, `"<decompiler>.<metric>.total" = 0.5`.
|
||||
FUNCTION_COUNT = re.compile(r"^function_count\s*=\s*(\d+)", re.MULTILINE)
|
||||
SCORE_KEY = re.compile(r'^"?([^".=\s]+)\.[^"=]*"?\s*=', re.MULTILINE)
|
||||
|
||||
|
||||
def split_name(stem: str, decompilers: list[str]) -> tuple[str, str] | None:
|
||||
"""`r2sleigh_native_bash` -> (`r2sleigh_native`, `bash`), the longest decompiler name that fits."""
|
||||
for decompiler in sorted(decompilers, key=len, reverse=True):
|
||||
if stem.startswith(decompiler + "_") and len(stem) > len(decompiler) + 1:
|
||||
return decompiler, stem[len(decompiler) + 1:]
|
||||
return None
|
||||
|
||||
|
||||
def rendered_binaries(tree: Path, decompilers: list[str], since: float | None = None) -> list[dict]:
|
||||
"""The (opt, project, binary, decompiler) cells the driver decompiled into this tree, with function counts."""
|
||||
cells = []
|
||||
for toml in sorted(tree.glob("*/*/decompiled/*.toml")):
|
||||
named = split_name(toml.stem, decompilers)
|
||||
if named is None or (since is not None and int(toml.stat().st_mtime) < since):
|
||||
continue
|
||||
try:
|
||||
found = FUNCTION_COUNT.search(toml.read_text())
|
||||
except (OSError, ValueError):
|
||||
found = None
|
||||
count = int(found.group(1)) if found else 0
|
||||
if count > 0:
|
||||
opt, project = toml.parts[-4], toml.parts[-3]
|
||||
cells.append({"opt": opt, "project": project, "binary": named[1], "decompiler": named[0],
|
||||
"functions": count, "dir": toml.parent.parent, "toml": toml})
|
||||
return cells
|
||||
|
||||
|
||||
def lost_evaluations(tree: Path, decompilers: list[str], since: float | None = None) -> tuple[int, list[str]]:
|
||||
"""How many cells were checked, and one message per cell whose scores are not in the evaluated file."""
|
||||
cells = rendered_binaries(tree, decompilers, since)
|
||||
lost = []
|
||||
for cell in cells:
|
||||
where = f"{cell['opt']}/{cell['project']}/evaluated/{cell['binary']}.toml"
|
||||
evaluated = cell["dir"] / "evaluated" / f"{cell['binary']}.toml"
|
||||
who = f"{cell['decompiler']} rendered {cell['functions']} functions of {cell['binary']}"
|
||||
if not evaluated.is_file():
|
||||
reason = f"{where} is missing"
|
||||
else:
|
||||
try:
|
||||
scored = sorted(set(SCORE_KEY.findall(evaluated.read_text())))
|
||||
except (OSError, ValueError):
|
||||
scored = None
|
||||
size = evaluated.stat().st_size
|
||||
if scored is None:
|
||||
reason = f"{where} cannot be read ({size} bytes)"
|
||||
elif cell["decompiler"] not in scored:
|
||||
reason = (f"{where} is {size} bytes and scores "
|
||||
f"{', '.join(scored) if scored else 'no decompiler'}, not {cell['decompiler']}")
|
||||
else:
|
||||
continue
|
||||
c_file = cell["toml"].with_suffix(".c")
|
||||
if c_file.is_file() and c_file.stat().st_size > MAX_PARALLEL_C_BYTES:
|
||||
reason += (f"; its decompiled C is {c_file.stat().st_size / 2**20:.1f} MB, so the evaluation worker was "
|
||||
"most likely OOM-killed (evaluate with --workers 1)")
|
||||
lost.append(f"{who}, but {reason}")
|
||||
return len(cells), lost
|
||||
|
||||
|
||||
def c_bytes_of(decompilations: dict) -> dict[tuple[str, str], int]:
|
||||
"""`{binary: {decompiler: DecompilationResult}}` -> the bytes of C each pair holds."""
|
||||
sizes = {}
|
||||
for binary, per_decompiler in decompilations.items():
|
||||
for decompiler, result in per_decompiler.items():
|
||||
functions = getattr(result, "functions", None) or {}
|
||||
sizes[(str(binary), str(decompiler))] = sum(
|
||||
len((getattr(function, "decompiled_code", "") or "").encode()) for function in functions.values())
|
||||
return sizes
|
||||
|
||||
|
||||
def unsafe_workers(sizes: dict[tuple[str, str], int], workers: int | None) -> list[str]:
|
||||
"""One line per binary whose decompiled C is over the limit when more than one worker would evaluate it."""
|
||||
if (workers if workers is not None else os.cpu_count() or 1) <= 1:
|
||||
return []
|
||||
return [f"{binary} ({decompiler}): {size / 2**20:.1f} MB of C"
|
||||
for (binary, decompiler), size in sorted(sizes.items()) if size > MAX_PARALLEL_C_BYTES]
|
||||
|
||||
|
||||
def refuse_unsafe_workers(decompilations: dict, workers: int | None) -> None:
|
||||
over = unsafe_workers(c_bytes_of(decompilations), workers)
|
||||
if over:
|
||||
raise SystemExit(
|
||||
f"refusing to evaluate with {workers if workers is not None else 'all'} workers: "
|
||||
+ "; ".join(over)
|
||||
+ f" (limit {MAX_PARALLEL_C_BYTES // 2**20} MB). One evaluation worker for r2s's bash holds 5.2 GB, "
|
||||
"so parallel workers are OOM-killed and the binary loses every score. Run it with --workers 1.")
|
||||
|
||||
|
||||
def install_workers_guard() -> None:
|
||||
"""Put `refuse_unsafe_workers` in front of DecBench's `evaluate_project`, before the driver imports the name."""
|
||||
from decbench.pipeline import evaluate
|
||||
|
||||
original = evaluate.evaluate_project
|
||||
if getattr(original, "_r2sleigh_guard", False):
|
||||
return
|
||||
signature = inspect.signature(original)
|
||||
|
||||
@functools.wraps(original)
|
||||
def guarded(*args, **kwargs):
|
||||
given = signature.bind_partial(*args, **kwargs).arguments
|
||||
refuse_unsafe_workers(given.get("decompilations") or {}, given.get("workers"))
|
||||
return original(*args, **kwargs)
|
||||
|
||||
guarded._r2sleigh_guard = True
|
||||
evaluate.evaluate_project = guarded
|
||||
|
||||
|
||||
def tree_c_bytes(tree: Path, decompilers: list[str]) -> dict[tuple[str, str], int]:
|
||||
"""The decompiled C files already in a tree: `(opt/project/binary, decompiler) -> bytes`."""
|
||||
sizes = {}
|
||||
for c_file in sorted(tree.glob("*/*/decompiled/*.c")):
|
||||
named = split_name(c_file.stem, decompilers)
|
||||
if named is not None:
|
||||
sizes[(f"{c_file.parts[-4]}/{c_file.parts[-3]}/{named[1]}", named[0])] = c_file.stat().st_size
|
||||
return sizes
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("check", choices=("evaluated", "workers"))
|
||||
parser.add_argument("--tree", type=Path, required=True)
|
||||
parser.add_argument("--decompilers", default="")
|
||||
parser.add_argument("--since", type=float)
|
||||
parser.add_argument("--workers", type=int, default=1)
|
||||
args = parser.parse_args(argv)
|
||||
decompilers = [d for d in args.decompilers.split(",") if d]
|
||||
if not decompilers:
|
||||
parser.error("--decompilers is required")
|
||||
|
||||
if args.check == "workers":
|
||||
over = unsafe_workers(tree_c_bytes(args.tree, decompilers), args.workers)
|
||||
if over:
|
||||
print(f"refusing --workers {args.workers}: " + "; ".join(over) + f" in {args.tree} "
|
||||
f"(limit {MAX_PARALLEL_C_BYTES // 2**20} MB); one evaluation worker for r2s's bash holds 5.2 GB. "
|
||||
"Use --workers 1.", file=sys.stderr)
|
||||
return REFUSED
|
||||
return 0
|
||||
|
||||
checked, lost = lost_evaluations(args.tree, decompilers, args.since)
|
||||
if lost:
|
||||
print(f"ERROR: {len(lost)} of {checked} rendered binaries have no evaluation; their rows would be dropped "
|
||||
"from the scoreboard:", file=sys.stderr)
|
||||
for line in lost[:30]:
|
||||
print(f" {line}", file=sys.stderr)
|
||||
if len(lost) > 30:
|
||||
print(f" ... and {len(lost) - 30} more", file=sys.stderr)
|
||||
return LOST
|
||||
print(f"evaluations present for all {checked} binaries the run decompiled")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A run that loses rows stops, and an evaluation the host would kill is refused."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
import run_gate # noqa: E402
|
||||
|
||||
SCORED = '''binary = "{binary}"
|
||||
"{dec}.byte_match.total" = 0.8461538461538461
|
||||
"{dec}.byte_match.functions.main" = 0.0
|
||||
"{dec}.ged.total" = 4.0
|
||||
'''
|
||||
|
||||
|
||||
class Tree:
|
||||
"""A results tree: `<opt>/<project>/{decompiled,evaluated}/`."""
|
||||
|
||||
def __init__(self, root: str):
|
||||
self.root = Path(root)
|
||||
|
||||
def decompiled(self, decompiler: str, binary: str, functions: int = 3, c_bytes: int = 0,
|
||||
opt: str = "O2", project: str = "bash") -> Path:
|
||||
directory = self.root / opt / project / "decompiled"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
toml = directory / f"{decompiler}_{binary}.toml"
|
||||
toml.write_text(f'binary = "{binary}"\ndecompiler = "{decompiler}"\nfunction_count = {functions}\n')
|
||||
if c_bytes:
|
||||
(directory / f"{decompiler}_{binary}.c").write_bytes(b"a" * c_bytes)
|
||||
return toml
|
||||
|
||||
def evaluated(self, binary: str, text: str, opt: str = "O2", project: str = "bash") -> Path:
|
||||
directory = self.root / opt / project / "evaluated"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{binary}.toml"
|
||||
path.write_text(text)
|
||||
return path
|
||||
|
||||
|
||||
def gate(*argv: str) -> tuple[int, str]:
|
||||
err = io.StringIO()
|
||||
with contextlib.redirect_stderr(err), contextlib.redirect_stdout(io.StringIO()):
|
||||
status = run_gate.main(list(argv))
|
||||
return status, err.getvalue()
|
||||
|
||||
|
||||
class EvaluatedFiles(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.tree = Tree(self.dir.name)
|
||||
self.args = ("evaluated", "--tree", self.dir.name, "--decompilers", "r2sleigh_native,angr,kuna")
|
||||
|
||||
def tearDown(self):
|
||||
self.dir.cleanup()
|
||||
|
||||
def test_a_16_byte_evaluated_file_stops_the_run(self):
|
||||
# The 2026-10-17 bash: the evaluation worker was killed and the file kept its header only.
|
||||
self.tree.decompiled("r2sleigh_native", "bash", functions=1540)
|
||||
path = self.tree.evaluated("bash", 'binary = "bash"\n')
|
||||
self.assertEqual(path.stat().st_size, 16)
|
||||
status, err = gate(*self.args)
|
||||
self.assertEqual(status, run_gate.LOST)
|
||||
self.assertIn("r2sleigh_native rendered 1540 functions of bash", err)
|
||||
self.assertIn("O2/bash/evaluated/bash.toml is 16 bytes and scores no decompiler", err)
|
||||
|
||||
def test_the_command_exits_nonzero_for_the_shell(self):
|
||||
import subprocess
|
||||
|
||||
self.tree.decompiled("kuna", "bash")
|
||||
self.tree.evaluated("bash", 'binary = "bash"\n')
|
||||
done = subprocess.run([sys.executable, str(HERE / "run_gate.py"), *self.args],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(done.returncode, 71)
|
||||
self.assertIn("1 of 1 rendered binaries have no evaluation", done.stderr)
|
||||
|
||||
def test_a_missing_file_stops_the_run(self):
|
||||
self.tree.decompiled("angr", "bash")
|
||||
status, err = gate(*self.args)
|
||||
self.assertEqual(status, run_gate.LOST)
|
||||
self.assertIn("O2/bash/evaluated/bash.toml is missing", err)
|
||||
|
||||
def test_a_large_file_that_scores_only_other_decompilers_stops_the_run(self):
|
||||
# O2-noinline bash of 2026-10-17: 108 KB, angr's 692 rows, nothing of kuna, ghidra or r2s.
|
||||
self.tree.decompiled("angr", "bash")
|
||||
self.tree.decompiled("kuna", "bash")
|
||||
self.tree.evaluated("bash", SCORED.format(binary="bash", dec="angr") * 2000)
|
||||
status, err = gate(*self.args)
|
||||
self.assertEqual(status, run_gate.LOST)
|
||||
self.assertIn("kuna rendered 3 functions of bash", err)
|
||||
self.assertIn("scores angr, not kuna", err)
|
||||
self.assertNotIn("angr rendered", err)
|
||||
|
||||
def test_an_oversized_c_file_names_the_likely_cause(self):
|
||||
self.tree.decompiled("r2sleigh_native", "bash", c_bytes=9 * 2**20)
|
||||
self.tree.evaluated("bash", 'binary = "bash"\n')
|
||||
status, err = gate(*self.args)
|
||||
self.assertEqual(status, run_gate.LOST)
|
||||
self.assertIn("9.0 MB", err)
|
||||
self.assertIn("--workers 1", err)
|
||||
|
||||
def test_scored_binaries_pass_including_dotted_names_and_small_files(self):
|
||||
self.tree.decompiled("r2sleigh_native", "libz.so.1.2", project="zlib")
|
||||
self.tree.evaluated("libz.so.1.2", SCORED.format(binary="libz.so.1.2", dec="r2sleigh_native"),
|
||||
project="zlib")
|
||||
self.tree.decompiled("kuna", "psize")
|
||||
self.tree.evaluated("psize", SCORED.format(binary="psize", dec="kuna"))
|
||||
status, err = gate(*self.args)
|
||||
self.assertEqual((status, err), (0, ""))
|
||||
|
||||
def test_only_what_this_run_decompiled_is_checked(self):
|
||||
# A resumed run: angr is older than the run's start and not evaluated again.
|
||||
old = self.tree.decompiled("angr", "bash")
|
||||
os.utime(old, (1_000, 1_000))
|
||||
self.tree.decompiled("r2sleigh_native", "bash")
|
||||
self.tree.evaluated("bash", SCORED.format(binary="bash", dec="r2sleigh_native"))
|
||||
self.assertEqual(gate(*self.args, "--since", "2000")[0], 0)
|
||||
self.assertEqual(gate(*self.args)[0], run_gate.LOST)
|
||||
|
||||
def test_a_decompiler_that_rendered_nothing_owes_no_scores(self):
|
||||
self.tree.decompiled("angr", "bash", functions=0)
|
||||
self.assertEqual(gate(*self.args)[0], 0)
|
||||
|
||||
|
||||
class OneWorkerForBigC(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.tree = Tree(self.dir.name)
|
||||
|
||||
def tearDown(self):
|
||||
self.dir.cleanup()
|
||||
|
||||
def workers(self, n: int) -> tuple[int, str]:
|
||||
return gate("workers", "--tree", self.dir.name, "--workers", str(n),
|
||||
"--decompilers", "r2sleigh_native,angr")
|
||||
|
||||
def test_more_than_one_worker_is_refused_for_a_binary_over_8_mb(self):
|
||||
self.tree.decompiled("r2sleigh_native", "bash", c_bytes=13 * 2**20)
|
||||
self.tree.decompiled("angr", "bash", c_bytes=2**20)
|
||||
status, err = self.workers(4)
|
||||
self.assertEqual(status, run_gate.REFUSED)
|
||||
self.assertIn("O2/bash/bash (r2sleigh_native): 13.0 MB", err)
|
||||
self.assertNotIn("angr", err)
|
||||
self.assertEqual(self.workers(1), (0, ""))
|
||||
|
||||
def test_a_binary_under_the_limit_may_use_every_worker(self):
|
||||
self.tree.decompiled("r2sleigh_native", "bzip2", c_bytes=7 * 2**20, project="bzip2")
|
||||
self.assertEqual(self.workers(8), (0, ""))
|
||||
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, megabytes: float):
|
||||
code = "a" * int(megabytes * 2**20)
|
||||
self.functions = {"main": types.SimpleNamespace(decompiled_code=code)}
|
||||
|
||||
|
||||
class DriverGuard(unittest.TestCase):
|
||||
"""`install_workers_guard` in front of a stand-in `evaluate_project` with DecBench's signature."""
|
||||
|
||||
def setUp(self):
|
||||
self.calls = []
|
||||
|
||||
def evaluate_project(project, decompilations, output_dir, optimization=None, metrics=None,
|
||||
parallel=True, workers=None, precomputed_source_cfgs=None,
|
||||
source_function_owners=None):
|
||||
self.calls.append(workers)
|
||||
return {"bash": {"r2sleigh_native": {"ged": 1}}}
|
||||
|
||||
self.module = types.ModuleType("decbench.pipeline.evaluate")
|
||||
self.module.evaluate_project = evaluate_project
|
||||
pipeline = types.ModuleType("decbench.pipeline")
|
||||
pipeline.evaluate = self.module
|
||||
patcher = mock.patch.dict(sys.modules, {
|
||||
"decbench": types.ModuleType("decbench"),
|
||||
"decbench.pipeline": pipeline,
|
||||
"decbench.pipeline.evaluate": self.module,
|
||||
})
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
run_gate.install_workers_guard()
|
||||
run_gate.install_workers_guard() # idempotent
|
||||
|
||||
def test_parallel_workers_over_big_c_end_the_run_instead_of_returning_nothing(self):
|
||||
big = {"bash": {"r2sleigh_native": FakeResult(9), "angr": FakeResult(1)}}
|
||||
# The driver catches `Exception` around `evaluate_project` and carries on with `{}`.
|
||||
with self.assertRaises(SystemExit) as stop:
|
||||
self.module.evaluate_project("project", big, Path("out"), None, None, True, 2)
|
||||
self.assertNotIsInstance(stop.exception, Exception)
|
||||
self.assertIn("bash (r2sleigh_native): 9.0 MB", str(stop.exception))
|
||||
self.assertIn("--workers 1", str(stop.exception))
|
||||
with self.assertRaises(SystemExit):
|
||||
self.module.evaluate_project("project", big, Path("out"), workers=2)
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_one_worker_or_small_c_passes_through(self):
|
||||
big = {"bash": {"r2sleigh_native": FakeResult(9)}}
|
||||
small = {"bzip2": {"r2sleigh_native": FakeResult(1)}}
|
||||
self.assertEqual(self.module.evaluate_project("p", big, Path("o"), workers=1),
|
||||
{"bash": {"r2sleigh_native": {"ged": 1}}})
|
||||
self.module.evaluate_project("p", small, Path("o"), workers=8)
|
||||
self.assertEqual(self.calls, [1, 8])
|
||||
|
||||
def test_no_worker_count_means_every_cpu(self):
|
||||
big = {"bash": {"r2sleigh_native": FakeResult(9)}}
|
||||
with mock.patch.object(run_gate.os, "cpu_count", return_value=4):
|
||||
with self.assertRaises(SystemExit):
|
||||
self.module.evaluate_project("p", big, Path("o"))
|
||||
with mock.patch.object(run_gate.os, "cpu_count", return_value=1):
|
||||
self.module.evaluate_project("p", big, Path("o"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user