diff --git a/src/openultrasast/cpg/backend.py b/src/openultrasast/cpg/backend.py index a390e25..befd7fd 100644 --- a/src/openultrasast/cpg/backend.py +++ b/src/openultrasast/cpg/backend.py @@ -361,6 +361,10 @@ class JoernBackend: if result is None: for scratch in session._scratch: session._cleanup(scratch, final=True) + # This copy owns any engine session it started. Leaving it open leaks a JVM holding the + # configured heap for the life of the process, and two builds per transaction meant two + # of them: enough to exhaust a 3 GB container and make every later request fail. + session.close_session() self.last_failure = session.last_failure self._diagnostics = list(session._diagnostics) @@ -1165,7 +1169,9 @@ class JoernBackend: 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", " ") + # 500 characters was not enough: a Scala stack trace is longer than that, so the window + # showed only frames and cut off the exception that names the cause. + detail = (session.last_body or session.log_tail())[-2000:].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 diff --git a/src/openultrasast/model/scan.py b/src/openultrasast/model/scan.py index ef08820..c9198ac 100644 --- a/src/openultrasast/model/scan.py +++ b/src/openultrasast/model/scan.py @@ -186,6 +186,15 @@ def scan_repository( finally: for cpg in owned: _dispose(cpg) + # An engine session belongs to the transaction, so it ends with the transaction. Leaving it + # open leaks a JVM holding the configured heap, which is how the first session-enabled run + # exhausted its container and made every later request fail. + close = getattr(backend, "close_session", None) + if callable(close): + try: + close() + except Exception as error: # noqa: BLE001 -- tidying up must not fail a completed scan + logger.warning("could not end the engine session: %s", error) diagnostics = list(result.degradations) if execution_budget is not None: if client is not None: diff --git a/tests/test_cpg_session.py b/tests/test_cpg_session.py index 6244395..488c7c6 100644 --- a/tests/test_cpg_session.py +++ b/tests/test_cpg_session.py @@ -290,3 +290,30 @@ def test_a_transport_failure_carries_the_server_log(tmp_path, fake): assert live.failure == "session_request_failed" assert "OutOfMemoryError" in live.last_body assert "OutOfMemoryError" in live.log_tail() + + +def test_a_build_closes_the_session_it_started(tmp_path, fake, monkeypatch): + """M2: an unclosed session leaks a JVM holding the configured heap for the whole process.""" + from openultrasast.cpg.backend import JoernBackend + + closed: list[str] = [] + monkeypatch.setattr(EngineSession, "start", lambda self: True) + monkeypatch.setattr(EngineSession, "close", lambda self: closed.append(str(self.scratch))) + backend = JoernBackend(session_transport=True) + # The build runs on a copy, so the session it starts is the copy's to close. + monkeypatch.setattr(JoernBackend, "_build_impl", lambda self, root, **k: self._session_for(tmp_path / "cpg.bin")) + backend.build(tmp_path, execution_budget=ExecutionBudget(time.monotonic() + 20, 1.0)) + assert closed, "the build copy left its session open, which leaks a JVM holding the heap" + assert backend._session is None + + +def test_the_driver_ends_the_session_with_the_transaction(tmp_path): + from openultrasast.cpg.backend import JoernBackend + from openultrasast.model.scan import scan_repository + + backend = JoernBackend() + closed: list[bool] = [] + backend.close_session = lambda: closed.append(True) # type: ignore[method-assign] + (tmp_path / "a.js").write_text("const x = 1;\n") + scan_repository(tmp_path, [], backend=backend) + assert closed == [True]