From c586f6e30d875e5b8baa6f185183b7389c4ee4c6 Mon Sep 17 00:00:00 2001 From: Maximilian Wedekind Date: Fri, 28 Aug 2026 12:55:29 +0200 Subject: [PATCH] fix(joern): stop poisoning the REPL with a repeated importCpg `run_cpgql` prefixed every query with `importCpg(...)`. On a server that already has the CPG loaded that import fails -- and the REPL's own error renderer throws while formatting the failure: java.lang.NullPointerException: Cannot invoke "java.lang.CharSequence.length()" because "raw" is null at fansi.Str$.apply(Fansi.scala:268) at replpp.Rendering.renderError(Rendering.scala:168) From then on the REPL is dead: every further query fails too, with or without an import. In effect the server was limited to one usable query per `lmc up`. Worse, it fails silently. The REST layer still answers success=true, so `JoernClient.run` returned an empty `result` and `lmc callers --engine joern` reported `{"1": []}` -- a zero hit that looks like an answer. Two changes: * `run_cpgql` runs the query first and only imports when the server says "No projects loaded". A query against a server with no CPG fails cleanly and leaves the REPL intact, so this path is safe; it costs one extra request on a cold start and none afterwards. * `JoernClient.run` treats a REPL error in stdout as a failure instead of reporting success with an empty result. Measured against intranet2.0 (2.5M nodes), three queries in a row on a freshly started server, same 120s default timeout: before: success=true, result='' on all three (crash in stdout) after: '2' (49.5s, cold-start import), '165' (0.5s), '44990' (0.5s) --- lmc/joern.py | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/lmc/joern.py b/lmc/joern.py index e338545..c18349b 100644 --- a/lmc/joern.py +++ b/lmc/joern.py @@ -55,6 +55,11 @@ _ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") +# Die Joern-REPL schreibt Fehler als `.Error:` / `...Exception:` an den +# Zeilenanfang; regulaere Ergebniszeilen beginnen mit `val resN:`. +_REPL_ERROR = re.compile(r'^(?:[\w$]+\.)*[\w$]*(?:Error|Exception)(?::|\s*$)', re.M) +_NO_CPG_LOADED = "No projects loaded" + def _extract_result(stdout: str) -> str: """Extrahiert den Wert der letzten `val resN: ... = ` Bindung. @@ -130,6 +135,12 @@ def run(self, query: str, timeout: float = 120.0) -> dict: if not res.get("success"): return res stdout = res["stdout"].strip() + # Die REPL meldet Fehler im stdout, das REST-Protokoll bleibt dabei auf + # success=true. Ohne diese Pruefung kommt eine gescheiterte Query als + # Erfolg mit leerem `result` zurueck -- ein Nulltreffer, der wie ein + # Ergebnis aussieht. + if _REPL_ERROR.search(stdout): + return {"success": False, "error": stdout, "engine": "joern"} return {"success": True, "stdout": stdout, "result": _extract_result(stdout), "engine": "joern"} @@ -174,10 +185,27 @@ def joern_parse(worktree: str, codebase_hash: str, language: str | None = None) def run_cpgql(codebase_hash: str, cpgql: str, url: str | None = None, timeout: float = 120.0) -> dict: - """Laedt den CPG und fuehrt rohes CPGQL aus; liefert cleaned stdout.""" - cpg = cpg_path_in_container(codebase_hash) - full = f'importCpg("{cpg}")\n{cpgql}' - return JoernClient(url).run(full, timeout=timeout) + """Fuehrt rohes CPGQL aus und importiert den CPG nur, wenn noch keiner geladen ist. + + `importCpg` auf einen bereits geladenen CPG vergiftet die REPL dauerhaft: der + Import scheitert, und der Fehler-Renderer wirft dabei selbst eine + NullPointerException (`fansi.Str$.apply` <- `replpp.Rendering.renderError`). + Danach scheitert *jede* weitere Query, auch ohne Import. Ein unbedingter + Import-Prefix begrenzt den Server damit auf genau eine brauchbare Query pro + Start. + + Eine Query ohne geladenen CPG scheitert dagegen sauber ("No projects loaded") + und laesst die REPL intakt -- deshalb erst fragen, dann bei Bedarf importieren. + Kostet einen zusaetzlichen Request im Kaltstart und keinen danach. + """ + client = JoernClient(url) + res = client.run(cpgql, timeout=timeout) + if res.get("success") or _NO_CPG_LOADED not in str(res.get("error", "")): + return res + imported = client.run(f'importCpg("{cpg_path_in_container(codebase_hash)}")', timeout=timeout) + if not imported.get("success"): + return imported + return client.run(cpgql, timeout=timeout) # --- Navigations-Queries (gleiche Shape wie der tree-sitter-Gateway) ---