diff --git a/src/openultrasast/cpg/backend.py b/src/openultrasast/cpg/backend.py index b70c689..a390e25 100644 --- a/src/openultrasast/cpg/backend.py +++ b/src/openultrasast/cpg/backend.py @@ -1163,7 +1163,11 @@ class JoernBackend: arguments = ", ".join(f"{name} = {json.dumps(_render(value))}" for name, value in sorted(params.items())) answer = session.evaluate(f"{symbol}({arguments})", timeout=self.query_timeout) if answer is None: + # Carry the engine's own account, not just our label: a bare `session_request_failed` + # cannot distinguish a timeout from a crash from an out-of-memory kill. + detail = (session.last_body or session.log_tail())[-500:].replace("\n", " ") self._note("session_query_failed:" + query + ":" + (session.failure or "unknown")) + logger.warning("session query %s failed (exit=%s): %s", query, session.exit_code(), detail) return None return answer.stdout diff --git a/src/openultrasast/cpg/session.py b/src/openultrasast/cpg/session.py index 126a063..adb5859 100644 --- a/src/openultrasast/cpg/session.py +++ b/src/openultrasast/cpg/session.py @@ -87,6 +87,17 @@ class EngineSession: def alive(self) -> bool: return not self.failure and self._process is not None and self._process.poll() is None + def log_tail(self, limit: int = 2000) -> str: + """The end of the server's own log, which is where a crash or an OOM kill is recorded.""" + try: + data = (self.scratch / "session.log").read_bytes() + except OSError: + return "" + return data[-limit:].decode("utf-8", "replace") + + def exit_code(self) -> int | None: + return self._process.poll() if self._process is not None else None + def _poison(self, reason: str) -> None: if not self.failure: self.failure = reason @@ -143,8 +154,11 @@ class EngineSession: try: with urllib.request.urlopen(call, timeout=timeout) as response: # noqa: S310 -- fixed loopback URL body = json.load(response) - except (urllib.error.URLError, TimeoutError, OSError, ValueError): - self.last_body = "" + except (urllib.error.URLError, TimeoutError, OSError, ValueError) as error: + # A transport failure usually means the server is gone, and the only account of why is + # in its own log. Without this the caller sees `session_request_failed` and nothing else, + # which is indistinguishable from a timeout, a crash and an out-of-memory kill. + self.last_body = f"{type(error).__name__}: {error}\n{self.log_tail()}" return None if not isinstance(body, dict): self.last_body = "" diff --git a/tests/test_cpg_session.py b/tests/test_cpg_session.py index e758d3f..6244395 100644 --- a/tests/test_cpg_session.py +++ b/tests/test_cpg_session.py @@ -278,3 +278,15 @@ def test_the_session_switch_is_explicit_and_defaults_off(monkeypatch): assert JoernBackend().session_transport is True # An explicit argument still wins over the environment in both directions. assert JoernBackend(session_transport=False).session_transport is False + + +def test_a_transport_failure_carries_the_server_log(tmp_path, fake): + """M2: `session_request_failed` alone cannot tell a crash from an OOM kill.""" + with session(tmp_path, fake) as live: + assert live.start() + (tmp_path / "scratch" / "session.log").write_text("java.lang.OutOfMemoryError: Java heap space\n") + live._port = 1 # nothing is listening there + assert live.evaluate("cpg.file.size", timeout=2) is None + assert live.failure == "session_request_failed" + assert "OutOfMemoryError" in live.last_body + assert "OutOfMemoryError" in live.log_tail()