Files
DroidDeck/tools/tests/test_termux_jni.py
abduznik fad52ec125 Terminal: release createSubprocess's JNI local references, and the cwd chars against cwd (#527)
Fix JNI local reference leak in createSubprocess

The argv and envp loops took one local reference per element from
GetObjectArrayElement and never released it, so the references piled up
until the native call returned. ART aborts the process at 512 local
references ("local reference table overflow"), and both loops share the
table, so a long argument list plus a large environment could hit it.

Delete each element's local reference once its chars are copied and
released, after ReleaseStringUTFChars since the chars depend on the string.

Also release the cwd chars against cwd rather than cmd; the call passed
the wrong jstring.

Adds a host-side test that runs termux.c against a mock JNIEnv that counts
local references and checks release pairing. It needs a jni.h (JNI_INCLUDE,
JAVA_HOME or the NDK) and skips without one.

Fixes #507
2026-10-10 14:29:51 -04:00

80 lines
3.2 KiB
Python

import glob
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import unittest
TESTS = Path(__file__).resolve().parent
TERMUX_DIR = TESTS.parents[1] / "app/src/main/cpp/termux"
def jni_include_dir():
"""A directory holding jni.h: JNI_INCLUDE, then the JDK, then the Android NDK."""
candidates = [os.environ.get("JNI_INCLUDE")]
java_home = os.environ.get("JAVA_HOME")
if java_home:
candidates.append(os.path.join(java_home, "include"))
candidates += glob.glob("/usr/lib/jvm/*/include")
for var in ("ANDROID_NDK_LATEST_HOME", "ANDROID_NDK_HOME", "ANDROID_NDK_ROOT", "ANDROID_NDK"):
ndk = os.environ.get(var)
if ndk:
candidates += glob.glob(os.path.join(ndk, "toolchains/llvm/prebuilt/*/sysroot/usr/include"))
for candidate in candidates:
if candidate and os.path.isfile(os.path.join(candidate, "jni.h")):
return candidate
return None
def include_flags():
"""-I flags for jni.h, plus the JDK's per-platform directory that holds jni_md.h."""
base = jni_include_dir()
flags = ["-I", base]
for sub in glob.glob(os.path.join(base, "*")):
if os.path.isfile(os.path.join(sub, "jni_md.h")):
flags += ["-I", sub]
return flags
@unittest.skipUnless(shutil.which("cc"), "needs a C compiler")
@unittest.skipUnless(jni_include_dir(), "needs jni.h (set JNI_INCLUDE, JAVA_HOME or ANDROID_NDK_HOME)")
class TermuxJniTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
cls.addClassCleanup(cls.tmp.cleanup)
cls.binary = os.path.join(cls.tmp.name, "termux_jni_harness")
subprocess.run(
["cc", "-std=gnu11", "-D_GNU_SOURCE", "-Wall", *include_flags(), "-I", str(TERMUX_DIR),
str(TESTS / "termux_jni_harness.c"), "-o", cls.binary],
check=True, capture_output=True, text=True)
def run_harness(self, args, envs):
result = subprocess.run([self.binary, str(args), str(envs)],
capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
line = next(l for l in result.stdout.splitlines() if l.startswith("peak_refs="))
return {key: int(value) for key, value in re.findall(r"(\w+)=(\d+)", line)}
def test_a_long_argv_and_environment_stay_under_the_local_reference_limit(self):
# 300 + 300 is under 512 for each loop on its own but over it together, as the issue says:
# ART aborts with "local reference table overflow (max=512)".
stats = self.run_harness(300, 300)
self.assertEqual(stats["overflowed"], 0, stats)
def test_every_element_reference_is_released(self):
stats = self.run_harness(8, 8)
self.assertEqual(stats["live_refs"], 0, stats)
self.assertLessEqual(stats["peak_refs"], 1, stats)
def test_utf_chars_are_released_against_the_string_they_came_from(self):
stats = self.run_harness(1, 1)
self.assertEqual(stats["mismatched_releases"], 0, stats)
self.assertEqual(stats["unreleased_chars"], 0, stats)
if __name__ == "__main__":
unittest.main()