fix(cpg): a timed-out query must kill the JVM, not just the script

`joern` and `joern-parse` are shell scripts that launch a JVM as a separate
process. `subprocess.run(timeout=)` kills the script and leaves the JVM
resident, holding its heap, indefinitely. Every `query_failed: timeout` leaked
about a gigabyte.

Found by running out of memory. After a day of 300s and 2,400s taint timeouts,
four orphaned JVMs -- three hours old, ~2GB between them -- were still resident
on a 7GB machine, and background tasks started being killed. On a CI runner
doing several scans in sequence this is an OOM rather than a degradation, and
it would present as "the scan needs more memory", which is the wrong lesson
entirely: the scan's own peak is a flat ~1.5GB whether its query runs for five
minutes or forty.

The command now starts in its own session, and a timeout SIGTERMs the process
group and SIGKILLs what survives. An injected test runner is left alone,
because those spawn nothing.

Tested with a real shell script that backgrounds a child, since the bug is
specifically about grandchildren -- and the test reaps that child itself if the
assertion fails, rather than leaking the process it is complaining about.

855 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-10 15:16:45 +02:00
co-authored by Claude Opus 5
parent a895bceefc
commit 5abc6cd6f6
2 changed files with 86 additions and 1 deletions
+45 -1
View File
@@ -24,6 +24,7 @@ import logging
import os import os
import re import re
import shutil import shutil
import signal
import subprocess import subprocess
import tempfile import tempfile
from collections.abc import Callable, Mapping, Sequence from collections.abc import Callable, Mapping, Sequence
@@ -655,13 +656,56 @@ class JoernBackend:
return f"-J-Xmx{configured if configured.isdigit() else CPG_HEAP_MB}m" return f"-J-Xmx{configured if configured.isdigit() else CPG_HEAP_MB}m"
def _run(self, command: list[str], *, timeout: int, cwd: Path | None = None) -> subprocess.CompletedProcess[str] | None: def _run(self, command: list[str], *, timeout: int, cwd: Path | None = None) -> subprocess.CompletedProcess[str] | None:
"""Run one Joern command, and on timeout kill the whole PROCESS GROUP rather than the child.
`joern` and `joern-parse` are shell scripts that launch a JVM as a separate process. Killing the
script -- which is all `subprocess.run(timeout=)` does -- leaves that JVM running, holding its heap,
for as long as the machine lasts. Every `query_failed: timeout` leaked about a gigabyte.
Measured the hard way: after a day of 300s and 2,400s taint timeouts, four orphaned JVMs three hours
old were still resident and the machine ran out of memory. On a CI runner doing several scans in a
row it would OOM rather than merely degrade -- and it would look like the SCAN needing more memory,
which is the wrong lesson entirely.
"""
run = self.runner if self.runner is not None else subprocess.run run = self.runner if self.runner is not None else subprocess.run
if self.runner is not None: # tests inject a fake runner and never spawn anything
try:
return run(command, capture_output=True, text=True, timeout=timeout, check=False, cwd=str(cwd) if cwd else None)
except (OSError, subprocess.SubprocessError):
return None
try: try:
return run(command, capture_output=True, text=True, timeout=timeout, check=False, cwd=str(cwd) if cwd else None) with subprocess.Popen( # noqa: S603 -- the command is built here, never from user input
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=str(cwd) if cwd else None,
start_new_session=True, # its own process group, so the JVM dies with the script
) as process:
try:
stdout, stderr = process.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
_terminate_group(process)
return None
return subprocess.CompletedProcess(command, process.returncode, stdout, stderr)
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
return None return None
def _terminate_group(process: subprocess.Popen[str]) -> None:
"""SIGTERM the process group, then SIGKILL what is left. Never raises: this is cleanup, not logic."""
for signal_number in (signal.SIGTERM, signal.SIGKILL):
try:
os.killpg(os.getpgid(process.pid), signal_number)
except (OSError, ProcessLookupError):
return
try:
process.wait(timeout=10)
return
except subprocess.TimeoutExpired:
continue
# Every Joern frontend logs a dropped file the same way, at WARN, and then carries on: # Every Joern frontend logs a dropped file the same way, at WARN, and then carries on:
# #
# WARN AstCreationPass Failed to process '/abs/path/to/file.php' # WARN AstCreationPass Failed to process '/abs/path/to/file.php'
+41
View File
@@ -7,6 +7,7 @@ Nothing here imports a JVM binding, and every test runs with Joern absent.
from __future__ import annotations from __future__ import annotations
import contextlib
import json import json
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -51,6 +52,7 @@ def test_the_seam_imports_nothing_outside_the_standard_library() -> None:
"os", "os",
"re", "re",
"shutil", "shutil",
"signal",
"subprocess", "subprocess",
"tempfile", "tempfile",
"dataclasses", "dataclasses",
@@ -684,3 +686,42 @@ def test_a_file_php2cpg_miscompiles_is_excluded_and_named(tmp_path: Path, monkey
build = next(c for c in commands if "--script" not in c) build = next(c for c in commands if "--script" not in c)
assert "--exclude" in build and any("bad.php" in part for part in build), "and excluded from the build" assert "--exclude" in build and any("bad.php" in part for part in build), "and excluded from the build"
assert not any("ok.php" in part for part in build if part != str(tmp_path)), "a global at function scope is fine" assert not any("ok.php" in part for part in build if part != str(tmp_path)), "a global at function scope is fine"
def test_a_timed_out_query_kills_the_jvm_not_just_the_script(tmp_path: Path) -> None:
"""`joern` is a shell script that launches a JVM as a separate process.
`subprocess.run(timeout=)` kills the script and leaves the JVM resident, holding its heap. Measured the
hard way: after a day of 300s and 2,400s taint timeouts, four orphaned JVMs three hours old were still
using about a gigabyte each and the machine ran out of memory. On CI that is an OOM rather than a
degradation, and it would look like the scan needing more memory -- the wrong lesson entirely.
Uses a real shell script that backgrounds a child, because the whole bug is about grandchildren.
"""
import os
import time
from openultrasast.cpg.backend import JoernBackend
marker = tmp_path / "child-alive"
script = tmp_path / "fake-joern.sh"
script.write_text(
f"#!/bin/sh\n( while true; do echo x > '{marker}'; sleep 0.2; done ) &\necho $! > " + str(tmp_path / "child.pid") + "\nsleep 60\n"
)
script.chmod(0o755)
started = time.monotonic()
assert JoernBackend()._run([str(script)], timeout=2) is None, "a timeout reports nothing, never a verdict"
assert time.monotonic() - started < 30, "and it does not wait for the script's own sleep"
child = int((tmp_path / "child.pid").read_text().strip())
time.sleep(0.5)
alive = True
try:
os.kill(child, 0)
except ProcessLookupError:
alive = False
if alive: # cleanup, so a failure here does not leak the process it is complaining about
with contextlib.suppress(ProcessLookupError, PermissionError):
os.kill(child, 9)
assert not alive, "the grandchild JVM must die with the script, or every timeout leaks a gigabyte"