fix(cpg): end an engine session with the transaction that owns it (M2)

The first session-enabled replay produced correct detections and no speedup,
reporting session_query_failed:census:session_request_failed on both sides. A
targeted probe ruled out the sequence I suspected: an overlay followed by a census
in one session both answer correctly, with the census confirming the overlays were
saved.

What the probe exposed instead is a leak. A build runs on a copy of the backend, so
the session started during the build belonged to that copy and was never closed,
leaving a JVM holding the configured 2 GB heap for the life of the process. Two
builds per transaction meant two of them, which is enough to exhaust a 3 GB
container and make every later request fail. It also matches the host running out
of memory during these runs.

The build now closes any session its copy started, and the driver ends the session
with the transaction, since that is what transaction-owned means. Controls cover
both: a build leaves nothing alive on the copy that started it, and a completed
scan closes the backend's session. Closing never fails a scan that found things.

This is a genuine defect found by measurement rather than by the suite, which
passed throughout. Whether it was the whole cause of the census failure is not yet
established; the next session-enabled run will say.

1,399 tests passed, nine skipped. Rollout stays NO-GO.

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-16 21:15:00 +02:00
co-authored by Claude Opus 5
parent d2362cd18d
commit 566b862b0a
3 changed files with 43 additions and 1 deletions
+7 -1
View File
@@ -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
+9
View File
@@ -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:
+27
View File
@@ -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]