From 6c397b130566b2ac2a8fed7c8e2f494f69fa6150 Mon Sep 17 00:00:00 2001 From: Tyler Finethy Date: Tue, 4 Aug 2026 15:01:30 -0400 Subject: [PATCH] fix: close jar entry streams during SymDB scan SymbolAggregator.parseJarEntry() never closed the jar entry stream, so the JarFile allocated a fresh native Inflater for every class file instead of reusing its cached one. Scanning a large jar could burst hundreds of MB of off-heap memory and OOMKill containers running with tight memory limits. Also closes the Files.walk() stream leaked by the directory scan path. --- .../debugger/symbol/SymbolAggregator.java | 26 +++++++--- .../debugger/symbol/SymbolAggregatorTest.java | 49 +++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/dd-java-agent/agent-debugger/src/main/java/com/datadog/debugger/symbol/SymbolAggregator.java b/dd-java-agent/agent-debugger/src/main/java/com/datadog/debugger/symbol/SymbolAggregator.java index d07ccda2804..c88976187bd 100644 --- a/dd-java-agent/agent-debugger/src/main/java/com/datadog/debugger/symbol/SymbolAggregator.java +++ b/dd-java-agent/agent-debugger/src/main/java/com/datadog/debugger/symbol/SymbolAggregator.java @@ -4,6 +4,7 @@ import com.datadog.debugger.sink.SymbolSink; import datadog.instrument.utils.ClassNameTrie; +import datadog.trace.api.internal.VisibleForTesting; import datadog.trace.bootstrap.debugger.DebuggerContext; import datadog.trace.util.AgentTaskScheduler; import datadog.trace.util.Strings; @@ -28,6 +29,7 @@ import java.util.concurrent.TimeUnit; import java.util.jar.JarEntry; import java.util.jar.JarFile; +import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -206,7 +208,7 @@ public void scanJar( scanDirectory(jarPath, alreadyScannedJars, baos, buffer, symDBReport); } else { try { - try (JarFile jarFile = new JarFile(jarPathFile)) { + try (JarFile jarFile = openJarFile(jarPathFile)) { jarFile.stream() .filter(jarEntry -> jarEntry.getName().endsWith(".class")) .filter( @@ -225,14 +227,19 @@ public void scanJar( alreadyScannedJars.add(jarPath.toString()); } + @VisibleForTesting + JarFile openJarFile(File file) throws IOException { + return new JarFile(file); + } + private void scanDirectory( Path jarPath, Set alreadyScannedJars, ByteArrayOutputStream baos, byte[] buffer, SymDBReport symDBReport) { - try { - Files.walk(jarPath) + try (Stream paths = Files.walk(jarPath)) { + paths // explicitly no follow links walking the directory to avoid cycles .filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) .filter(path -> path.toString().endsWith(".class")) @@ -276,11 +283,14 @@ private void parseJarEntry( byte[] buffer) { LOGGER.debug("parsing jarEntry class: {}", jarEntry.getName()); try { - InputStream inputStream = jarFile.getInputStream(jarEntry); - int readBytes; - baos.reset(); - while ((readBytes = inputStream.read(buffer)) != -1) { - baos.write(buffer, 0, readBytes); + // must be closed so the JarFile returns its Inflater to the cache instead of allocating a + // new native zlib context per entry + try (InputStream inputStream = jarFile.getInputStream(jarEntry)) { + int readBytes; + baos.reset(); + while ((readBytes = inputStream.read(buffer)) != -1) { + baos.write(buffer, 0, readBytes); + } } parseClass(symDBReport, jarEntry.getName(), baos.toByteArray(), jarPath.toString()); } catch (IOException ex) { diff --git a/dd-java-agent/agent-debugger/src/test/java/com/datadog/debugger/symbol/SymbolAggregatorTest.java b/dd-java-agent/agent-debugger/src/test/java/com/datadog/debugger/symbol/SymbolAggregatorTest.java index 4fb5ee83a4b..6154d4a4cef 100644 --- a/dd-java-agent/agent-debugger/src/test/java/com/datadog/debugger/symbol/SymbolAggregatorTest.java +++ b/dd-java-agent/agent-debugger/src/test/java/com/datadog/debugger/symbol/SymbolAggregatorTest.java @@ -15,9 +15,19 @@ import com.datadog.debugger.sink.SymbolSink; import com.datadog.debugger.util.ClassNameFiltering; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; import java.security.CodeSource; import java.security.ProtectionDomain; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.jar.JarFile; +import java.util.zip.ZipEntry; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledIf; import org.mockito.ArgumentCaptor; @@ -76,6 +86,45 @@ void testScanQueuedCorruptedJars() { captor.getAllValues().get(2)); } + @Test + void testScanJarClosesEntryStreams() throws Exception { + SymbolSink symbolSink = mock(SymbolSink.class); + CountingJarFile[] holder = new CountingJarFile[1]; + SymbolAggregator symbolAggregator = + new SymbolAggregator(ClassNameFiltering.allowAll(), emptyList(), symbolSink, 1) { + @Override + JarFile openJarFile(File file) throws IOException { + return holder[0] = new CountingJarFile(file); + } + }; + Path jarPath = Paths.get(getClass().getResource("/debugger-symbol.jar").toURI()); + symbolAggregator.scanJar( + SymDBReport.NO_OP, jarPath, new ByteArrayOutputStream(8192), new byte[4096]); + assertTrue(holder[0].opened.get() > 0); + assertEquals(holder[0].opened.get(), holder[0].closed.get()); + } + + private static class CountingJarFile extends JarFile { + final AtomicInteger opened = new AtomicInteger(); + final AtomicInteger closed = new AtomicInteger(); + + CountingJarFile(File file) throws IOException { + super(file); + } + + @Override + public synchronized InputStream getInputStream(ZipEntry ze) throws IOException { + opened.incrementAndGet(); + return new FilterInputStream(super.getInputStream(ze)) { + @Override + public void close() throws IOException { + closed.incrementAndGet(); + super.close(); + } + }; + } + } + @Test @DisabledIf( value = "datadog.environment.JavaVirtualMachine#isJ9",