fix(cpg): answer a failed batch in parts instead of losing it whole (M3)

PMPro's 1,274 PHP files produced a response the engine could not serialize: it
failed inside ujson while building its own answer array, and every question in that
batch was lost with it. Three budgets from 900 to 3000 seconds made no difference,
because the size of the answer was the problem rather than the time available.

One invocation stays the first thing tried, so a working scan costs exactly what it
did. Each request in a batch is independent, so a batch that fails as a whole can
still answer in parts: on failure the requests are split and retried, and a part
that fails leaves its own requests unanswered rather than discarding every answer
beside them. Only a batch where no part answered is reported as unavailable, which
keeps "could not ask" distinct from "no rows".

Controls cover all three shapes: a whole batch that fails is answered in parts with
the split recorded, a failing part leaves only its own requests unanswered while the
rest are kept, and a batch no part can answer is still unavailable rather than
empty.

Whether this is enough for PMPro is a separate question the measurement will
answer: a single taint request on that tree already takes 40 seconds and returns
36 KB, so the batch may be slow as well as large.

1,410 tests passed, nine skipped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-18 12:47:50 +02:00
co-authored by Claude Opus 5
parent d64abc2d43
commit aa66429a82
2 changed files with 99 additions and 1 deletions
+58
View File
@@ -1159,3 +1159,61 @@ def test_javascript_partition_uses_declared_frontend_not_legacy_autodetection(tm
finally:
if result is not None:
result.cleanup()
def test_a_batch_that_fails_whole_is_answered_in_parts(tmp_path, monkeypatch):
"""M3: PMPro's batch failed inside the engine's own serialization and lost every answer with it."""
from openultrasast.cpg.backend import JoernBackend
seen = []
def runner(command, **kwargs):
payload = Path(command[command.index("--param") + 3].split("=", 1)[1])
requests = json.loads(payload.read_text())
seen.append(len(requests))
if len(requests) > 4: # the whole batch is more than this engine can answer
return subprocess.CompletedProcess(command, 1, "", "ujson serialization failed")
rows = {rid: [{"sink": "q(" + rid + ")"}] for rid in requests}
body = "---OUSAST-CPG-BEGIN---\n" + json.dumps(rows) + "\n---OUSAST-CPG-END---"
return subprocess.CompletedProcess(command, 0, body, "")
monkeypatch.setattr("shutil.which", lambda name: f"/opt/joern/{name}")
backend = JoernBackend(runner=runner)
graph = tmp_path / "cpg.bin"
graph.write_bytes(b"graph")
answered = backend.query_batch(graph, "taint", {str(i): {"file": f"f{i}.php"} for i in range(16)})
assert answered is not None and len(answered) == 16, "every independent request should still be answered"
assert seen[0] == 16 and max(seen[1:]) <= 4, "the whole batch is tried first, then parts"
assert any("batch_split_after_failure" in note for note in backend._diagnostics)
def test_a_part_that_fails_leaves_only_its_own_requests_unanswered(tmp_path, monkeypatch):
from openultrasast.cpg.backend import JoernBackend
def runner(command, **kwargs):
payload = Path(command[command.index("--param") + 3].split("=", 1)[1])
requests = json.loads(payload.read_text())
if len(requests) > 4 or "0" in requests: # whole batch, and one part, cannot answer
return subprocess.CompletedProcess(command, 1, "", "engine failure")
rows = {rid: [] for rid in requests}
body = "---OUSAST-CPG-BEGIN---\n" + json.dumps(rows) + "\n---OUSAST-CPG-END---"
return subprocess.CompletedProcess(command, 0, body, "")
monkeypatch.setattr("shutil.which", lambda name: f"/opt/joern/{name}")
backend = JoernBackend(runner=runner)
graph = tmp_path / "cpg.bin"
graph.write_bytes(b"graph")
answered = backend.query_batch(graph, "taint", {str(i): {"file": f"f{i}.php"} for i in range(16)})
assert answered is not None and "0" not in answered, "a failed part stays unanswered, not empty"
assert len(answered) == 14, "the parts that answered are kept"
def test_a_batch_no_part_can_answer_is_still_unavailable(tmp_path, monkeypatch):
from openultrasast.cpg.backend import JoernBackend
monkeypatch.setattr("shutil.which", lambda name: f"/opt/joern/{name}")
backend = JoernBackend(runner=lambda command, **k: subprocess.CompletedProcess(command, 1, "", "dead"))
graph = tmp_path / "cpg.bin"
graph.write_bytes(b"graph")
# None means "could not ask", which must never become an empty answer.
assert backend.query_batch(graph, "taint", {str(i): {"file": f"f{i}.php"} for i in range(8)}) is None