From a05ca5ae9bfb7e64e984fac2a678c7c2263843a7 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 5 Jun 2026 09:35:59 +0200 Subject: [PATCH 1/5] IDEA-390131 Do not flush batched events immediatly if it fits memory limit after zip --- .../rt/debugger/agent/LogCaptureStorage.java | 136 ++++++++++++--- .../agent/LogCaptureEncodingTest.java | 155 +++++++++++++++++- 2 files changed, 255 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java b/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java index 3c02585..45c799d 100644 --- a/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java +++ b/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java @@ -26,22 +26,27 @@ protected Boolean initialValue() { static final String BATCHING_ENABLED_PROPERTY = "logCaptureBatchingEnabled"; static final String BATCHING_FLUSH_PERIOD_PROPERTY = "logCaptureBatchingFlushPeriod"; static final String BATCHING_MAX_EVENTS_PROPERTY = "logCaptureBatchingMaxEvents"; + static final String BATCHING_MAX_PACKED_BYTES_PROPERTY = "logCaptureBatchingMaxPackedBytes"; + private static final long DEFAULT_MAX_BATCHED_PACKED_BYTES = 5L * 1024L * 1024L; private static boolean BATCHING_ENABLED; private static int MAX_BATCHED_EVENTS_COUNT; + private static long MAX_BATCHED_PACKED_BYTES; private static boolean STDOUT_CAPTURE_ENABLED; // It's used by the debugger. static final AtomicLong EVENT_COUNTER = new AtomicLong(); - // It contains events that are waiting to be flushed. + // It contains raw events that are waiting to be packed. // New ones could be added concurrently. - // They can also be flushed concurrently, leading to sending the same events multiple times. - // It's ok and is handed by the debugger using IDs. - // Event is removed from the queue only after it's guaranteed to be received by the debugger. + // Raw or packed data can be flushed concurrently, leading to sending the same events multiple times. + // It's ok and is handled by the debugger using IDs. static final ConcurrentLinkedQueue EVENTS = new ConcurrentLinkedQueue<>(); + static final ConcurrentLinkedQueue PACKED_BATCHES = new ConcurrentLinkedQueue<>(); + static final AtomicLong PACKED_BATCHES_BYTES = new AtomicLong(); static final AtomicLong LAST_FLUSHED_EVENT_ID = new AtomicLong(-1); + static final AtomicLong LAST_PACKED_EVENT_ID = new AtomicLong(-1); static final AtomicLong LAST_LOGGING_BREAKPOINT_EVENT_ID = new AtomicLong(-1); static class Event { @@ -59,6 +64,16 @@ public Event(long id, byte type, byte[] payload) { } } + static class PackedBatch { + public final byte[] data; + public final long lastEventId; + + public PackedBatch(byte[] data, long lastEventId) { + this.data = data; + this.lastEventId = lastEventId; + } + } + private static final FileDescriptor FD_OUT = FileDescriptor.out; private static final FileDescriptor FD_ERR = FileDescriptor.err; @@ -73,6 +88,9 @@ public static boolean init(Properties properties, boolean logCaptureEnabled) { STDOUT_CAPTURE_ENABLED = logCaptureEnabled; BATCHING_ENABLED = Boolean.parseBoolean(properties.getProperty(BATCHING_ENABLED_PROPERTY, "true")); MAX_BATCHED_EVENTS_COUNT = Integer.parseInt(properties.getProperty(BATCHING_MAX_EVENTS_PROPERTY, "100")); + MAX_BATCHED_PACKED_BYTES = Long.parseLong(properties.getProperty( + BATCHING_MAX_PACKED_BYTES_PROPERTY, + String.valueOf(DEFAULT_MAX_BATCHED_PACKED_BYTES))); if (BATCHING_ENABLED && !batchingSchedulerStarted) { batchingSchedulerStarted = true; @@ -174,42 +192,72 @@ private static void handleException(Throwable e) { e.printStackTrace(System.err); } - private static void flushBatchedData() throws IOException { - flushBatchedDataIfMoreThan(0); - } - /** * It's used by the debugger via evaluation. - * This method intentionally does not clear the collected data because the return value may be collected + * This method intentionally does not drop the collected data because the return value may be collected * before it appears on the debugger side. The clearing happens in the periodic flush cycle. */ static String packBatchedData() throws IOException { - ArrayList eventsSnapshot = new ArrayList<>(EVENTS); - if (eventsSnapshot.isEmpty()) return null; - return pack(eventsSnapshot); + packRawEventsIfMoreThan(0); + List packedBatchesSnapshot = new ArrayList<>(PACKED_BATCHES); + if (packedBatchesSnapshot.isEmpty()) return null; + return packPendingData(packedBatchesSnapshot); } private static void flushBatchedDataIfMoreThan(int eventsCountLimit) throws IOException { - if (eventsCountLimit > 0) { - // This is an approximation, but eventsCountLimit is considered non-strict when it is not 0. - // The exact size is checked below. - // N.B. EVENTS.size() takes linear time, so it can be very slow. - long currentSize = EVENT_COUNTER.get() - 1 - LAST_FLUSHED_EVENT_ID.get(); - if (currentSize <= eventsCountLimit) return; + if (eventsCountLimit <= 0 || currentEventsSize() > eventsCountLimit) { + packRawEventsIfMoreThan(eventsCountLimit); + } + + flushPackedBatchesIfNeeded(false); + } + + private static long currentEventsSize() { + // This is an approximation, but eventsCountLimit is considered non-strict when it is not 0. + // The exact size is checked below. + // N.B. EVENTS.size() takes linear time, so it can be very slow. + return EVENT_COUNTER.get() - 1 - LAST_PACKED_EVENT_ID.get(); + } + + private static void flushBatchedData() throws IOException { + packRawEventsIfMoreThan(0); + flushPackedBatchesIfNeeded(true); + } + + private static void packRawEventsIfMoreThan(int eventsCountLimit) throws IOException { + if (EVENTS.isEmpty()) return; + List eventsSnapshot = new ArrayList<>(EVENTS); + if (eventsSnapshot.size() > eventsCountLimit) { + enqueuePackedBatch(eventsSnapshot); + EVENTS.removeAll(new HashSet<>(eventsSnapshot)); } - ArrayList eventsSnapshot = new ArrayList<>(EVENTS); - if (eventsSnapshot.size() <= eventsCountLimit) return; - packAndSend(eventsSnapshot); - EVENTS.removeAll(new HashSet<>(eventsSnapshot)); - long lastFlushedId = findMaxId(eventsSnapshot); - setIfGreater(LAST_FLUSHED_EVENT_ID, lastFlushedId); + } + + private static void enqueuePackedBatch(List events) throws IOException { + if (events.isEmpty()) return; + byte[] packed = packBytes(events); + long lastPackedId = findMaxId(events); + PACKED_BATCHES.add(new PackedBatch(packed, lastPackedId)); + PACKED_BATCHES_BYTES.addAndGet(packed.length); + setIfGreater(LAST_PACKED_EVENT_ID, lastPackedId); + } + + private static void flushPackedBatchesIfNeeded(boolean forceOutput) throws IOException { + if (!forceOutput && PACKED_BATCHES_BYTES.get() <= MAX_BATCHED_PACKED_BYTES) return; + packRawEventsIfMoreThan(0); + List packedBatchesSnapshot = new ArrayList<>(PACKED_BATCHES); + if (packedBatchesSnapshot.isEmpty()) return; + + outputWritten(packPendingData(packedBatchesSnapshot)); + removePackedBatches(packedBatchesSnapshot); + setIfGreater(LAST_FLUSHED_EVENT_ID, findMaxPackedEventId(packedBatchesSnapshot)); } private static void packAndSend(Collection events) throws IOException { - outputWritten(pack(events)); + outputWritten(packPendingData(Collections.singletonList(new PackedBatch(packBytes(events), -1)))); } - private static String pack(Collection events) throws IOException { + private static byte[] packBytes(Collection events) throws IOException { assert !events.isEmpty(); ByteArrayOutputStream bas = new ByteArrayOutputStream(); // no need to close it @@ -225,6 +273,18 @@ private static String pack(Collection events) throws IOException { } } // ensure to close the gzip stream before extracting compressed data. + return bas.toByteArray(); + } + + private static String packPendingData(Collection packedBatches) throws IOException { + ByteArrayOutputStream bas = new ByteArrayOutputStream(); // no need to close it + try (DataOutputStream dos = new DataOutputStream(bas)) { + dos.writeInt(packedBatches.size()); + for (PackedBatch batch : packedBatches) { + dos.writeInt(batch.data.length); + dos.write(batch.data); + } + } return bas.toString(StandardCharsets.ISO_8859_1.name()); } @@ -258,7 +318,7 @@ public static void loggingBreakpointHit(int instrumentationId, String message) { } } - private static long findMaxId(ArrayList events) { + private static long findMaxId(List events) { long lastFlushedId = -1; for (int i = events.size() - 1; i >= 0; i--) { long id = events.get(i).id; @@ -269,6 +329,28 @@ private static long findMaxId(ArrayList events) { return lastFlushedId; } + private static long findMaxPackedEventId(Collection packedBatches) { + long result = -1; + for (PackedBatch batch : packedBatches) { + if (batch.lastEventId > result) { + result = batch.lastEventId; + } + } + return result; + } + + private static void removePackedBatches(Collection packedBatches) { + long removedBytes = 0; + for (PackedBatch batch : packedBatches) { + if (PACKED_BATCHES.remove(batch)) { + removedBytes += batch.data.length; + } + } + if (removedBytes > 0) { + PACKED_BATCHES_BYTES.addAndGet(-removedBytes); + } + } + private static void setIfGreater(AtomicLong maxValue, long newValue) { while (true) { long current = maxValue.get(); diff --git a/src/test/java/com/intellij/rt/debugger/agent/LogCaptureEncodingTest.java b/src/test/java/com/intellij/rt/debugger/agent/LogCaptureEncodingTest.java index af0449a..78ef82f 100644 --- a/src/test/java/com/intellij/rt/debugger/agent/LogCaptureEncodingTest.java +++ b/src/test/java/com/intellij/rt/debugger/agent/LogCaptureEncodingTest.java @@ -16,6 +16,8 @@ import static org.junit.Assert.*; public class LogCaptureEncodingTest { + private static final String LARGE_PACKED_BYTE_LIMIT = String.valueOf(5 * 1024 * 1024); + private final Properties properties = new Properties(); @Before @@ -27,6 +29,7 @@ public void setUp() { resetLogCaptureStorage(); properties.put(LogCaptureStorage.BATCHING_ENABLED_PROPERTY, "true"); properties.put(LogCaptureStorage.BATCHING_FLUSH_PERIOD_PROPERTY, "999999999"); // never + properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, "0"); LogCaptureStorage.outputWrittenDumpForTests = new ArrayList<>(); } @@ -134,7 +137,7 @@ public void packBatchedDataReturnsNullWhenNoEventsPending() throws Exception { } @Test - public void packBatchedDataEncodesEventsWithoutDrainingThem() throws Exception { + public void packBatchedDataEncodesEventsWithoutSendingThem() throws Exception { properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "100"); LogCaptureStorage.init(properties, true); @@ -148,23 +151,23 @@ public void packBatchedDataEncodesEventsWithoutDrainingThem() throws Exception { assertNotNull(packed); // The packed string decodes to the events we added, in id order. - try (DataInputStream is = openPacked(packed)) { + try (DataInputStream is = openPackedBatch(packed)) { assertEquals(2, is.readInt()); readAndCheckLoggingBreakpointEvent(0, 77, "first log", is); readAndCheckLoggingBreakpointEvent(1, 88, "second log", is); } - // EVENTS is not drained. - assertEquals(2, LogCaptureStorage.EVENTS.size()); + assertEquals("raw events are packed for future debugger reads", 0, LogCaptureStorage.EVENTS.size()); + assertEquals("packed data stays queued", 1, LogCaptureStorage.PACKED_BATCHES.size()); assertEquals(-1, LogCaptureStorage.LAST_FLUSHED_EVENT_ID.get()); // outputWritten() was not invoked — pack only returns, never sends. assertEquals(0, LogCaptureStorage.outputWrittenDumpForTests.size()); - // Calling pack again yields the same encoded content (no internal state changed). + // Calling pack again yields the same encoded content. String packed2 = LogCaptureStorage.packBatchedData(); assertNotNull(packed2); - try (DataInputStream is = openPacked(packed2)) { + try (DataInputStream is = openPackedBatch(packed2)) { assertEquals(2, is.readInt()); readAndCheckLoggingBreakpointEvent(0, 77, "first log", is); readAndCheckLoggingBreakpointEvent(1, 88, "second log", is); @@ -181,12 +184,143 @@ public void packBatchedDataEncodesEventsWithoutDrainingThem() throws Exception { } } - static DataInputStream openPacked(String packed) throws IOException { - return new DataInputStream(new GZIPInputStream(new ByteArrayInputStream(packed.getBytes(StandardCharsets.ISO_8859_1)))); + @Test + public void exceededRawEventLimitPacksEventsWithoutCallingOutputWrittenBelowPackedMemoryLimit() throws Exception { + properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "1"); // 1 is ok, 2 is a signal to pack + properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, LARGE_PACKED_BYTE_LIMIT); + LogCaptureStorage.init(properties, true); + + capture(FileDescriptor.out, "first stdout\n"); + capture(FileDescriptor.err, "second stdout\n"); + + assertEquals("packed data stays in memory below the packed-byte limit", 0, LogCaptureStorage.outputWrittenDumpForTests.size()); + assertEquals("raw events are drained after packing", 0, LogCaptureStorage.EVENTS.size()); + assertEquals(1, LogCaptureStorage.PACKED_BATCHES.size()); + assertTrue(LogCaptureStorage.PACKED_BATCHES_BYTES.get() > 0); + assertEquals("not sent to debugger yet", -1, LogCaptureStorage.LAST_FLUSHED_EVENT_ID.get()); + + String packed = LogCaptureStorage.packBatchedData(); + assertNotNull(packed); + try (DataInputStream is = openPackedBatch(packed)) { + assertEquals(2, is.readInt()); + readAndCheckStdoutEvent(0, false, "first stdout\n", is); + readAndCheckStdoutEvent(1, true, "second stdout\n", is); + } + + assertEquals("packBatchedData does not drain packed batches", 1, LogCaptureStorage.PACKED_BATCHES.size()); + assertEquals(0, LogCaptureStorage.outputWrittenDumpForTests.size()); + } + + @Test + public void packBatchedDataPacksPendingRawEventsBeforeReturning() throws Exception { + properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "1"); // 1 is ok, 2 is a signal to pack + properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, LARGE_PACKED_BYTE_LIMIT); + LogCaptureStorage.init(properties, true); + + capture(FileDescriptor.out, "first stdout\n"); + capture(FileDescriptor.err, "second stdout\n"); + assertEquals(1, LogCaptureStorage.PACKED_BATCHES.size()); + assertEquals(0, LogCaptureStorage.EVENTS.size()); + + capture(FileDescriptor.out, "third stdout\n"); + assertEquals(1, LogCaptureStorage.PACKED_BATCHES.size()); + assertEquals(1, LogCaptureStorage.EVENTS.size()); + + String packed = LogCaptureStorage.packBatchedData(); + assertNotNull(packed); + try (DataInputStream batches = openPackedBatches(packed)) { + assertEquals(2, batches.readInt()); + try (DataInputStream is = openNextPackedBatch(batches); + DataInputStream secondBatch = openNextPackedBatch(batches)) { + assertEquals(2, is.readInt()); + readAndCheckStdoutEvent(0, false, "first stdout\n", is); + readAndCheckStdoutEvent(1, true, "second stdout\n", is); + + assertEquals(1, secondBatch.readInt()); + readAndCheckStdoutEvent(2, false, "third stdout\n", secondBatch); + } + } + + assertEquals(0, LogCaptureStorage.EVENTS.size()); + assertEquals(2, LogCaptureStorage.PACKED_BATCHES.size()); + } + + @Test + public void packedBatchOverflowFlushesPendingRawEventsToo() throws Exception { + properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "1"); // 1 is ok, 2 is a signal to pack + properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, LARGE_PACKED_BYTE_LIMIT); + LogCaptureStorage.init(properties, true); + + capture(FileDescriptor.out, "first stdout\n"); + capture(FileDescriptor.err, "second stdout\n"); + assertEquals(1, LogCaptureStorage.PACKED_BATCHES.size()); + assertEquals(0, LogCaptureStorage.outputWrittenDumpForTests.size()); + + properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, "1"); + LogCaptureStorage.init(properties, true); + capture(FileDescriptor.out, "third stdout\n"); + + assertEquals(1, LogCaptureStorage.outputWrittenDumpForTests.size()); + assertEquals(0, LogCaptureStorage.EVENTS.size()); + assertEquals(0, LogCaptureStorage.PACKED_BATCHES.size()); + try (DataInputStream batches = openDumpBatches(0)) { + assertEquals(2, batches.readInt()); + try (DataInputStream is = openNextPackedBatch(batches); + DataInputStream secondBatch = openNextPackedBatch(batches)) { + assertEquals(2, is.readInt()); + readAndCheckStdoutEvent(0, false, "first stdout\n", is); + readAndCheckStdoutEvent(1, true, "second stdout\n", is); + + assertEquals(1, secondBatch.readInt()); + readAndCheckStdoutEvent(2, false, "third stdout\n", secondBatch); + } + } + } + + @Test + public void packBatchedDataDeclaresNumberOfPackedBatches() throws Exception { + LogCaptureStorage.init(properties, true); + + capture(FileDescriptor.out, "first batch\n"); + assertNotNull(LogCaptureStorage.packBatchedData()); + capture(FileDescriptor.err, "second batch\n"); + + String packed = LogCaptureStorage.packBatchedData(); + assertNotNull(packed); + try (DataInputStream batches = openPackedBatches(packed)) { + assertEquals(2, batches.readInt()); + try (DataInputStream firstBatch = openNextPackedBatch(batches); + DataInputStream secondBatch = openNextPackedBatch(batches)) { + assertEquals(1, firstBatch.readInt()); + readAndCheckStdoutEvent(0, false, "first batch\n", firstBatch); + + assertEquals(1, secondBatch.readInt()); + readAndCheckStdoutEvent(1, true, "second batch\n", secondBatch); + } + } + } + + static DataInputStream openPackedBatch(String packed) throws IOException { + try (DataInputStream batches = openPackedBatches(packed)) { + assertEquals(1, batches.readInt()); + return openNextPackedBatch(batches); + } + } + + static DataInputStream openPackedBatches(String packed) { + return new DataInputStream(new ByteArrayInputStream(packed.getBytes(StandardCharsets.ISO_8859_1))); + } + + static DataInputStream openNextPackedBatch(DataInputStream batches) throws IOException { + return new DataInputStream(new GZIPInputStream(new ByteArrayInputStream(readBytesWithSize(batches)))); } static DataInputStream openDump(int index) throws IOException { - return openPacked(LogCaptureStorage.outputWrittenDumpForTests.get(index)); + return openPackedBatch(LogCaptureStorage.outputWrittenDumpForTests.get(index)); + } + + static DataInputStream openDumpBatches(int index) { + return openPackedBatches(LogCaptureStorage.outputWrittenDumpForTests.get(index)); } static List readAndCheckStdoutEvent(int expectedId, @@ -260,8 +394,11 @@ private static void capture(FileDescriptor fd, String text) { static void resetLogCaptureStorage() { LogCaptureStorage.EVENT_COUNTER.set(0); LogCaptureStorage.LAST_FLUSHED_EVENT_ID.set(-1); + LogCaptureStorage.LAST_PACKED_EVENT_ID.set(-1); LogCaptureStorage.LAST_LOGGING_BREAKPOINT_EVENT_ID.set(-1); LogCaptureStorage.EVENTS.clear(); + LogCaptureStorage.PACKED_BATCHES.clear(); + LogCaptureStorage.PACKED_BATCHES_BYTES.set(0); LogCaptureStorage.outputWrittenDumpForTests = null; } } From b8e7b03a3cdc14f77e8899d4071c4e9fe7fffae0 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 5 Jun 2026 15:17:21 +0200 Subject: [PATCH 2/5] IDEA-390131 Do not unpack Throwables until flushing --- .../rt/debugger/agent/CaptureStorage.java | 20 +- .../rt/debugger/agent/LogCaptureStorage.java | 35 ++-- .../agent/PackedBatchCapacityOverhead.java | 49 +++++ .../agent/ThrowableCapacityOverhead.java | 179 ++++++++++++++++++ 4 files changed, 258 insertions(+), 25 deletions(-) create mode 100644 src/test/java/com/intellij/rt/debugger/agent/PackedBatchCapacityOverhead.java create mode 100644 src/test/java/com/intellij/rt/debugger/agent/ThrowableCapacityOverhead.java diff --git a/src/main/java/com/intellij/rt/debugger/agent/CaptureStorage.java b/src/main/java/com/intellij/rt/debugger/agent/CaptureStorage.java index 9496d7b..7cc1bc4 100644 --- a/src/main/java/com/intellij/rt/debugger/agent/CaptureStorage.java +++ b/src/main/java/com/intellij/rt/debugger/agent/CaptureStorage.java @@ -69,6 +69,10 @@ private static Deque getStacksForCurrentThread() { } } + static CapturedStack getCurrentCapturedStack() { + return getStacksForCurrentThread().peekLast(); + } + @SuppressWarnings("StaticNonFinalField") public static boolean DEBUG; // set from debugger private static boolean ENABLED = true; // set from debugger @@ -406,7 +410,7 @@ private StackData(List stackTrace, CapturedStack previous) { } } - private static abstract class CapturedStack { + static abstract class CapturedStack { abstract List getStackTrace(); int getRecursionDepth() { @@ -488,13 +492,13 @@ StackData collectStacks(List stackTrace) { } } - /** - * Returns the captured stack trace of the current thread. - */ - static List getCurrentCapturedStack(int limit) { - CapturedStack stack = getStacksForCurrentThread().peekLast(); - if (stack == null) return null; - return getStackTrace(stack, limit); + static void writeCapturedStackToStream(Throwable throwable, CapturedStack capturedStack, int limit, DataOutputStream dos) throws IOException { + List regularStack = trimInitAgentFrames(Arrays.asList(throwable.getStackTrace())); + writeAsyncStackTraceToStream(regularStack, dos); + if (capturedStack != null) { + writeAsyncStackTraceElementToStream(ASYNC_STACK_ELEMENT, dos); + writeAsyncStackTraceToStream(getStackTrace(capturedStack, limit - regularStack.size()), dos); + } } // to be run from the debugger diff --git a/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java b/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java index 45c799d..4241d27 100644 --- a/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java +++ b/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java @@ -56,11 +56,15 @@ static class Event { public final long id; public final byte type; public final byte[] payload; + public final Throwable throwable; + public final CaptureStorage.CapturedStack stack; - public Event(long id, byte type, byte[] payload) { + public Event(long id, byte type, byte[] payload, Throwable throwable, CaptureStorage.CapturedStack stack) { this.id = id; this.type = type; this.payload = payload; + this.throwable = throwable; + this.stack = stack; } } @@ -87,7 +91,7 @@ public static boolean init(Properties properties, boolean logCaptureEnabled) { ENABLED = true; STDOUT_CAPTURE_ENABLED = logCaptureEnabled; BATCHING_ENABLED = Boolean.parseBoolean(properties.getProperty(BATCHING_ENABLED_PROPERTY, "true")); - MAX_BATCHED_EVENTS_COUNT = Integer.parseInt(properties.getProperty(BATCHING_MAX_EVENTS_PROPERTY, "100")); + MAX_BATCHED_EVENTS_COUNT = Integer.parseInt(properties.getProperty(BATCHING_MAX_EVENTS_PROPERTY, "1000")); MAX_BATCHED_PACKED_BYTES = Long.parseLong(properties.getProperty( BATCHING_MAX_PACKED_BYTES_PROPERTY, String.valueOf(DEFAULT_MAX_BATCHED_PACKED_BYTES))); @@ -152,10 +156,9 @@ public static void capture(FileDescriptor fd, byte[] bytes, int off, int len) { dos.writeInt(len); dos.write(bytes, off, len); dos.writeBoolean(isErr); - writeCurrentStacks(dos); } byte[] payload = bas.toByteArray(); - captureEvent(new Event(id, Event.STD_OUTPUT_TYPE, payload)); + captureEvent(new Event(id, Event.STD_OUTPUT_TYPE, payload, new Throwable(), CaptureStorage.getCurrentCapturedStack())); } catch (Throwable e) { handleException(e); } finally { @@ -176,16 +179,6 @@ private static void captureEvent(Event event) throws IOException { } } - private static void writeCurrentStacks(DataOutputStream dos) throws IOException { - List regularStack = CaptureStorage.getCurrentStackTraceWithoutAgentFrames(); - List capturedStack = CaptureStorage.getCurrentCapturedStack(MAX_STACK_DEPTH - regularStack.size()); - CaptureStorage.writeAsyncStackTraceToStream(regularStack, dos); - if (capturedStack != null) { - CaptureStorage.writeAsyncStackTraceElementToStream(CaptureStorage.ASYNC_STACK_ELEMENT, dos); - CaptureStorage.writeAsyncStackTraceToStream(capturedStack, dos); - } - } - private static void handleException(Throwable e) { ENABLED = false; System.err.println("Debugger agent, log capture: cannot capture logging"); @@ -267,7 +260,7 @@ private static byte[] packBytes(Collection events) throws IOException { for (Event event : events) { dos.writeLong(event.id); dos.writeByte(event.type); - byte[] bytes = event.payload; + byte[] bytes = packEventPayload(event); dos.writeInt(bytes.length); dos.write(bytes); } @@ -276,6 +269,15 @@ private static byte[] packBytes(Collection events) throws IOException { return bas.toByteArray(); } + private static byte[] packEventPayload(Event event) throws IOException { + ByteArrayOutputStream bas = new ByteArrayOutputStream(); // no need to close it + try (DataOutputStream dos = new DataOutputStream(bas)) { + dos.write(event.payload); + CaptureStorage.writeCapturedStackToStream(event.throwable, event.stack, MAX_STACK_DEPTH, dos); + } + return bas.toByteArray(); + } + private static String packPendingData(Collection packedBatches) throws IOException { ByteArrayOutputStream bas = new ByteArrayOutputStream(); // no need to close it try (DataOutputStream dos = new DataOutputStream(bas)) { @@ -307,10 +309,9 @@ public static void loggingBreakpointHit(int instrumentationId, String message) { dos.writeInt(instrumentationId); dos.writeInt(messageBytes.length); dos.write(messageBytes); - writeCurrentStacks(dos); } byte[] payload = bas.toByteArray(); - captureEvent(new Event(id, Event.LOGGING_BREAKPOINT_TYPE, payload)); + captureEvent(new Event(id, Event.LOGGING_BREAKPOINT_TYPE, payload, new Throwable(), CaptureStorage.getCurrentCapturedStack())); } catch (Throwable e) { handleException(e); } finally { diff --git a/src/test/java/com/intellij/rt/debugger/agent/PackedBatchCapacityOverhead.java b/src/test/java/com/intellij/rt/debugger/agent/PackedBatchCapacityOverhead.java new file mode 100644 index 0000000..aca4419 --- /dev/null +++ b/src/test/java/com/intellij/rt/debugger/agent/PackedBatchCapacityOverhead.java @@ -0,0 +1,49 @@ +package com.intellij.rt.debugger.agent; + +import java.io.FileDescriptor; +import java.nio.charset.StandardCharsets; +import java.util.Properties; + +//Packed batches: 99 +//Packed bytes: 6481311 +//Packed bytes per batch: 65468 +//Packed batches fitting 5 MiB storage limit: 80 +//Capture + pack time: 40185 ns +public class PackedBatchCapacityOverhead { + private static final long PACKED_BYTES_LIMIT = 5L * 1024L * 1024L; + + private static long recursiveFunc(int depth, int repeats, byte[] message) { + if (depth > 0) return recursiveFunc(depth - 1, repeats, message); + + long startNs = System.nanoTime(); + for (int i = 0; i < repeats; i++) { + LogCaptureStorage.capture(FileDescriptor.out, message); + } + return System.nanoTime() - startNs; + } + + public static void main(String[] args) throws Exception { + int repeats = 100_000; + int stackDepth = 100; + byte[] message = "stdout message\n".getBytes(StandardCharsets.UTF_8); + + Properties properties = new Properties(); + properties.put(LogCaptureStorage.BATCHING_ENABLED_PROPERTY, "true"); + properties.put(LogCaptureStorage.BATCHING_FLUSH_PERIOD_PROPERTY, "999999999"); + properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "1000"); + properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, String.valueOf(Long.MAX_VALUE)); + LogCaptureStorage.init(properties, true); + + long totalNs = recursiveFunc(stackDepth, repeats, message); + long packedBatches = LogCaptureStorage.PACKED_BATCHES.size(); + long packedBytes = LogCaptureStorage.PACKED_BATCHES_BYTES.get(); + double packedBytesPerBatch = (double) packedBytes / packedBatches; + double captureTimeNs = (double) totalNs / repeats; + + System.out.println("Packed batches: " + packedBatches); + System.out.println("Packed bytes: " + packedBytes); + System.out.println("Packed bytes per batch: " + Math.round(packedBytesPerBatch)); + System.out.println("Packed batches fitting 5 MiB storage limit: " + (long) (PACKED_BYTES_LIMIT / packedBytesPerBatch)); + System.out.println("Capture + pack time: " + Math.round(captureTimeNs) + " ns"); + } +} diff --git a/src/test/java/com/intellij/rt/debugger/agent/ThrowableCapacityOverhead.java b/src/test/java/com/intellij/rt/debugger/agent/ThrowableCapacityOverhead.java new file mode 100644 index 0000000..6260ce8 --- /dev/null +++ b/src/test/java/com/intellij/rt/debugger/agent/ThrowableCapacityOverhead.java @@ -0,0 +1,179 @@ +package com.intellij.rt.debugger.agent; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.FileDescriptor; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Properties; + +//Throwable capacity before getStackTrace() +//Bytes per Throwable: 2809 +//Throwables fitting 5 MiB: 1866 +// +//Throwable capacity after getStackTrace() +//Bytes per Throwable: 8225 +//Throwables fitting 5 MiB: 637 +// +//Captured stack bytes capacity +//Bytes per stack: 9142 +//Stacks fitting 5 MiB: 573 +// +//Packed batch capacity +//Packed bytes per event: 65 +//Events fitting 5 MiB: 80896 +public class ThrowableCapacityOverhead { + private static final long BYTES_LIMIT = 5L * 1024L * 1024L; + private static final int MAX_STACK_DEPTH = 100; + + private static volatile Object retained; + private static volatile int checksumSink; + + private static Throwable recursiveThrowable(int depth) { + if (depth > 0) return recursiveThrowable(depth - 1); + return new Throwable(); + } + + private static byte[] recursiveCurrentStacks(int depth) throws IOException { + if (depth > 0) return recursiveCurrentStacks(depth - 1); + return writeCapturedStack(); + } + + private static void recursiveCapture(int depth, int repeats, byte[] message) { + if (depth > 0) { + recursiveCapture(depth - 1, repeats, message); + return; + } + for (int i = 0; i < repeats; i++) { + LogCaptureStorage.capture(FileDescriptor.out, message); + } + } + + private static int fillThrowables(Throwable[] throwables, int stackDepth, boolean materializeStackTrace) { + int agg = 0; + for (int i = 0; i < throwables.length; i++) { + Throwable throwable = recursiveThrowable(stackDepth); + if (materializeStackTrace) { + agg ^= throwable.getStackTrace().length; + } + else { + agg ^= System.identityHashCode(throwable); + } + throwables[i] = throwable; + } + return agg; + } + + private static int fillCurrentStacks(byte[][] currentStacks, int stackDepth) throws IOException { + int agg = 0; + for (int i = 0; i < currentStacks.length; i++) { + byte[] stackBytes = recursiveCurrentStacks(stackDepth); + agg ^= stackBytes.length; + currentStacks[i] = stackBytes; + } + return agg; + } + + private static byte[] writeCapturedStack() throws IOException { + ByteArrayOutputStream bas = new ByteArrayOutputStream(); + try (DataOutputStream dos = new DataOutputStream(bas)) { + CaptureStorage.writeCapturedStackToStream(new Throwable(), CaptureStorage.getCurrentCapturedStack(), MAX_STACK_DEPTH, dos); + } + return bas.toByteArray(); + } + + private static long usedMemory() { + Runtime runtime = Runtime.getRuntime(); + return runtime.totalMemory() - runtime.freeMemory(); + } + + private static void forceGc() throws InterruptedException { + System.gc(); + Thread.sleep(100); + System.gc(); + Thread.sleep(100); + } + + private static void measure(String title, int stackDepth, int repeats, boolean materializeStackTrace) throws Exception { + retained = null; + forceGc(); + long usedBefore = usedMemory(); + + Throwable[] throwables = new Throwable[repeats]; + retained = throwables; + + checksumSink = fillThrowables(throwables, stackDepth, materializeStackTrace); + forceGc(); + long usedAfter = usedMemory(); + long usedBytes = usedAfter - usedBefore; + double bytesPerThrowable = (double) usedBytes / repeats; + + System.out.println(title); + System.out.println("Bytes per Throwable: " + Math.round(bytesPerThrowable)); + System.out.println("Throwables fitting 5 MiB: " + (long) (BYTES_LIMIT / bytesPerThrowable)); + } + + private static void measureWithCurrentStacks(int stackDepth, int repeats) throws Exception { + retained = null; + forceGc(); + long usedBefore = usedMemory(); + + byte[][] currentStacks = new byte[repeats][]; + retained = currentStacks; + + checksumSink = fillCurrentStacks(currentStacks, stackDepth); + forceGc(); + long usedAfter = usedMemory(); + long usedBytes = usedAfter - usedBefore; + double bytesPerThrowable = (double) usedBytes / repeats; + + System.out.println("Captured stack bytes capacity"); + System.out.println("Bytes per stack: " + Math.round(bytesPerThrowable)); + System.out.println("Stacks fitting 5 MiB: " + (long) (BYTES_LIMIT / bytesPerThrowable)); + } + + private static void measureAfterBatchZipping(int stackDepth, int repeats) throws Exception { + resetLogCaptureStorage(); + + Properties properties = new Properties(); + properties.put(LogCaptureStorage.BATCHING_ENABLED_PROPERTY, "true"); + properties.put(LogCaptureStorage.BATCHING_FLUSH_PERIOD_PROPERTY, "999999999"); + properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "1000"); + properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, String.valueOf(Long.MAX_VALUE)); + LogCaptureStorage.init(properties, true); + + recursiveCapture(stackDepth, repeats, "stdout message\n".getBytes(StandardCharsets.UTF_8)); + LogCaptureStorage.packBatchedData(); + + double packedBytesPerEvent = (double) LogCaptureStorage.PACKED_BATCHES_BYTES.get() / repeats; + + System.out.println("Packed batch capacity"); + System.out.println("Packed bytes per event: " + Math.round(packedBytesPerEvent)); + System.out.println("Events fitting 5 MiB: " + (long) (BYTES_LIMIT / packedBytesPerEvent)); + } + + private static void resetLogCaptureStorage() { + LogCaptureStorage.EVENT_COUNTER.set(0); + LogCaptureStorage.LAST_FLUSHED_EVENT_ID.set(-1); + LogCaptureStorage.LAST_PACKED_EVENT_ID.set(-1); + LogCaptureStorage.LAST_LOGGING_BREAKPOINT_EVENT_ID.set(-1); + LogCaptureStorage.EVENTS.clear(); + LogCaptureStorage.PACKED_BATCHES.clear(); + LogCaptureStorage.PACKED_BATCHES_BYTES.set(0); + LogCaptureStorage.outputWrittenDumpForTests = null; + } + + public static void main(String[] args) throws Exception { + int repeats = args.length > 0 ? Integer.parseInt(args[0]) : 100_000; + int stackDepth = args.length > 1 ? Integer.parseInt(args[1]) : 100; + + measure("Throwable capacity before getStackTrace()", stackDepth, repeats, false); + System.out.println(); + measure("Throwable capacity after getStackTrace()", stackDepth, repeats, true); + System.out.println(); + measureWithCurrentStacks(stackDepth, repeats); + System.out.println(); + measureAfterBatchZipping(stackDepth, repeats); + } + +} From 88e9926fb42f40d8806d869dfc8c5882b18f9b42 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Wed, 3 Jun 2026 20:34:42 +0200 Subject: [PATCH 3/5] IDEA-390131 Add ThrowableInterner for efficient throwable deduplication in debugger agent --- build.gradle | 6 + .../rt/debugger/agent/CaptureStorage.java | 5 + .../rt/debugger/agent/LogCaptureStorage.java | 4 +- .../rt/debugger/agent/ThrowableInterner.java | 147 +++++++++++++++++ .../debugger/agent/ThrowableTransformer.java | 28 +++- .../debugger/agent/ThrowableInternerTest.java | 155 ++++++++++++++++++ 6 files changed, 342 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/intellij/rt/debugger/agent/ThrowableInterner.java create mode 100644 src/test/java/com/intellij/rt/debugger/agent/ThrowableInternerTest.java diff --git a/build.gradle b/build.gradle index f11fa97..df52d9a 100644 --- a/build.gradle +++ b/build.gradle @@ -18,6 +18,12 @@ dependencies { testImplementation("org.openjdk.jmh:jmh-core:1.37") } +test { + if (JavaVersion.current().isJava9Compatible()) { + jvmArgs "--add-opens=java.base/java.lang=ALL-UNNAMED" + } +} + task mainJar(type: Jar) { from configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } with jar diff --git a/src/main/java/com/intellij/rt/debugger/agent/CaptureStorage.java b/src/main/java/com/intellij/rt/debugger/agent/CaptureStorage.java index 7cc1bc4..d582d3b 100644 --- a/src/main/java/com/intellij/rt/debugger/agent/CaptureStorage.java +++ b/src/main/java/com/intellij/rt/debugger/agent/CaptureStorage.java @@ -124,6 +124,11 @@ public void run() { }); } + @SuppressWarnings("unused") + public static void captureThrowableBacktrace(Object backtrace) { + ThrowableInterner.captureBacktrace(backtrace); + } + @SuppressWarnings("unused") public static void captureThrowable(final Throwable throwable) { final ThreadLocalContext context = CURRENT_CONTEXT.get(); diff --git a/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java b/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java index 4241d27..6a7042d 100644 --- a/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java +++ b/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java @@ -158,7 +158,7 @@ public static void capture(FileDescriptor fd, byte[] bytes, int off, int len) { dos.writeBoolean(isErr); } byte[] payload = bas.toByteArray(); - captureEvent(new Event(id, Event.STD_OUTPUT_TYPE, payload, new Throwable(), CaptureStorage.getCurrentCapturedStack())); + captureEvent(new Event(id, Event.STD_OUTPUT_TYPE, payload, ThrowableInterner.createThrowable(), CaptureStorage.getCurrentCapturedStack())); } catch (Throwable e) { handleException(e); } finally { @@ -311,7 +311,7 @@ public static void loggingBreakpointHit(int instrumentationId, String message) { dos.write(messageBytes); } byte[] payload = bas.toByteArray(); - captureEvent(new Event(id, Event.LOGGING_BREAKPOINT_TYPE, payload, new Throwable(), CaptureStorage.getCurrentCapturedStack())); + captureEvent(new Event(id, Event.LOGGING_BREAKPOINT_TYPE, payload, ThrowableInterner.createThrowable(), CaptureStorage.getCurrentCapturedStack())); } catch (Throwable e) { handleException(e); } finally { diff --git a/src/main/java/com/intellij/rt/debugger/agent/ThrowableInterner.java b/src/main/java/com/intellij/rt/debugger/agent/ThrowableInterner.java new file mode 100644 index 0000000..5ac9f33 --- /dev/null +++ b/src/main/java/com/intellij/rt/debugger/agent/ThrowableInterner.java @@ -0,0 +1,147 @@ +package com.intellij.rt.debugger.agent; + +import java.util.Arrays; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Interns throwables using the raw VM backtrace captured by {@link ThrowableTransformer} + * without calling {@link Throwable#getStackTrace()}. + *

+ * This is a small debugger-agent copy of the approach from + * + * com.intellij.openapi.util.objectTree.ThrowableInterner. + */ +final class ThrowableInterner { + private static final ThreadLocal CAPTURE_BACKTRACE = new ThreadLocal<>(); + private static final ThreadLocal CAPTURED_BACKTRACE = new ThreadLocal<>(); + private static final ConcurrentMap INTERNED_THROWABLES = new ConcurrentHashMap<>(); + private static volatile boolean ourEnabled = true; + + private ThrowableInterner() { + } + + static Throwable createThrowable() { + if (!ourEnabled) return new Throwable(); + CAPTURE_BACKTRACE.set(Boolean.TRUE); + try { + Throwable throwable = new Throwable(); + return intern(throwable, CAPTURED_BACKTRACE.get()); + } finally { + CAPTURE_BACKTRACE.remove(); + CAPTURED_BACKTRACE.remove(); + } + } + + static void captureBacktrace(Object backtrace) { + if (!ourEnabled) return; + try { + Boolean isInsideCreateThrowable = CAPTURE_BACKTRACE.get(); + if (isInsideCreateThrowable == Boolean.TRUE) { + CAPTURED_BACKTRACE.set(backtrace); + } + } catch (Throwable t) { + disable("Debugger agent, throwable interner: cannot capture throwable backtrace", t); + } + } + + static Throwable intern(Throwable throwable, Object backtrace) { + if (!ourEnabled) return throwable; + if (backtrace == null) return throwable; + // Log capture only interns plain new Throwable(); richer throwables are rare here and + // would make equality slower because message/cause have to be compared recursively. + if (throwable.getClass() != Throwable.class) return throwable; + if (throwable.getMessage() != null || throwable.getCause() != null) return throwable; + // HotSpot stores the raw backtrace as Object[]; OpenJ9 stores walkback PCs as long[] or int[]. + if (!isSupportedBacktrace(backtrace)) { + String backtraceType = backtrace.getClass().getName(); + disable("Debugger agent, throwable interner: unsupported throwable backtrace type " + backtraceType); + return throwable; + } + + // The raw VM backtrace is captured by new Throwable(), unlike StackTraceElement[] which is + // created lazily and relatively expensively by getStackTrace(). + BacktraceKey key = new BacktraceKey(throwable, backtrace); + Throwable interned = INTERNED_THROWABLES.putIfAbsent(key, throwable); + return interned == null ? throwable : interned; + } + + static void clear() { + INTERNED_THROWABLES.clear(); + } + + static int size() { + return INTERNED_THROWABLES.size(); + } + + static void disable(String message) { + disable(message, null); + } + + private static final class BacktraceKey { + private final Class myThrowableClass; + private final Object myBacktrace; + private final int myHashCode; + + private BacktraceKey(Throwable throwable, Object backtrace) { + myThrowableClass = throwable.getClass(); + myBacktrace = backtrace; + myHashCode = computeHashCode(myThrowableClass, backtrace); + } + + private static int computeHashCode(Class throwableClass, Object backtrace) { + int result = throwableClass.hashCode(); + result = 31 * result + backtraceHashCode(backtrace); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof BacktraceKey)) return false; + + BacktraceKey key = (BacktraceKey) obj; + return myHashCode == key.myHashCode && + myThrowableClass == key.myThrowableClass && + backtracesEqual(myBacktrace, key.myBacktrace); + } + + @Override + public int hashCode() { + return myHashCode; + } + } + + private static boolean isSupportedBacktrace(Object backtrace) { + return backtrace instanceof Object[] || backtrace instanceof long[] || backtrace instanceof int[]; + } + + private static synchronized void disable(String message, Throwable cause) { + if (!ourEnabled) return; + ourEnabled = false; + System.err.println(message); + if (cause != null) { + cause.printStackTrace(System.err); + } + } + + private static int backtraceHashCode(Object backtrace) { + if (backtrace instanceof Object[]) return Arrays.deepHashCode((Object[]) backtrace); + if (backtrace instanceof long[]) return Arrays.hashCode((long[]) backtrace); + if (backtrace instanceof int[]) return Arrays.hashCode((int[]) backtrace); + return 0; + } + + private static boolean backtracesEqual(Object first, Object second) { + if (first instanceof Object[] && second instanceof Object[]) { + return Arrays.deepEquals((Object[]) first, (Object[]) second); + } + if (first instanceof long[] && second instanceof long[]) { + return Arrays.equals((long[]) first, (long[]) second); + } + if (first instanceof int[] && second instanceof int[]) { + return Arrays.equals((int[]) first, (int[]) second); + } + return false; + } +} diff --git a/src/main/java/com/intellij/rt/debugger/agent/ThrowableTransformer.java b/src/main/java/com/intellij/rt/debugger/agent/ThrowableTransformer.java index 5c84f38..c651e64 100644 --- a/src/main/java/com/intellij/rt/debugger/agent/ThrowableTransformer.java +++ b/src/main/java/com/intellij/rt/debugger/agent/ThrowableTransformer.java @@ -1,6 +1,7 @@ package com.intellij.rt.debugger.agent; import org.jetbrains.capture.org.objectweb.asm.ClassVisitor; +import org.jetbrains.capture.org.objectweb.asm.FieldVisitor; import org.jetbrains.capture.org.objectweb.asm.ClassWriter; import org.jetbrains.capture.org.objectweb.asm.MethodVisitor; import org.jetbrains.capture.org.objectweb.asm.Opcodes; @@ -23,16 +24,36 @@ public byte[] transform(ClassLoader loader, ClassTransformer transformer = new ClassTransformer(className, classfileBuffer, ClassWriter.COMPUTE_FRAMES, loader); return transformer.accept(new ClassVisitor(Opcodes.API_VERSION, transformer.writer) { + private String myBacktraceFieldName; + private String myBacktraceFieldDescriptor; + + @Override + public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) { + if (isBacktraceField(name, descriptor)) { + myBacktraceFieldName = name; + myBacktraceFieldDescriptor = descriptor; + } + return super.visitField(access, name, descriptor, signature, value); + } + @Override public MethodVisitor visitMethod(final int access, String name, String descriptor, String signature, String[] exceptions) { MethodVisitor superMethodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions); switch (name) { case "": - // Insert call of CaptureStorage.captureThrowable(this) in the end of constructors. + // Insert CaptureStorage calls in the end of constructors. return new MethodVisitor(api, superMethodVisitor) { @Override public void visitInsn(int opcode) { if (opcode == Opcodes.RETURN) { + if (myBacktraceFieldName != null) { + mv.visitVarInsn(Opcodes.ALOAD, 0); + mv.visitFieldInsn(Opcodes.GETFIELD, THROWABLE_NAME, myBacktraceFieldName, myBacktraceFieldDescriptor); + CaptureAgent.invokeStorageMethod(mv, "captureThrowableBacktrace"); + } + else { + ThrowableInterner.disable("Capture agent: cannot capture Throwable backtrace, no supported backtrace field was found"); + } mv.visitVarInsn(Opcodes.ALOAD, 0); CaptureAgent.invokeStorageMethod(mv, "captureThrowable"); } @@ -70,4 +91,9 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri } return null; } + + private static boolean isBacktraceField(String name, String descriptor) { + if (!"backtrace".equals(name) && !"walkback".equals(name)) return false; + return descriptor.startsWith("L") || descriptor.startsWith("["); + } } diff --git a/src/test/java/com/intellij/rt/debugger/agent/ThrowableInternerTest.java b/src/test/java/com/intellij/rt/debugger/agent/ThrowableInternerTest.java new file mode 100644 index 0000000..9b8d127 --- /dev/null +++ b/src/test/java/com/intellij/rt/debugger/agent/ThrowableInternerTest.java @@ -0,0 +1,155 @@ +package com.intellij.rt.debugger.agent; + +import org.junit.Before; +import org.junit.Test; + +import java.lang.reflect.Field; + +import static org.junit.Assert.*; +import static org.junit.Assume.assumeTrue; + +public class ThrowableInternerTest { + private static final Field BACKTRACE_FIELD = findBacktraceField(); + private static final Field STACK_TRACE_FIELD = findField("stackTrace"); + + @Before + public void setUp() { + ThrowableInterner.clear(); + } + + @Test + public void internsEqualUnexpandedThrowablesFromSameCallSite() throws Exception { + assumeTrue("Throwable backtrace field should be accessible", BACKTRACE_FIELD != null); + assumeTrue("Throwable stackTrace field should be accessible", STACK_TRACE_FIELD != null); + Throwable[] throwables = new Throwable[2]; + for (int i = 0; i < throwables.length; i++) { + throwables[i] = createThrowable(); + } + Throwable first = throwables[0]; + Throwable second = throwables[1]; + + Throwable internedFirst = intern(first); + Throwable internedSecond = intern(second); + + assertSame(internedFirst, internedSecond); + assertFalse(isStackTraceExpanded(first)); + assertFalse(isStackTraceExpanded(second)); + assertEquals(1, ThrowableInterner.size()); + } + + @Test + public void clearDropsInternedThrowables() throws Exception { + assumeTrue("Throwable backtrace field should be accessible", BACKTRACE_FIELD != null); + + Throwable[] internedThrowables = new Throwable[2]; + for (int i = 0; i < internedThrowables.length; i++) { + if (i > 0) { + ThrowableInterner.clear(); + } + internedThrowables[i] = intern(createThrowable()); + assertEquals(1, ThrowableInterner.size()); + } + + assertNotSame(internedThrowables[0], internedThrowables[1]); + } + + @Test + public void internsEqualLongArrayBacktraces() { + Throwable first = createThrowable(); + Throwable second = createThrowable(); + + Throwable internedFirst = ThrowableInterner.intern(first, new long[]{1L, 2L, 3L}); + Throwable internedSecond = ThrowableInterner.intern(second, new long[]{1L, 2L, 3L}); + + assertSame(internedFirst, internedSecond); + assertEquals(1, ThrowableInterner.size()); + } + + @Test + public void internsEqualIntArrayBacktraces() { + Throwable first = createThrowable(); + Throwable second = createThrowable(); + + Throwable internedFirst = ThrowableInterner.intern(first, new int[]{1, 2, 3}); + Throwable internedSecond = ThrowableInterner.intern(second, new int[]{1, 2, 3}); + + assertSame(internedFirst, internedSecond); + assertEquals(1, ThrowableInterner.size()); + } + + @Test + public void keepsPrimitiveBacktraceTypesSeparate() { + Throwable longBacktraceThrowable = createThrowable(); + Throwable intBacktraceThrowable = createThrowable(); + + Throwable internedLongBacktrace = ThrowableInterner.intern(longBacktraceThrowable, new long[]{1L, 2L, 3L}); + Throwable internedIntBacktrace = ThrowableInterner.intern(intBacktraceThrowable, new int[]{1, 2, 3}); + + assertSame(longBacktraceThrowable, internedLongBacktrace); + assertSame(intBacktraceThrowable, internedIntBacktrace); + assertEquals(2, ThrowableInterner.size()); + } + + @Test + public void doesNotCallVirtualMethodsOnThrowableSubclasses() { + Throwable throwable = new ThrowableWithFailingMessage(); + + Throwable interned = ThrowableInterner.intern(throwable, new long[]{1L, 2L, 3L}); + + assertSame(throwable, interned); + assertEquals(0, ThrowableInterner.size()); + } + + @Test + public void doesNotInternPlainThrowablesWithMessageOrCause() { + Throwable withMessage = new Throwable("message"); + Throwable withCause = new Throwable(new Throwable()); + + assertSame(withMessage, ThrowableInterner.intern(withMessage, new long[]{1L, 2L, 3L})); + assertSame(withCause, ThrowableInterner.intern(withCause, new long[]{1L, 2L, 3L})); + assertEquals(0, ThrowableInterner.size()); + } + + private static Throwable createThrowable() { + return new Throwable(); + } + + private static class ThrowableWithFailingMessage extends Throwable { + @Override + public String getMessage() { + throw new AssertionError("getMessage must not be called"); + } + } + + private static Throwable intern(Throwable throwable) throws Exception { + return ThrowableInterner.intern(throwable, getBacktrace(throwable)); + } + + private static Object getBacktrace(Throwable throwable) throws Exception { + return BACKTRACE_FIELD.get(throwable); + } + + private static boolean isStackTraceExpanded(Throwable throwable) throws Exception { + Field field = STACK_TRACE_FIELD; + assumeTrue("Throwable stackTrace field should be accessible", field != null); + StackTraceElement[] stackTrace = (StackTraceElement[]) field.get(throwable); + return stackTrace.length != 0; + } + + private static Field findBacktraceField() { + Field field = findField("backtrace"); + if (field != null) return field; + return findField("walkback"); + } + + private static Field findField(String name) { + try { + Field field = Throwable.class.getDeclaredField(name); + field.setAccessible(true); + Object ignoredValue = field.get(new Throwable()); + return field; + } catch (Throwable ignored) { + return null; + } + } +} From 32db0640eb875cc68894752cc840af842d49880b Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 5 Jun 2026 18:30:43 +0200 Subject: [PATCH 4/5] IDEA-390131 Dedup (async) stacks in batches --- .../rt/debugger/agent/CaptureStorage.java | 13 +- .../agent/CapturedStackDeduplicator.java | 129 ++++++++ .../rt/debugger/agent/LogCaptureStorage.java | 142 ++++++--- .../agent/LogCaptureEncodingTest.java | 275 ++++++++++++++---- .../agent/PackedBatchCapacityOverhead.java | 28 +- .../agent/ThrowableCapacityOverhead.java | 23 +- 6 files changed, 479 insertions(+), 131 deletions(-) create mode 100644 src/main/java/com/intellij/rt/debugger/agent/CapturedStackDeduplicator.java diff --git a/src/main/java/com/intellij/rt/debugger/agent/CaptureStorage.java b/src/main/java/com/intellij/rt/debugger/agent/CaptureStorage.java index d582d3b..560bfbf 100644 --- a/src/main/java/com/intellij/rt/debugger/agent/CaptureStorage.java +++ b/src/main/java/com/intellij/rt/debugger/agent/CaptureStorage.java @@ -497,13 +497,12 @@ StackData collectStacks(List stackTrace) { } } - static void writeCapturedStackToStream(Throwable throwable, CapturedStack capturedStack, int limit, DataOutputStream dos) throws IOException { - List regularStack = trimInitAgentFrames(Arrays.asList(throwable.getStackTrace())); - writeAsyncStackTraceToStream(regularStack, dos); - if (capturedStack != null) { - writeAsyncStackTraceElementToStream(ASYNC_STACK_ELEMENT, dos); - writeAsyncStackTraceToStream(getStackTrace(capturedStack, limit - regularStack.size()), dos); - } + static List getThrowableStackTrace(Throwable throwable) { + return trimInitAgentFrames(Arrays.asList(throwable.getStackTrace())); + } + + static List getCapturedStackTrace(CapturedStack capturedStack, int limit) { + return getStackTrace(capturedStack, limit); } // to be run from the debugger diff --git a/src/main/java/com/intellij/rt/debugger/agent/CapturedStackDeduplicator.java b/src/main/java/com/intellij/rt/debugger/agent/CapturedStackDeduplicator.java new file mode 100644 index 0000000..7979099 --- /dev/null +++ b/src/main/java/com/intellij/rt/debugger/agent/CapturedStackDeduplicator.java @@ -0,0 +1,129 @@ +package com.intellij.rt.debugger.agent; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; + +final class CapturedStackDeduplicator { + static final class StackDictionary { + final List> stacks; + final int[] throwableStackIds; + final int[] capturedStackIds; + + private StackDictionary(List> stacks, int[] throwableStackIds, int[] capturedStackIds) { + this.stacks = stacks; + this.throwableStackIds = throwableStackIds; + this.capturedStackIds = capturedStackIds; + } + } + + private static final class StackRef { + final int id; + final List stack; + + private StackRef(int id, List stack) { + this.id = id; + this.stack = stack; + } + } + + private final List events; + private final int maxStackDepth; + private final ArrayList> stacks; + private final int[] throwableStackIds; + private final int[] capturedStackIds; + private final IdentityHashMap throwableStacks; + private final IdentityHashMap capturedStacks; + + private CapturedStackDeduplicator(List events, int maxStackDepth) { + this.events = events; + this.maxStackDepth = maxStackDepth; + int eventCount = events.size(); + stacks = new ArrayList<>(); + throwableStackIds = new int[eventCount]; + capturedStackIds = new int[eventCount]; + throwableStacks = new IdentityHashMap<>(); + capturedStacks = new IdentityHashMap<>(); + } + + static StackDictionary createStackDictionary(List events, int maxStackDepth) { + CapturedStackDeduplicator deduplicator = new CapturedStackDeduplicator(events, maxStackDepth); + deduplicator.collectThrowableStacksAndCapturedStackDepths(); + deduplicator.collectCapturedStacks(); + return new StackDictionary(deduplicator.stacks, deduplicator.throwableStackIds, deduplicator.capturedStackIds); + } + + private void collectThrowableStacksAndCapturedStackDepths() { + for (int i = 0; i < events.size(); i++) { + LogCaptureStorage.Event event = events.get(i); + StackRef throwableStackRef = getThrowableStackRef(event.throwable); + throwableStackIds[i] = throwableStackRef.id; + + if (event.stack == null) { + continue; + } + + CapturedStackInfo capturedStackInfo = capturedStacks.get(event.stack); + if (capturedStackInfo == null) { + capturedStackInfo = new CapturedStackInfo(event.stack); + capturedStacks.put(event.stack, capturedStackInfo); + } + capturedStackInfo.requireDepth(maxStackDepth - throwableStackRef.stack.size()); + } + } + + private StackRef getThrowableStackRef(Throwable throwable) { + StackRef stackRef = throwableStacks.get(throwable); + if (stackRef != null) return stackRef; + + stackRef = addStack(CaptureStorage.getThrowableStackTrace(throwable)); + throwableStacks.put(throwable, stackRef); + return stackRef; + } + + private void collectCapturedStacks() { + for (int i = 0; i < events.size(); i++) { + CaptureStorage.CapturedStack capturedStack = events.get(i).stack; + if (capturedStack == null) { + capturedStackIds[i] = -1; + continue; + } + + CapturedStackInfo capturedStackInfo = capturedStacks.get(capturedStack); + if (capturedStackInfo.stackId == -1) { + capturedStackInfo.stackId = addStack(capturedStackInfo.getStackTrace()).id; + } + capturedStackIds[i] = capturedStackInfo.stackId; + } + } + + private StackRef addStack(List stack) { + StackRef stackRef = new StackRef(stacks.size(), stack); + stacks.add(stack); + return stackRef; + } + + private static final class CapturedStackInfo { + private final CaptureStorage.CapturedStack capturedStack; + private int maxRequiredDepth; + private List stackTrace; + private int stackId = -1; + + private CapturedStackInfo(CaptureStorage.CapturedStack capturedStack) { + this.capturedStack = capturedStack; + } + + private void requireDepth(int depth) { + if (maxRequiredDepth < depth) { + maxRequiredDepth = depth; + } + } + + private List getStackTrace() { + if (stackTrace == null) { + stackTrace = CaptureStorage.getCapturedStackTrace(capturedStack, maxRequiredDepth); + } + return stackTrace; + } + } +} diff --git a/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java b/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java index 6a7042d..3263ab3 100644 --- a/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java +++ b/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java @@ -8,6 +8,7 @@ import java.util.*; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.zip.GZIPOutputStream; @@ -25,12 +26,11 @@ protected Boolean initialValue() { static final String BATCHING_ENABLED_PROPERTY = "logCaptureBatchingEnabled"; static final String BATCHING_FLUSH_PERIOD_PROPERTY = "logCaptureBatchingFlushPeriod"; - static final String BATCHING_MAX_EVENTS_PROPERTY = "logCaptureBatchingMaxEvents"; static final String BATCHING_MAX_PACKED_BYTES_PROPERTY = "logCaptureBatchingMaxPackedBytes"; private static final long DEFAULT_MAX_BATCHED_PACKED_BYTES = 5L * 1024L * 1024L; + private static final int ESTIMATED_THROWABLE_BYTES = 3000; private static boolean BATCHING_ENABLED; - private static int MAX_BATCHED_EVENTS_COUNT; private static long MAX_BATCHED_PACKED_BYTES; private static boolean STDOUT_CAPTURE_ENABLED; @@ -42,6 +42,7 @@ protected Boolean initialValue() { // Raw or packed data can be flushed concurrently, leading to sending the same events multiple times. // It's ok and is handled by the debugger using IDs. static final ConcurrentLinkedQueue EVENTS = new ConcurrentLinkedQueue<>(); + static final AtomicLong EVENTS_PAYLOAD_BYTES = new AtomicLong(); static final ConcurrentLinkedQueue PACKED_BATCHES = new ConcurrentLinkedQueue<>(); static final AtomicLong PACKED_BATCHES_BYTES = new AtomicLong(); @@ -49,7 +50,13 @@ protected Boolean initialValue() { static final AtomicLong LAST_PACKED_EVENT_ID = new AtomicLong(-1); static final AtomicLong LAST_LOGGING_BREAKPOINT_EVENT_ID = new AtomicLong(-1); - static class Event { + private interface MemoryFootprintEstimate { + int memoryFootprintEstimate(); + + boolean markRemoved(); + } + + static class Event implements MemoryFootprintEstimate { public static final byte STD_OUTPUT_TYPE = 0; public static final byte LOGGING_BREAKPOINT_TYPE = 1; @@ -58,6 +65,7 @@ static class Event { public final byte[] payload; public final Throwable throwable; public final CaptureStorage.CapturedStack stack; + private final AtomicBoolean removed = new AtomicBoolean(); public Event(long id, byte type, byte[] payload, Throwable throwable, CaptureStorage.CapturedStack stack) { this.id = id; @@ -66,16 +74,37 @@ public Event(long id, byte type, byte[] payload, Throwable throwable, CaptureSto this.throwable = throwable; this.stack = stack; } + + @Override + public int memoryFootprintEstimate() { + return payload.length; + } + + @Override + public boolean markRemoved() { + return removed.compareAndSet(false, true); + } } - static class PackedBatch { + static class PackedBatch implements MemoryFootprintEstimate { public final byte[] data; public final long lastEventId; + private final AtomicBoolean removed = new AtomicBoolean(); public PackedBatch(byte[] data, long lastEventId) { this.data = data; this.lastEventId = lastEventId; } + + @Override + public int memoryFootprintEstimate() { + return data.length; + } + + @Override + public boolean markRemoved() { + return removed.compareAndSet(false, true); + } } private static final FileDescriptor FD_OUT = FileDescriptor.out; @@ -91,7 +120,6 @@ public static boolean init(Properties properties, boolean logCaptureEnabled) { ENABLED = true; STDOUT_CAPTURE_ENABLED = logCaptureEnabled; BATCHING_ENABLED = Boolean.parseBoolean(properties.getProperty(BATCHING_ENABLED_PROPERTY, "true")); - MAX_BATCHED_EVENTS_COUNT = Integer.parseInt(properties.getProperty(BATCHING_MAX_EVENTS_PROPERTY, "1000")); MAX_BATCHED_PACKED_BYTES = Long.parseLong(properties.getProperty( BATCHING_MAX_PACKED_BYTES_PROPERTY, String.valueOf(DEFAULT_MAX_BATCHED_PACKED_BYTES))); @@ -173,9 +201,13 @@ private static boolean hasBatchedLoggingBreakpointEvents() { private static void captureEvent(Event event) throws IOException { if (BATCHING_ENABLED) { EVENTS.add(event); - flushBatchedDataIfMoreThan(MAX_BATCHED_EVENTS_COUNT); + EVENTS_PAYLOAD_BYTES.addAndGet(event.memoryFootprintEstimate()); + flushBatchedDataIfNeeded(); } else { - packAndSend(Collections.singletonList(event)); + PackedBatch batch = new PackedBatch(packBytes(Collections.singletonList(event)), -1); + ThrowableInterner.clear(); + String packed = packBatches(Collections.singletonList(batch)); + outputWritten(packed); } } @@ -191,76 +223,91 @@ private static void handleException(Throwable e) { * before it appears on the debugger side. The clearing happens in the periodic flush cycle. */ static String packBatchedData() throws IOException { - packRawEventsIfMoreThan(0); + packRawEvents(); List packedBatchesSnapshot = new ArrayList<>(PACKED_BATCHES); if (packedBatchesSnapshot.isEmpty()) return null; - return packPendingData(packedBatchesSnapshot); + return packBatches(packedBatchesSnapshot); } - private static void flushBatchedDataIfMoreThan(int eventsCountLimit) throws IOException { - if (eventsCountLimit <= 0 || currentEventsSize() > eventsCountLimit) { - packRawEventsIfMoreThan(eventsCountLimit); + private static void flushBatchedDataIfNeeded() throws IOException { + if (currentEventsEstimatedBytes() > MAX_BATCHED_PACKED_BYTES) { + packRawEvents(); } flushPackedBatchesIfNeeded(false); } - private static long currentEventsSize() { - // This is an approximation, but eventsCountLimit is considered non-strict when it is not 0. - // The exact size is checked below. - // N.B. EVENTS.size() takes linear time, so it can be very slow. - return EVENT_COUNTER.get() - 1 - LAST_PACKED_EVENT_ID.get(); + private static long currentEventsEstimatedBytes() { + long throwablesCount = ThrowableInterner.size(); + if (throwablesCount == 0) { + // Interner transformation likely did not work, possible in tests or on transformer failures/ + // Switch to considering every throwable to be unique. + throwablesCount = EVENT_COUNTER.get() - 1 - LAST_PACKED_EVENT_ID.get(); + } + return EVENTS_PAYLOAD_BYTES.get() + ESTIMATED_THROWABLE_BYTES * throwablesCount; } private static void flushBatchedData() throws IOException { - packRawEventsIfMoreThan(0); + packRawEvents(); flushPackedBatchesIfNeeded(true); } - private static void packRawEventsIfMoreThan(int eventsCountLimit) throws IOException { + private static void packRawEvents() throws IOException { if (EVENTS.isEmpty()) return; List eventsSnapshot = new ArrayList<>(EVENTS); - if (eventsSnapshot.size() > eventsCountLimit) { - enqueuePackedBatch(eventsSnapshot); - EVENTS.removeAll(new HashSet<>(eventsSnapshot)); - } + if (eventsSnapshot.isEmpty()) return; + enqueuePackedBatch(eventsSnapshot); + long removedBytes = removeItems(EVENTS, eventsSnapshot); + EVENTS_PAYLOAD_BYTES.addAndGet(-removedBytes); + ThrowableInterner.clear(); } private static void enqueuePackedBatch(List events) throws IOException { if (events.isEmpty()) return; byte[] packed = packBytes(events); long lastPackedId = findMaxId(events); - PACKED_BATCHES.add(new PackedBatch(packed, lastPackedId)); - PACKED_BATCHES_BYTES.addAndGet(packed.length); + PackedBatch batch = new PackedBatch(packed, lastPackedId); + PACKED_BATCHES.add(batch); + PACKED_BATCHES_BYTES.addAndGet(batch.memoryFootprintEstimate()); setIfGreater(LAST_PACKED_EVENT_ID, lastPackedId); } private static void flushPackedBatchesIfNeeded(boolean forceOutput) throws IOException { if (!forceOutput && PACKED_BATCHES_BYTES.get() <= MAX_BATCHED_PACKED_BYTES) return; - packRawEventsIfMoreThan(0); + packRawEvents(); List packedBatchesSnapshot = new ArrayList<>(PACKED_BATCHES); if (packedBatchesSnapshot.isEmpty()) return; - outputWritten(packPendingData(packedBatchesSnapshot)); - removePackedBatches(packedBatchesSnapshot); + outputWritten(packBatches(packedBatchesSnapshot)); + long removedBytes = removeItems(PACKED_BATCHES, packedBatchesSnapshot); + PACKED_BATCHES_BYTES.addAndGet(-removedBytes); setIfGreater(LAST_FLUSHED_EVENT_ID, findMaxPackedEventId(packedBatchesSnapshot)); } - private static void packAndSend(Collection events) throws IOException { - outputWritten(packPendingData(Collections.singletonList(new PackedBatch(packBytes(events), -1)))); - } - - private static byte[] packBytes(Collection events) throws IOException { + private static byte[] packBytes(List events) throws IOException { assert !events.isEmpty(); ByteArrayOutputStream bas = new ByteArrayOutputStream(); // no need to close it try (GZIPOutputStream gos = new GZIPOutputStream(bas); DataOutputStream dos = new DataOutputStream(gos)) { + CapturedStackDeduplicator.StackDictionary stackDictionary = + CapturedStackDeduplicator.createStackDictionary(events, MAX_STACK_DEPTH); + + dos.writeInt(stackDictionary.stacks.size()); + for (List stack : stackDictionary.stacks) { + byte[] bytes = packStack(stack); + dos.writeInt(bytes.length); + dos.write(bytes); + } + dos.writeInt(events.size()); - for (Event event : events) { + for (int i = 0; i < events.size(); i++) { + Event event = events.get(i); dos.writeLong(event.id); dos.writeByte(event.type); - byte[] bytes = packEventPayload(event); + dos.writeInt(stackDictionary.throwableStackIds[i]); + dos.writeInt(stackDictionary.capturedStackIds[i]); + byte[] bytes = event.payload; dos.writeInt(bytes.length); dos.write(bytes); } @@ -269,16 +316,15 @@ private static byte[] packBytes(Collection events) throws IOException { return bas.toByteArray(); } - private static byte[] packEventPayload(Event event) throws IOException { + private static byte[] packStack(List stackTrace) throws IOException { ByteArrayOutputStream bas = new ByteArrayOutputStream(); // no need to close it try (DataOutputStream dos = new DataOutputStream(bas)) { - dos.write(event.payload); - CaptureStorage.writeCapturedStackToStream(event.throwable, event.stack, MAX_STACK_DEPTH, dos); + CaptureStorage.writeAsyncStackTraceToStream(stackTrace, dos); } return bas.toByteArray(); } - private static String packPendingData(Collection packedBatches) throws IOException { + private static String packBatches(Collection packedBatches) throws IOException { ByteArrayOutputStream bas = new ByteArrayOutputStream(); // no need to close it try (DataOutputStream dos = new DataOutputStream(bas)) { dos.writeInt(packedBatches.size()); @@ -340,16 +386,20 @@ private static long findMaxPackedEventId(Collection packedBatches) return result; } - private static void removePackedBatches(Collection packedBatches) { + private static long removeItems(ConcurrentLinkedQueue queue, Collection items) { + Set itemsToRemove = new HashSet<>(items); + long removedBytes = 0; - for (PackedBatch batch : packedBatches) { - if (PACKED_BATCHES.remove(batch)) { - removedBytes += batch.data.length; + for (Iterator queueIterator = queue.iterator(); queueIterator.hasNext(); ) { + T queueItem = queueIterator.next(); + if (itemsToRemove.contains(queueItem)) { + queueIterator.remove(); + if (queueItem.markRemoved()) { + removedBytes += queueItem.memoryFootprintEstimate(); + } } } - if (removedBytes > 0) { - PACKED_BATCHES_BYTES.addAndGet(-removedBytes); - } + return removedBytes; } private static void setIfGreater(AtomicLong maxValue, long newValue) { diff --git a/src/test/java/com/intellij/rt/debugger/agent/LogCaptureEncodingTest.java b/src/test/java/com/intellij/rt/debugger/agent/LogCaptureEncodingTest.java index 78ef82f..876820a 100644 --- a/src/test/java/com/intellij/rt/debugger/agent/LogCaptureEncodingTest.java +++ b/src/test/java/com/intellij/rt/debugger/agent/LogCaptureEncodingTest.java @@ -4,7 +4,9 @@ import org.junit.Test; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.DataInputStream; +import java.io.DataOutputStream; import java.io.FileDescriptor; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -17,6 +19,7 @@ public class LogCaptureEncodingTest { private static final String LARGE_PACKED_BYTE_LIMIT = String.valueOf(5 * 1024 * 1024); + private static final ThreadLocal>> STACK_DICTIONARY = new ThreadLocal<>(); private final Properties properties = new Properties(); @@ -29,14 +32,13 @@ public void setUp() { resetLogCaptureStorage(); properties.put(LogCaptureStorage.BATCHING_ENABLED_PROPERTY, "true"); properties.put(LogCaptureStorage.BATCHING_FLUSH_PERIOD_PROPERTY, "999999999"); // never - properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, "0"); + properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, LARGE_PACKED_BYTE_LIMIT); LogCaptureStorage.outputWrittenDumpForTests = new ArrayList<>(); } @Test public void batchesCapturedStdoutEvents() throws Exception { - properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "1"); // 1 is ok, 2 is a signal to flush LogCaptureStorage.init(properties, true); capture(new FileDescriptor() /* some non-standard FD */, "xxx\n"); @@ -46,9 +48,10 @@ public void batchesCapturedStdoutEvents() throws Exception { assertEquals("no flush yet", 0, LogCaptureStorage.outputWrittenDumpForTests.size()); capture(FileDescriptor.err, "bbb\n"); - assertEquals("flushed", 1, LogCaptureStorage.outputWrittenDumpForTests.size()); + assertEquals("packed data stays in memory below the packed-byte limit", 0, LogCaptureStorage.outputWrittenDumpForTests.size()); + assertEquals(0, LogCaptureStorage.PACKED_BATCHES.size()); - try (DataInputStream is = openDump(0)) { + try (DataInputStream is = openPackedBatch(LogCaptureStorage.packBatchedData())) { assertEquals(2, is.readInt()); // count readAndCheckStdoutEvent(0, false, "aaa\n", is); readAndCheckStdoutEvent(1, true, "bbb\n", is); @@ -57,15 +60,15 @@ public void batchesCapturedStdoutEvents() throws Exception { @Test public void batchesLoggingBreakpointEvents() throws Exception { - properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "1"); // 1 is ok, 2 is a signal to flush LogCaptureStorage.init(properties, false); LogCaptureStorage.loggingBreakpointHit(11, "first message"); assertEquals(0, LogCaptureStorage.outputWrittenDumpForTests.size()); LogCaptureStorage.loggingBreakpointHit(22, "second message"); - assertEquals(1, LogCaptureStorage.outputWrittenDumpForTests.size()); + assertEquals(0, LogCaptureStorage.outputWrittenDumpForTests.size()); + assertEquals(0, LogCaptureStorage.PACKED_BATCHES.size()); - try (DataInputStream is = openDump(0)) { + try (DataInputStream is = openPackedBatch(LogCaptureStorage.packBatchedData())) { assertEquals(2, is.readInt()); // count readAndCheckLoggingBreakpointEvent(0, 11, "first message", is); readAndCheckLoggingBreakpointEvent(1, 22, "second message", is); @@ -74,7 +77,6 @@ public void batchesLoggingBreakpointEvents() throws Exception { @Test public void stdoutCaptureFlushesPendingLoggingBreakpointEventsFirst() throws Exception { - properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "100"); LogCaptureStorage.init(properties, true); LogCaptureStorage.loggingBreakpointHit(33, "before stdout"); @@ -91,7 +93,6 @@ public void stdoutCaptureFlushesPendingLoggingBreakpointEventsFirst() throws Exc @Test public void stdoutFlushHookWorksWhenStdoutCaptureIsDisabled() throws Exception { - properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "100"); LogCaptureStorage.init(properties, false); LogCaptureStorage.loggingBreakpointHit(44, "before ignored stdout"); @@ -106,7 +107,6 @@ public void stdoutFlushHookWorksWhenStdoutCaptureIsDisabled() throws Exception { @Test public void keepsEventIdsAndOrderingAcrossMultipleFlushes() throws Exception { - properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "1"); // 1 is ok, 2 is a signal to flush LogCaptureStorage.init(properties, true); LogCaptureStorage.loggingBreakpointHit(55, "first log"); @@ -130,7 +130,6 @@ public void keepsEventIdsAndOrderingAcrossMultipleFlushes() throws Exception { @Test public void packBatchedDataReturnsNullWhenNoEventsPending() throws Exception { - properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "100"); LogCaptureStorage.init(properties, true); assertNull(LogCaptureStorage.packBatchedData()); @@ -138,7 +137,6 @@ public void packBatchedDataReturnsNullWhenNoEventsPending() throws Exception { @Test public void packBatchedDataEncodesEventsWithoutSendingThem() throws Exception { - properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "100"); LogCaptureStorage.init(properties, true); LogCaptureStorage.loggingBreakpointHit(77, "first log"); @@ -185,8 +183,7 @@ public void packBatchedDataEncodesEventsWithoutSendingThem() throws Exception { } @Test - public void exceededRawEventLimitPacksEventsWithoutCallingOutputWrittenBelowPackedMemoryLimit() throws Exception { - properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "1"); // 1 is ok, 2 is a signal to pack + public void rawEventsStayQueuedBelowPackedMemoryLimit() throws Exception { properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, LARGE_PACKED_BYTE_LIMIT); LogCaptureStorage.init(properties, true); @@ -194,9 +191,9 @@ public void exceededRawEventLimitPacksEventsWithoutCallingOutputWrittenBelowPack capture(FileDescriptor.err, "second stdout\n"); assertEquals("packed data stays in memory below the packed-byte limit", 0, LogCaptureStorage.outputWrittenDumpForTests.size()); - assertEquals("raw events are drained after packing", 0, LogCaptureStorage.EVENTS.size()); - assertEquals(1, LogCaptureStorage.PACKED_BATCHES.size()); - assertTrue(LogCaptureStorage.PACKED_BATCHES_BYTES.get() > 0); + assertEquals(2, LogCaptureStorage.EVENTS.size()); + assertEquals(0, LogCaptureStorage.PACKED_BATCHES.size()); + assertEquals(0, LogCaptureStorage.PACKED_BATCHES_BYTES.get()); assertEquals("not sent to debugger yet", -1, LogCaptureStorage.LAST_FLUSHED_EVENT_ID.get()); String packed = LogCaptureStorage.packBatchedData(); @@ -211,49 +208,80 @@ public void exceededRawEventLimitPacksEventsWithoutCallingOutputWrittenBelowPack assertEquals(0, LogCaptureStorage.outputWrittenDumpForTests.size()); } + @Test + public void exceededRawEventBytesLimitPacksEvents() throws Exception { + properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, "1"); + LogCaptureStorage.init(properties, true); + + capture(FileDescriptor.out, "a\n"); + + assertEquals("raw event bytes are drained after exceeding the estimated byte limit", 0, LogCaptureStorage.EVENTS.size()); + assertEquals(0, LogCaptureStorage.EVENTS_PAYLOAD_BYTES.get()); + assertEquals(0, LogCaptureStorage.PACKED_BATCHES.size()); + assertEquals("packed bytes use the same limit and are sent immediately", 1, LogCaptureStorage.outputWrittenDumpForTests.size()); + } + @Test public void packBatchedDataPacksPendingRawEventsBeforeReturning() throws Exception { - properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "1"); // 1 is ok, 2 is a signal to pack properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, LARGE_PACKED_BYTE_LIMIT); LogCaptureStorage.init(properties, true); capture(FileDescriptor.out, "first stdout\n"); capture(FileDescriptor.err, "second stdout\n"); - assertEquals(1, LogCaptureStorage.PACKED_BATCHES.size()); - assertEquals(0, LogCaptureStorage.EVENTS.size()); + assertEquals(0, LogCaptureStorage.PACKED_BATCHES.size()); + assertEquals(2, LogCaptureStorage.EVENTS.size()); capture(FileDescriptor.out, "third stdout\n"); + assertEquals(0, LogCaptureStorage.PACKED_BATCHES.size()); + assertEquals(3, LogCaptureStorage.EVENTS.size()); + + String packed = LogCaptureStorage.packBatchedData(); + assertNotNull(packed); + try (DataInputStream batches = openPackedBatches(packed)) { + assertEquals(1, batches.readInt()); + try (DataInputStream is = openNextPackedBatch(batches)) { + assertEquals(3, is.readInt()); + readAndCheckStdoutEvent(0, false, "first stdout\n", is); + readAndCheckStdoutEvent(1, true, "second stdout\n", is); + readAndCheckStdoutEvent(2, false, "third stdout\n", is); + } + } + + assertEquals(0, LogCaptureStorage.EVENTS.size()); assertEquals(1, LogCaptureStorage.PACKED_BATCHES.size()); - assertEquals(1, LogCaptureStorage.EVENTS.size()); + } + + @Test + public void packBatchedDataDeclaresNumberOfPackedBatches() throws Exception { + LogCaptureStorage.init(properties, true); + + capture(FileDescriptor.out, "first batch\n"); + assertNotNull(LogCaptureStorage.packBatchedData()); + capture(FileDescriptor.err, "second batch\n"); String packed = LogCaptureStorage.packBatchedData(); assertNotNull(packed); try (DataInputStream batches = openPackedBatches(packed)) { assertEquals(2, batches.readInt()); - try (DataInputStream is = openNextPackedBatch(batches); + try (DataInputStream firstBatch = openNextPackedBatch(batches); DataInputStream secondBatch = openNextPackedBatch(batches)) { - assertEquals(2, is.readInt()); - readAndCheckStdoutEvent(0, false, "first stdout\n", is); - readAndCheckStdoutEvent(1, true, "second stdout\n", is); + assertEquals(1, firstBatch.readInt()); + readAndCheckStdoutEvent(0, false, "first batch\n", firstBatch); assertEquals(1, secondBatch.readInt()); - readAndCheckStdoutEvent(2, false, "third stdout\n", secondBatch); + readAndCheckStdoutEvent(1, true, "second batch\n", secondBatch); } } - - assertEquals(0, LogCaptureStorage.EVENTS.size()); - assertEquals(2, LogCaptureStorage.PACKED_BATCHES.size()); } @Test public void packedBatchOverflowFlushesPendingRawEventsToo() throws Exception { - properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "1"); // 1 is ok, 2 is a signal to pack properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, LARGE_PACKED_BYTE_LIMIT); LogCaptureStorage.init(properties, true); capture(FileDescriptor.out, "first stdout\n"); capture(FileDescriptor.err, "second stdout\n"); - assertEquals(1, LogCaptureStorage.PACKED_BATCHES.size()); + assertEquals(0, LogCaptureStorage.PACKED_BATCHES.size()); assertEquals(0, LogCaptureStorage.outputWrittenDumpForTests.size()); properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, "1"); @@ -264,39 +292,99 @@ public void packedBatchOverflowFlushesPendingRawEventsToo() throws Exception { assertEquals(0, LogCaptureStorage.EVENTS.size()); assertEquals(0, LogCaptureStorage.PACKED_BATCHES.size()); try (DataInputStream batches = openDumpBatches(0)) { - assertEquals(2, batches.readInt()); - try (DataInputStream is = openNextPackedBatch(batches); - DataInputStream secondBatch = openNextPackedBatch(batches)) { - assertEquals(2, is.readInt()); + assertEquals(1, batches.readInt()); + try (DataInputStream is = openNextPackedBatch(batches)) { + assertEquals(3, is.readInt()); readAndCheckStdoutEvent(0, false, "first stdout\n", is); readAndCheckStdoutEvent(1, true, "second stdout\n", is); - - assertEquals(1, secondBatch.readInt()); - readAndCheckStdoutEvent(2, false, "third stdout\n", secondBatch); + readAndCheckStdoutEvent(2, false, "third stdout\n", is); } } } @Test - public void packBatchedDataDeclaresNumberOfPackedBatches() throws Exception { + public void packedBatchDeduplicatesThrowableStacksByIdentity() throws Exception { LogCaptureStorage.init(properties, true); - capture(FileDescriptor.out, "first batch\n"); - assertNotNull(LogCaptureStorage.packBatchedData()); - capture(FileDescriptor.err, "second batch\n"); + Throwable throwable = new Throwable(); + LogCaptureStorage.EVENTS.add(new LogCaptureStorage.Event( + 0, LogCaptureStorage.Event.STD_OUTPUT_TYPE, stdoutPayload("same stack\n", false), throwable, null)); + LogCaptureStorage.EVENTS.add(new LogCaptureStorage.Event( + 1, LogCaptureStorage.Event.STD_OUTPUT_TYPE, stdoutPayload("same stack\n", false), throwable, null)); String packed = LogCaptureStorage.packBatchedData(); assertNotNull(packed); - try (DataInputStream batches = openPackedBatches(packed)) { - assertEquals(2, batches.readInt()); - try (DataInputStream firstBatch = openNextPackedBatch(batches); - DataInputStream secondBatch = openNextPackedBatch(batches)) { - assertEquals(1, firstBatch.readInt()); - readAndCheckStdoutEvent(0, false, "first batch\n", firstBatch); + try (DataInputStream is = openPackedBatch(packed)) { + assertEquals("same throwable should use one stack dictionary entry", 1, STACK_DICTIONARY.get().size()); + assertEquals(2, is.readInt()); + List firstStack = readAndCheckStdoutEvent(0, false, "same stack\n", is); + List secondStack = readAndCheckStdoutEvent(1, false, "same stack\n", is); + assertEquals(firstStack, secondStack); + } + } - assertEquals(1, secondBatch.readInt()); - readAndCheckStdoutEvent(1, true, "second batch\n", secondBatch); - } + @Test + public void packedBatchUsesSharedDictionaryForThrowableAndCapturedStacks() throws Exception { + LogCaptureStorage.init(properties, true); + + CaptureStorage.CapturedStack capturedStack = capturedStack( + new StackTraceElement("Captured", "shared", null, 42)); + LogCaptureStorage.EVENTS.add(new LogCaptureStorage.Event( + 0, LogCaptureStorage.Event.STD_OUTPUT_TYPE, stdoutPayload("first\n", false), new Throwable(), capturedStack)); + LogCaptureStorage.EVENTS.add(new LogCaptureStorage.Event( + 1, LogCaptureStorage.Event.STD_OUTPUT_TYPE, stdoutPayload("second\n", false), new Throwable(), capturedStack)); + + String packed = LogCaptureStorage.packBatchedData(); + assertNotNull(packed); + try (DataInputStream is = openPackedBatch(packed)) { + assertEquals(3, STACK_DICTIONARY.get().size()); + assertEquals(2, is.readInt()); + + assertEquals(0, is.readLong()); + assertEquals(LogCaptureStorage.Event.STD_OUTPUT_TYPE, is.readByte()); + assertEquals(0, is.readInt()); + assertEquals("captured stack id shares the same dictionary namespace", 2, is.readInt()); + readAndCheckStdoutMessage("first\n", false, new DataInputStream(new ByteArrayInputStream(readBytesWithSize(is)))); + + assertEquals(1, is.readLong()); + assertEquals(LogCaptureStorage.Event.STD_OUTPUT_TYPE, is.readByte()); + assertEquals(1, is.readInt()); + assertEquals("same captured stack should reuse the shared dictionary id", 2, is.readInt()); + readAndCheckStdoutMessage("second\n", false, new DataInputStream(new ByteArrayInputStream(readBytesWithSize(is)))); + } + } + + @Test + public void packedBatchUsesMaxRequiredDepthForDeduplicatedCapturedStack() throws Exception { + LogCaptureStorage.init(properties, true); + + StackTraceElement capturedFrame = new StackTraceElement("Captured", "shared", null, 42); + CaptureStorage.CapturedStack capturedStack = capturedStack(capturedFrame); + LogCaptureStorage.EVENTS.add(new LogCaptureStorage.Event( + 0, LogCaptureStorage.Event.STD_OUTPUT_TYPE, stdoutPayload("full\n", false), throwableWithStackDepth(100), capturedStack)); + LogCaptureStorage.EVENTS.add(new LogCaptureStorage.Event( + 1, LogCaptureStorage.Event.STD_OUTPUT_TYPE, stdoutPayload("room\n", false), throwableWithStackDepth(99), capturedStack)); + + String packed = LogCaptureStorage.packBatchedData(); + assertNotNull(packed); + try (DataInputStream is = openPackedBatch(packed)) { + assertEquals(3, STACK_DICTIONARY.get().size()); + List capturedDictionaryStack = STACK_DICTIONARY.get().get(2); + assertEquals("captured stack should use the maximum required depth", 1, capturedDictionaryStack.size()); + assertEquals(capturedFrame, capturedDictionaryStack.get(0)); + + assertEquals(2, is.readInt()); + assertEquals(0, is.readLong()); + assertEquals(LogCaptureStorage.Event.STD_OUTPUT_TYPE, is.readByte()); + assertEquals(0, is.readInt()); + assertEquals(2, is.readInt()); + readAndCheckStdoutMessage("full\n", false, new DataInputStream(new ByteArrayInputStream(readBytesWithSize(is)))); + + assertEquals(1, is.readLong()); + assertEquals(LogCaptureStorage.Event.STD_OUTPUT_TYPE, is.readByte()); + assertEquals(1, is.readInt()); + assertEquals("same captured stack should reuse id with max required depth", 2, is.readInt()); + readAndCheckStdoutMessage("room\n", false, new DataInputStream(new ByteArrayInputStream(readBytesWithSize(is)))); } } @@ -312,7 +400,16 @@ static DataInputStream openPackedBatches(String packed) { } static DataInputStream openNextPackedBatch(DataInputStream batches) throws IOException { - return new DataInputStream(new GZIPInputStream(new ByteArrayInputStream(readBytesWithSize(batches)))); + DataInputStream batch = new DataInputStream(new GZIPInputStream(new ByteArrayInputStream(readBytesWithSize(batches)))); + int stackCount = batch.readInt(); + List> stacks = new ArrayList<>(); + for (int i = 0; i < stackCount; i++) { + try (DataInputStream stackStream = new DataInputStream(new ByteArrayInputStream(readBytesWithSize(batch)))) { + stacks.add(readStackFrames(stackStream)); + } + } + STACK_DICTIONARY.set(stacks); + return batch; } static DataInputStream openDump(int index) throws IOException { @@ -329,8 +426,11 @@ static List readAndCheckStdoutEvent(int expectedId, DataInputStream is) throws IOException { assertEquals(expectedId, is.readLong()); assertEquals(LogCaptureStorage.Event.STD_OUTPUT_TYPE, is.readByte()); + int throwableId = is.readInt(); + int capturedStackId = is.readInt(); try (DataInputStream eis = new DataInputStream(new ByteArrayInputStream(readBytesWithSize(is)))) { - return readAndCheckStdoutMessageAndStack(expectedMsg, expectedIsErr, eis); + readAndCheckStdoutMessage(expectedMsg, expectedIsErr, eis); + return stackByIds(throwableId, capturedStackId); } } @@ -340,27 +440,55 @@ private static List readAndCheckLoggingBreakpointEvent(int ex DataInputStream is) throws IOException { assertEquals(expectedId, is.readLong()); assertEquals(LogCaptureStorage.Event.LOGGING_BREAKPOINT_TYPE, is.readByte()); + int throwableId = is.readInt(); + int capturedStackId = is.readInt(); try (DataInputStream eis = new DataInputStream(new ByteArrayInputStream(readBytesWithSize(is)))) { assertEquals(expectedInstrumentationId, eis.readInt()); - return readAndCheckMessageAndStack(expectedMsg, eis); + readAndCheckMessage(expectedMsg, eis); + return stackByIds(throwableId, capturedStackId); } } static List readAndCheckMessageAndStack(String expectedMsg, DataInputStream is) throws IOException { + readAndCheckMessage(expectedMsg, is); + return readStackFrames(is); + } + + private static void readAndCheckMessage(String expectedMsg, DataInputStream is) throws IOException { byte[] msgBytes = readBytesWithSize(is); String msg = new String(msgBytes, StandardCharsets.UTF_8); assertEquals(expectedMsg, msg); - return readStackFrames(is); } static List readAndCheckStdoutMessageAndStack(String expectedMsg, boolean expectedIsErr, DataInputStream is) throws IOException { + readAndCheckStdoutMessage(expectedMsg, expectedIsErr, is); + return readStackFrames(is); + } + + private static void readAndCheckStdoutMessage(String expectedMsg, + boolean expectedIsErr, + DataInputStream is) throws IOException { byte[] msgBytes = readBytesWithSize(is); String msg = new String(msgBytes, StandardCharsets.UTF_8); assertEquals(expectedMsg, msg); assertEquals(expectedIsErr, is.readBoolean()); - return readStackFrames(is); + } + + private static List stackByIds(int throwableId, int capturedStackId) { + List> stacks = STACK_DICTIONARY.get(); + assertNotNull("expected stack dictionary", stacks); + assertTrue("throwable stack id is out of dictionary bounds: " + throwableId, + throwableId >= 0 && throwableId < stacks.size()); + + ArrayList stack = new ArrayList<>(stacks.get(throwableId)); + if (capturedStackId >= 0) { + assertTrue("captured stack id is out of dictionary bounds: " + capturedStackId, + capturedStackId < stacks.size()); + stack.addAll(stacks.get(capturedStackId)); + } + return stack; } private static List readStackFrames(DataInputStream is) throws IOException { @@ -391,14 +519,49 @@ private static void capture(FileDescriptor fd, String text) { LogCaptureStorage.capture(fd, text.getBytes(StandardCharsets.UTF_8)); } + private static byte[] stdoutPayload(String text, boolean isErr) throws IOException { + byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream bas = new ByteArrayOutputStream(); + try (DataOutputStream dos = new DataOutputStream(bas)) { + dos.writeInt(bytes.length); + dos.write(bytes); + dos.writeBoolean(isErr); + } + return bas.toByteArray(); + } + + private static CaptureStorage.CapturedStack capturedStack(final StackTraceElement element) { + return new CaptureStorage.CapturedStack() { + @Override + List getStackTrace() { + ArrayList stack = new ArrayList<>(); + stack.add(element); + return stack; + } + }; + } + + private static Throwable throwableWithStackDepth(int depth) { + StackTraceElement[] stackTrace = new StackTraceElement[depth]; + for (int i = 0; i < depth; i++) { + stackTrace[i] = new StackTraceElement("Regular", "frame" + i, null, i); + } + Throwable throwable = new Throwable(); + throwable.setStackTrace(stackTrace); + return throwable; + } + static void resetLogCaptureStorage() { LogCaptureStorage.EVENT_COUNTER.set(0); LogCaptureStorage.LAST_FLUSHED_EVENT_ID.set(-1); LogCaptureStorage.LAST_PACKED_EVENT_ID.set(-1); LogCaptureStorage.LAST_LOGGING_BREAKPOINT_EVENT_ID.set(-1); LogCaptureStorage.EVENTS.clear(); + LogCaptureStorage.EVENTS_PAYLOAD_BYTES.set(0); LogCaptureStorage.PACKED_BATCHES.clear(); LogCaptureStorage.PACKED_BATCHES_BYTES.set(0); LogCaptureStorage.outputWrittenDumpForTests = null; + ThrowableInterner.clear(); + STACK_DICTIONARY.remove(); } } diff --git a/src/test/java/com/intellij/rt/debugger/agent/PackedBatchCapacityOverhead.java b/src/test/java/com/intellij/rt/debugger/agent/PackedBatchCapacityOverhead.java index aca4419..f225380 100644 --- a/src/test/java/com/intellij/rt/debugger/agent/PackedBatchCapacityOverhead.java +++ b/src/test/java/com/intellij/rt/debugger/agent/PackedBatchCapacityOverhead.java @@ -4,37 +4,39 @@ import java.nio.charset.StandardCharsets; import java.util.Properties; -//Packed batches: 99 -//Packed bytes: 6481311 -//Packed bytes per batch: 65468 -//Packed batches fitting 5 MiB storage limit: 80 -//Capture + pack time: 40185 ns +//Packed batches: 30 +//Packed bytes: 2005984 +//Packed bytes per batch: 66866 +//Packed batches fitting 5 MiB storage limit: 78 +//Capture + pack time: 73569 ns public class PackedBatchCapacityOverhead { private static final long PACKED_BYTES_LIMIT = 5L * 1024L * 1024L; - private static long recursiveFunc(int depth, int repeats, byte[] message) { - if (depth > 0) return recursiveFunc(depth - 1, repeats, message); + private static void recursiveCapture(int depth, int repeats, byte[] message) { + if (depth > 0) { + recursiveCapture(depth - 1, repeats, message); + return; + } - long startNs = System.nanoTime(); for (int i = 0; i < repeats; i++) { LogCaptureStorage.capture(FileDescriptor.out, message); } - return System.nanoTime() - startNs; } public static void main(String[] args) throws Exception { - int repeats = 100_000; + int repeats = 1_000_000; int stackDepth = 100; byte[] message = "stdout message\n".getBytes(StandardCharsets.UTF_8); Properties properties = new Properties(); properties.put(LogCaptureStorage.BATCHING_ENABLED_PROPERTY, "true"); properties.put(LogCaptureStorage.BATCHING_FLUSH_PERIOD_PROPERTY, "999999999"); - properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "1000"); - properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, String.valueOf(Long.MAX_VALUE)); LogCaptureStorage.init(properties, true); - long totalNs = recursiveFunc(stackDepth, repeats, message); + long startNs = System.nanoTime(); + recursiveCapture(stackDepth, repeats, message); + LogCaptureStorage.packBatchedData(); + long totalNs = System.nanoTime() - startNs; long packedBatches = LogCaptureStorage.PACKED_BATCHES.size(); long packedBytes = LogCaptureStorage.PACKED_BATCHES_BYTES.get(); double packedBytesPerBatch = (double) packedBytes / packedBatches; diff --git a/src/test/java/com/intellij/rt/debugger/agent/ThrowableCapacityOverhead.java b/src/test/java/com/intellij/rt/debugger/agent/ThrowableCapacityOverhead.java index 6260ce8..015f837 100644 --- a/src/test/java/com/intellij/rt/debugger/agent/ThrowableCapacityOverhead.java +++ b/src/test/java/com/intellij/rt/debugger/agent/ThrowableCapacityOverhead.java @@ -5,23 +5,25 @@ import java.io.FileDescriptor; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; import java.util.Properties; //Throwable capacity before getStackTrace() -//Bytes per Throwable: 2809 -//Throwables fitting 5 MiB: 1866 +//Bytes per Throwable: 2807 +//Throwables fitting 5 MiB: 1867 // //Throwable capacity after getStackTrace() -//Bytes per Throwable: 8225 -//Throwables fitting 5 MiB: 637 +//Bytes per Throwable: 8231 +//Throwables fitting 5 MiB: 636 // //Captured stack bytes capacity //Bytes per stack: 9142 //Stacks fitting 5 MiB: 573 // //Packed batch capacity -//Packed bytes per event: 65 -//Events fitting 5 MiB: 80896 +//Packed bytes per event: 35 +//Events fitting 5 MiB: 150500 public class ThrowableCapacityOverhead { private static final long BYTES_LIMIT = 5L * 1024L * 1024L; private static final int MAX_STACK_DEPTH = 100; @@ -77,7 +79,10 @@ private static int fillCurrentStacks(byte[][] currentStacks, int stackDepth) thr private static byte[] writeCapturedStack() throws IOException { ByteArrayOutputStream bas = new ByteArrayOutputStream(); try (DataOutputStream dos = new DataOutputStream(bas)) { - CaptureStorage.writeCapturedStackToStream(new Throwable(), CaptureStorage.getCurrentCapturedStack(), MAX_STACK_DEPTH, dos); + CaptureStorage.CapturedStack capturedStack = CaptureStorage.getCurrentCapturedStack(); + List regularStack = Arrays.asList(new Throwable().getStackTrace()); + CaptureStorage.writeAsyncStackTraceToStream(regularStack, dos); + CaptureStorage.writeAsyncStackTraceToStream(CaptureStorage.getCapturedStackTrace(capturedStack, MAX_STACK_DEPTH - regularStack.size()), dos); } return bas.toByteArray(); } @@ -138,7 +143,6 @@ private static void measureAfterBatchZipping(int stackDepth, int repeats) throws Properties properties = new Properties(); properties.put(LogCaptureStorage.BATCHING_ENABLED_PROPERTY, "true"); properties.put(LogCaptureStorage.BATCHING_FLUSH_PERIOD_PROPERTY, "999999999"); - properties.put(LogCaptureStorage.BATCHING_MAX_EVENTS_PROPERTY, "1000"); properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, String.valueOf(Long.MAX_VALUE)); LogCaptureStorage.init(properties, true); @@ -155,12 +159,13 @@ private static void measureAfterBatchZipping(int stackDepth, int repeats) throws private static void resetLogCaptureStorage() { LogCaptureStorage.EVENT_COUNTER.set(0); LogCaptureStorage.LAST_FLUSHED_EVENT_ID.set(-1); - LogCaptureStorage.LAST_PACKED_EVENT_ID.set(-1); LogCaptureStorage.LAST_LOGGING_BREAKPOINT_EVENT_ID.set(-1); LogCaptureStorage.EVENTS.clear(); + LogCaptureStorage.EVENTS_PAYLOAD_BYTES.set(0); LogCaptureStorage.PACKED_BATCHES.clear(); LogCaptureStorage.PACKED_BATCHES_BYTES.set(0); LogCaptureStorage.outputWrittenDumpForTests = null; + ThrowableInterner.clear(); } public static void main(String[] args) throws Exception { From 6deeeffce722e50f1ab438f57659cde4912552f5 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Mon, 8 Jun 2026 18:09:55 +0200 Subject: [PATCH 5/5] IDEA-390131 Cap memory consumption based on Xmx --- .../rt/debugger/agent/LogCaptureStorage.java | 50 +++++++++---------- .../agent/LogCaptureEncodingTest.java | 35 ++++++++----- .../agent/PackedBatchCapacityOverhead.java | 10 ++-- .../agent/ThrowableCapacityOverhead.java | 2 +- 4 files changed, 54 insertions(+), 43 deletions(-) diff --git a/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java b/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java index 3263ab3..581b436 100644 --- a/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java +++ b/src/main/java/com/intellij/rt/debugger/agent/LogCaptureStorage.java @@ -26,12 +26,12 @@ protected Boolean initialValue() { static final String BATCHING_ENABLED_PROPERTY = "logCaptureBatchingEnabled"; static final String BATCHING_FLUSH_PERIOD_PROPERTY = "logCaptureBatchingFlushPeriod"; - static final String BATCHING_MAX_PACKED_BYTES_PROPERTY = "logCaptureBatchingMaxPackedBytes"; - private static final long DEFAULT_MAX_BATCHED_PACKED_BYTES = 5L * 1024L * 1024L; + static final String BATCHING_BUFFER_SIZE_PROPERTY = "logCaptureBatchingBufferSize"; + private static final long DEFAULT_BUFFER_SIZE = calculateDefaultBufferSize(); private static final int ESTIMATED_THROWABLE_BYTES = 3000; private static boolean BATCHING_ENABLED; - private static long MAX_BATCHED_PACKED_BYTES; + private static long BUFFER_SIZE; private static boolean STDOUT_CAPTURE_ENABLED; // It's used by the debugger. @@ -120,9 +120,8 @@ public static boolean init(Properties properties, boolean logCaptureEnabled) { ENABLED = true; STDOUT_CAPTURE_ENABLED = logCaptureEnabled; BATCHING_ENABLED = Boolean.parseBoolean(properties.getProperty(BATCHING_ENABLED_PROPERTY, "true")); - MAX_BATCHED_PACKED_BYTES = Long.parseLong(properties.getProperty( - BATCHING_MAX_PACKED_BYTES_PROPERTY, - String.valueOf(DEFAULT_MAX_BATCHED_PACKED_BYTES))); + // Split in 2 halves for raw events and compressed batches. + BUFFER_SIZE = getBufferSize(properties) / 2; if (BATCHING_ENABLED && !batchingSchedulerStarted) { batchingSchedulerStarted = true; @@ -131,7 +130,7 @@ public static boolean init(Properties properties, boolean logCaptureEnabled) { public void run() { CAPTURING.set(true); try { - flushBatchedData(); + flushBatchedData(true); } catch (Throwable e) { handleException(e); } finally { @@ -151,6 +150,18 @@ public void run() { return true; } + static long calculateDefaultBufferSize() { + long defaultMax = 5L * 1024L * 1024L; // 5 MB + long runtimeMax = Runtime.getRuntime().maxMemory() / 100; // 1% of max heap + return Math.min(defaultMax, runtimeMax); + } + + static long getBufferSize(Properties properties) { + return Long.parseLong(properties.getProperty( + BATCHING_BUFFER_SIZE_PROPERTY, + String.valueOf(DEFAULT_BUFFER_SIZE))); + } + private static long createNextEventId(int eventType) { if (!BATCHING_ENABLED) return -1; long id = EVENT_COUNTER.getAndIncrement(); @@ -173,7 +184,7 @@ public static void capture(FileDescriptor fd, byte[] bytes, int off, int len) { // Avoid logging breakpoint's output reorder with stdout. if (hasBatchedLoggingBreakpointEvents()) { - flushBatchedData(); + flushBatchedData(true); } if (!STDOUT_CAPTURE_ENABLED) return; @@ -202,7 +213,7 @@ private static void captureEvent(Event event) throws IOException { if (BATCHING_ENABLED) { EVENTS.add(event); EVENTS_PAYLOAD_BYTES.addAndGet(event.memoryFootprintEstimate()); - flushBatchedDataIfNeeded(); + flushBatchedData(false); } else { PackedBatch batch = new PackedBatch(packBytes(Collections.singletonList(event)), -1); ThrowableInterner.clear(); @@ -229,14 +240,6 @@ static String packBatchedData() throws IOException { return packBatches(packedBatchesSnapshot); } - private static void flushBatchedDataIfNeeded() throws IOException { - if (currentEventsEstimatedBytes() > MAX_BATCHED_PACKED_BYTES) { - packRawEvents(); - } - - flushPackedBatchesIfNeeded(false); - } - private static long currentEventsEstimatedBytes() { long throwablesCount = ThrowableInterner.size(); if (throwablesCount == 0) { @@ -247,11 +250,6 @@ private static long currentEventsEstimatedBytes() { return EVENTS_PAYLOAD_BYTES.get() + ESTIMATED_THROWABLE_BYTES * throwablesCount; } - private static void flushBatchedData() throws IOException { - packRawEvents(); - flushPackedBatchesIfNeeded(true); - } - private static void packRawEvents() throws IOException { if (EVENTS.isEmpty()) return; List eventsSnapshot = new ArrayList<>(EVENTS); @@ -272,9 +270,11 @@ private static void enqueuePackedBatch(List events) throws IOException { setIfGreater(LAST_PACKED_EVENT_ID, lastPackedId); } - private static void flushPackedBatchesIfNeeded(boolean forceOutput) throws IOException { - if (!forceOutput && PACKED_BATCHES_BYTES.get() <= MAX_BATCHED_PACKED_BYTES) return; - packRawEvents(); + private static void flushBatchedData(boolean forceOutput) throws IOException { + if (forceOutput || currentEventsEstimatedBytes() > BUFFER_SIZE) { + packRawEvents(); + } + if (!forceOutput && PACKED_BATCHES_BYTES.get() <= BUFFER_SIZE) return; List packedBatchesSnapshot = new ArrayList<>(PACKED_BATCHES); if (packedBatchesSnapshot.isEmpty()) return; diff --git a/src/test/java/com/intellij/rt/debugger/agent/LogCaptureEncodingTest.java b/src/test/java/com/intellij/rt/debugger/agent/LogCaptureEncodingTest.java index 876820a..699dac5 100644 --- a/src/test/java/com/intellij/rt/debugger/agent/LogCaptureEncodingTest.java +++ b/src/test/java/com/intellij/rt/debugger/agent/LogCaptureEncodingTest.java @@ -18,7 +18,7 @@ import static org.junit.Assert.*; public class LogCaptureEncodingTest { - private static final String LARGE_PACKED_BYTE_LIMIT = String.valueOf(5 * 1024 * 1024); + private static final String LARGE_BUFFER_SIZE = String.valueOf(5 * 1024 * 1024); private static final ThreadLocal>> STACK_DICTIONARY = new ThreadLocal<>(); private final Properties properties = new Properties(); @@ -32,7 +32,7 @@ public void setUp() { resetLogCaptureStorage(); properties.put(LogCaptureStorage.BATCHING_ENABLED_PROPERTY, "true"); properties.put(LogCaptureStorage.BATCHING_FLUSH_PERIOD_PROPERTY, "999999999"); // never - properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, LARGE_PACKED_BYTE_LIMIT); + properties.put(LogCaptureStorage.BATCHING_BUFFER_SIZE_PROPERTY, LARGE_BUFFER_SIZE); LogCaptureStorage.outputWrittenDumpForTests = new ArrayList<>(); } @@ -48,7 +48,7 @@ public void batchesCapturedStdoutEvents() throws Exception { assertEquals("no flush yet", 0, LogCaptureStorage.outputWrittenDumpForTests.size()); capture(FileDescriptor.err, "bbb\n"); - assertEquals("packed data stays in memory below the packed-byte limit", 0, LogCaptureStorage.outputWrittenDumpForTests.size()); + assertEquals("buffered data stays in memory below the buffer threshold", 0, LogCaptureStorage.outputWrittenDumpForTests.size()); assertEquals(0, LogCaptureStorage.PACKED_BATCHES.size()); try (DataInputStream is = openPackedBatch(LogCaptureStorage.packBatchedData())) { @@ -183,14 +183,14 @@ public void packBatchedDataEncodesEventsWithoutSendingThem() throws Exception { } @Test - public void rawEventsStayQueuedBelowPackedMemoryLimit() throws Exception { - properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, LARGE_PACKED_BYTE_LIMIT); + public void rawEventsStayQueuedBelowBufferThreshold() throws Exception { + properties.put(LogCaptureStorage.BATCHING_BUFFER_SIZE_PROPERTY, LARGE_BUFFER_SIZE); LogCaptureStorage.init(properties, true); capture(FileDescriptor.out, "first stdout\n"); capture(FileDescriptor.err, "second stdout\n"); - assertEquals("packed data stays in memory below the packed-byte limit", 0, LogCaptureStorage.outputWrittenDumpForTests.size()); + assertEquals("buffered data stays in memory below the buffer threshold", 0, LogCaptureStorage.outputWrittenDumpForTests.size()); assertEquals(2, LogCaptureStorage.EVENTS.size()); assertEquals(0, LogCaptureStorage.PACKED_BATCHES.size()); assertEquals(0, LogCaptureStorage.PACKED_BATCHES_BYTES.get()); @@ -209,8 +209,8 @@ public void rawEventsStayQueuedBelowPackedMemoryLimit() throws Exception { } @Test - public void exceededRawEventBytesLimitPacksEvents() throws Exception { - properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, "1"); + public void exceededRawEventBufferThresholdPacksEvents() throws Exception { + properties.put(LogCaptureStorage.BATCHING_BUFFER_SIZE_PROPERTY, "1"); LogCaptureStorage.init(properties, true); capture(FileDescriptor.out, "a\n"); @@ -218,12 +218,23 @@ public void exceededRawEventBytesLimitPacksEvents() throws Exception { assertEquals("raw event bytes are drained after exceeding the estimated byte limit", 0, LogCaptureStorage.EVENTS.size()); assertEquals(0, LogCaptureStorage.EVENTS_PAYLOAD_BYTES.get()); assertEquals(0, LogCaptureStorage.PACKED_BATCHES.size()); - assertEquals("packed bytes use the same limit and are sent immediately", 1, LogCaptureStorage.outputWrittenDumpForTests.size()); + assertEquals("packed batches use the same buffer threshold and are sent immediately", 1, LogCaptureStorage.outputWrittenDumpForTests.size()); + } + + @Test + public void rawEventsUseHalfOfBufferSizeAsThreshold() throws Exception { + properties.put(LogCaptureStorage.BATCHING_BUFFER_SIZE_PROPERTY, String.valueOf(2 * 3000)); + LogCaptureStorage.init(properties, true); + + capture(FileDescriptor.out, "a\n"); + + assertEquals("raw event bytes are packed after exceeding half the buffer size", 0, LogCaptureStorage.EVENTS.size()); + assertEquals(0, LogCaptureStorage.EVENTS_PAYLOAD_BYTES.get()); } @Test public void packBatchedDataPacksPendingRawEventsBeforeReturning() throws Exception { - properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, LARGE_PACKED_BYTE_LIMIT); + properties.put(LogCaptureStorage.BATCHING_BUFFER_SIZE_PROPERTY, LARGE_BUFFER_SIZE); LogCaptureStorage.init(properties, true); capture(FileDescriptor.out, "first stdout\n"); @@ -276,7 +287,7 @@ public void packBatchedDataDeclaresNumberOfPackedBatches() throws Exception { @Test public void packedBatchOverflowFlushesPendingRawEventsToo() throws Exception { - properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, LARGE_PACKED_BYTE_LIMIT); + properties.put(LogCaptureStorage.BATCHING_BUFFER_SIZE_PROPERTY, LARGE_BUFFER_SIZE); LogCaptureStorage.init(properties, true); capture(FileDescriptor.out, "first stdout\n"); @@ -284,7 +295,7 @@ public void packedBatchOverflowFlushesPendingRawEventsToo() throws Exception { assertEquals(0, LogCaptureStorage.PACKED_BATCHES.size()); assertEquals(0, LogCaptureStorage.outputWrittenDumpForTests.size()); - properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, "1"); + properties.put(LogCaptureStorage.BATCHING_BUFFER_SIZE_PROPERTY, "1"); LogCaptureStorage.init(properties, true); capture(FileDescriptor.out, "third stdout\n"); diff --git a/src/test/java/com/intellij/rt/debugger/agent/PackedBatchCapacityOverhead.java b/src/test/java/com/intellij/rt/debugger/agent/PackedBatchCapacityOverhead.java index f225380..5b31596 100644 --- a/src/test/java/com/intellij/rt/debugger/agent/PackedBatchCapacityOverhead.java +++ b/src/test/java/com/intellij/rt/debugger/agent/PackedBatchCapacityOverhead.java @@ -4,11 +4,11 @@ import java.nio.charset.StandardCharsets; import java.util.Properties; -//Packed batches: 30 -//Packed bytes: 2005984 -//Packed bytes per batch: 66866 -//Packed batches fitting 5 MiB storage limit: 78 -//Capture + pack time: 73569 ns +//Packed batches: 59 +//Packed bytes: 1987391 +//Packed bytes per batch: 33685 +//Packed batches fitting 5 MiB storage limit: 155 +//Capture + pack time: 54638 ns public class PackedBatchCapacityOverhead { private static final long PACKED_BYTES_LIMIT = 5L * 1024L * 1024L; diff --git a/src/test/java/com/intellij/rt/debugger/agent/ThrowableCapacityOverhead.java b/src/test/java/com/intellij/rt/debugger/agent/ThrowableCapacityOverhead.java index 015f837..0a92767 100644 --- a/src/test/java/com/intellij/rt/debugger/agent/ThrowableCapacityOverhead.java +++ b/src/test/java/com/intellij/rt/debugger/agent/ThrowableCapacityOverhead.java @@ -143,7 +143,7 @@ private static void measureAfterBatchZipping(int stackDepth, int repeats) throws Properties properties = new Properties(); properties.put(LogCaptureStorage.BATCHING_ENABLED_PROPERTY, "true"); properties.put(LogCaptureStorage.BATCHING_FLUSH_PERIOD_PROPERTY, "999999999"); - properties.put(LogCaptureStorage.BATCHING_MAX_PACKED_BYTES_PROPERTY, String.valueOf(Long.MAX_VALUE)); + properties.put(LogCaptureStorage.BATCHING_BUFFER_SIZE_PROPERTY, String.valueOf(Long.MAX_VALUE)); LogCaptureStorage.init(properties, true); recursiveCapture(stackDepth, repeats, "stdout message\n".getBytes(StandardCharsets.UTF_8));