gnodet commented on code in PR #24367: URL: https://github.com/apache/camel/pull/24367#discussion_r3511157937
########## dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelJfrOldObjects.java: ########## @@ -0,0 +1,534 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.action; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import com.github.freva.asciitable.AsciiTable; +import com.github.freva.asciitable.Column; +import com.github.freva.asciitable.HorizontalAlign; +import com.github.freva.asciitable.OverflowBehaviour; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.PathUtils; +import org.apache.camel.dsl.jbang.core.common.TerminalWidthHelper; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command(name = "jfr-old-objects", + description = "Diagnose memory leaks using JFR OldObjectSample events in a running Camel integration", + sortOptions = false, showDefaultValues = true, + footer = { + "%nNote: Sizes are sampled during the recording window, not total heap usage.", + "A longer recording captures more samples and shows larger totals for the same leak.", + "Use values to compare classes relative to each other, not as absolute heap numbers.", + "%nExamples:", + " camel cmd jfr-old-objects --start", + " camel cmd jfr-old-objects --start --duration 60", + " camel cmd jfr-old-objects --stop", + " camel cmd jfr-old-objects --status", + " camel cmd jfr-old-objects --query", + " camel cmd jfr-old-objects --query --min-size 1MB", + " camel cmd jfr-old-objects --query --stacktrace", + " camel cmd jfr-old-objects --start --mode dual" }) +public class CamelJfrOldObjects extends ActionBaseCommand { + + @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1") + String name = "*"; + + @CommandLine.Option(names = { "--start" }, description = "Start a JFR recording for OldObjectSample events") + boolean start; + + @CommandLine.Option(names = { "--stop" }, description = "Stop the active recording and display results") + boolean stop; + + @CommandLine.Option(names = { "--status" }, description = "Check recording status") + boolean status; + + @CommandLine.Option(names = { "--query" }, description = "Query cached results from the last recording") + boolean query; + + @CommandLine.Option(names = { "--mode" }, + description = "Recording mode: dual (default, two recordings with trend comparison) or single (one recording)", + defaultValue = "dual") + String mode; + + @CommandLine.Option(names = { "--duration" }, + description = "Recording duration in seconds (auto-stops after this time)", defaultValue = "60") + int duration; + + @CommandLine.Option(names = { "--top" }, + description = "Show only the top N samples", defaultValue = "50") + int top; + + @CommandLine.Option(names = { "--min-size" }, + description = "Only show samples with total size above this value (e.g. 1024, 10KB, 1MB). Default 1KB in dual mode to filter noise", + defaultValue = "0") + String minSize; + + @CommandLine.Option(names = { "--stacktrace" }, + description = "Show allocation stack trace for each sample") + boolean stacktrace; + + public CamelJfrOldObjects(CamelJBangMain main) { + super(main); + } + + @Override + public Integer doCall() throws Exception { + List<Long> pids = findPids(name); + if (pids.isEmpty()) { + return 1; + } else if (pids.size() > 1) { + printer().println("Name or pid " + name + " matches " + pids.size() + + " running Camel integrations. Specify a name or PID that matches exactly one."); + return 1; + } + + long pid = pids.get(0); + + if (start && "dual".equalsIgnoreCase(mode)) { + return doDualRecording(pid); + } + + String command; + if (start) { + command = "start"; + } else if (stop) { + command = "stop"; + } else if (query) { + command = "query"; + } else { + command = "status"; + } + + JsonObject jo = sendAction(pid, command, start ? duration : 0); + if (jo == null) { + printer().println("Response from running Camel with PID " + pid + " not received within timeout"); + return 1; + } + + return handleResponse(pid, jo); + } + + private int doDualRecording(long pid) throws Exception { + int dur1 = duration; + int dur2 = duration * 2; + + // run 1 + printer().printf("Recording 1 of 2 (%ds)...%n", dur1); + JsonObject startResp = sendAction(pid, "start", dur1); + if (startResp == null || !"recording".equals(startResp.getString("status"))) { + printer().println("Error: Failed to start recording 1"); + return 1; + } + + Thread.sleep((dur1 + 3) * 1000L); Review Comment: This `Thread.sleep((dur1 + 3) * 1000L)` blocks the calling thread for the full recording duration (up to 63+ seconds with defaults). The dev console already auto-stops via its `scheduler.schedule()` and reports completion through the `status` command. Consider polling status instead — similar to how `ActionBaseCommand.getJsonObject()` polls with a `StopWatch` loop: ```java // Poll status until recording completes (or times out) StopWatch watch = new StopWatch(); long timeout = (dur1 + 30) * 1000L; while (watch.taken() < timeout) { Thread.sleep(2000); JsonObject sts = sendAction(pid, "status", 0); if (sts != null && !"recording".equals(sts.getString("status"))) { break; } } ``` This would detect early completion/failure immediately and eliminate the `+3` second padding that could be too short on a loaded system. Same applies to the second recording sleep below. ########## dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RuntimeTools.java: ########## @@ -327,6 +327,140 @@ public JsonObject camel_runtime_heap_histogram( return runtimeService.executeAction(p.pid(), "heap-histogram", null); } + @Tool(annotations = @Tool.Annotations(readOnlyHint = false, destructiveHint = false, openWorldHint = false), + description = """ + Manage JFR OldObjectSample recording for memory leak diagnosis. \ + Captures objects surviving multiple GC cycles and their reference chains back to GC roots. \ + Use command 'start' to begin recording, 'stop' to stop and get results, \ + 'status' to check recording state, and 'query' to retrieve cached results from the last recording. \ + Use mode 'dual' with 'start' to run two sequential recordings (Xs then 2Xs) and automatically \ + compare trends — returns growth ratios and trend classifications (growing, stable, shrinking, new, gone). \ + Note: sizes are sampled during the recording window, not total heap usage — \ + a longer recording captures more samples and shows larger totals for the same leak. \ + Use values to compare classes relative to each other, not as absolute heap numbers.""") + public JsonObject camel_runtime_jfr_old_objects( + @ToolArg(description = NAME_OR_PID_DESC) String nameOrPid, + @ToolArg(description = "Command: start, stop, status, or query") String command, + @ToolArg(description = "Recording duration in seconds (only for start command, default 60, use 0 for manual stop)") String duration, + @ToolArg(description = "Recording mode: dual (default, two recordings at Xs and 2Xs with trend comparison) or single (one recording)") String mode, + @ToolArg(description = "Include allocation stack traces in results (default false, set true for detailed analysis)") String stacktrace, + @ToolArg(description = "Minimum total size in bytes to include a sample (e.g. 1024 for 1KB). Filters out small allocations to reduce noise. Default 1024 (1KB) in dual mode") String minSize) { + if (command == null || command.isBlank()) { + throw new ToolCallException("command is required (start, stop, status, or query)", null); + } + RuntimeService.ProcessInfo p = runtimeService.findSingleProcess(nameOrPid); + + if ("start".equals(command) && "dual".equalsIgnoreCase(mode)) { + return doDualJfrRecording(p.pid(), duration, stacktrace, minSize); + } + + return runtimeService.executeAction(p.pid(), "jfr-old-objects", root -> { + root.put("command", command); + if ("start".equals(command) && duration != null && !duration.isBlank()) { + root.put("duration", duration); + } + if (stacktrace != null) { + root.put("stacktrace", stacktrace); + } + if (minSize != null && !minSize.isBlank()) { + root.put("minSize", minSize); + } + }); + } + + private JsonObject doDualJfrRecording(long pid, String duration, String stacktrace, String minSize) { + int dur = 30; + if (duration != null && !duration.isBlank()) { + dur = Integer.parseInt(duration); + } + if (dur <= 0) { + dur = 30; + } + int dur1 = dur; + int dur2 = dur * 2; + + // run 1 + JsonObject r1 = runtimeService.executeAction(pid, "jfr-old-objects", root -> { + root.put("command", "start"); + root.put("duration", String.valueOf(dur1)); + if (stacktrace != null) { + root.put("stacktrace", stacktrace); + } + if (minSize != null && !minSize.isBlank()) { + root.put("minSize", minSize); + } + }); + if (r1 == null || !"recording".equals(r1.getString("status"))) { + throw new ToolCallException("Failed to start recording 1", null); + } + + try { + Thread.sleep((dur1 + 3) * 1000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ToolCallException("Interrupted during recording 1", null); + } Review Comment: The MCP tool blocks for `(dur1 + 3) * 1000L` here and again below for recording 2 — potentially 96+ seconds total with 30s default. MCP clients may have their own timeouts, and a blocked tool call gives no feedback to the caller. Same suggestion as in the CLI command: poll the `status` action every 2–3 seconds instead. This also applies to the second sleep at line ~437. ########## core/camel-console/src/main/java/org/apache/camel/impl/console/JfrOldObjectSampleDevConsole.java: ########## @@ -0,0 +1,808 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.impl.console; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import jdk.jfr.Recording; +import jdk.jfr.consumer.RecordedClass; +import jdk.jfr.consumer.RecordedEvent; +import jdk.jfr.consumer.RecordedFrame; +import jdk.jfr.consumer.RecordedObject; +import jdk.jfr.consumer.RecordedStackTrace; +import jdk.jfr.consumer.RecordingFile; +import org.apache.camel.spi.Configurer; +import org.apache.camel.spi.annotations.DevConsole; +import org.apache.camel.support.console.AbstractDevConsole; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Dev console for JFR-based old object sampling. Captures objects surviving multiple GC cycles and their reference + * chains back to GC roots, enabling memory leak diagnosis. + */ +@DevConsole(name = "jfr-old-objects", displayName = "JFR Old Object Samples", + description = "JFR-based old object sampling for memory leak diagnosis") +@Configurer(extended = true) +public class JfrOldObjectSampleDevConsole extends AbstractDevConsole { + + private static final Logger LOG = LoggerFactory.getLogger(JfrOldObjectSampleDevConsole.class); + + private static final int DEFAULT_LIMIT = 100; + private static final int MAX_STACK_FRAMES = 10; + private static final int MAX_CHAIN_DEPTH = 20; + + private volatile Recording activeRecording; + private volatile JsonObject cachedResults; + private volatile JsonObject previousResults; + private volatile long recordingStartTime; + private volatile int requestedDurationSeconds; + private ScheduledExecutorService scheduler; + private ScheduledFuture<?> autoStopFuture; + + public JfrOldObjectSampleDevConsole() { + super("jvm", "jfr-old-objects", "JFR Old Object Samples", + "JFR-based old object sampling for memory leak diagnosis"); + } + + @Override + protected String doCallText(Map<String, Object> options) { + JsonObject json = doCallJson(options); + return json.toJson(); + } + + @Override + protected JsonObject doCallJson(Map<String, Object> options) { + String command = optionString(options, "command"); + if (command == null) { + command = "status"; + } + + return switch (command) { + case "start" -> doStart(options); + case "stop" -> doStop(options); + case "status" -> doStatus(); + case "query" -> doQuery(options); + case "compare" -> doCompare(options); + default -> errorJson("Unknown command: " + command); + }; + } + + private JsonObject doStart(Map<String, Object> options) { + if (activeRecording != null) { Review Comment: Minor: `doStart()` is not synchronized, but `doStopRecordingAndParse()` is. The check-then-act on `activeRecording` here has a theoretical race — two concurrent callers could both pass the null check and create two `Recording` instances, leaking the first. Making `doStart` synchronized (or using `compareAndSet` on an `AtomicReference`) would close the gap. Low severity since concurrent calls to this console are unlikely in practice. ########## dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/JfrOldObjectSampleTab.java: ########## @@ -0,0 +1,1568 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; + +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +import dev.tamboui.style.Style; +import dev.tamboui.terminal.Frame; +import dev.tamboui.text.Line; +import dev.tamboui.text.Span; +import dev.tamboui.text.Text; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.block.Borders; +import dev.tamboui.widgets.paragraph.Paragraph; +import dev.tamboui.widgets.table.Cell; +import dev.tamboui.widgets.table.Row; +import dev.tamboui.widgets.table.Table; +import dev.tamboui.widgets.table.TableState; +import org.apache.camel.dsl.jbang.core.common.PathUtils; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*; + +class JfrOldObjectSampleTab implements MonitorTab { + + private enum State { + IDLE, + RECORDING, + LOADING_RESULTS, + HAS_RESULTS + } + + private enum RecordingMode { + SINGLE, + DUAL + } + + private static final String[] SORT_COLUMNS = { "allocationClass", "sampledSize", "count", "objectAge" }; + private static final long[] MIN_SIZE_PRESETS = { + 0, 1024, 10 * 1024, 100 * 1024, 1024 * 1024, 10 * 1024 * 1024, 100 * 1024 * 1024 }; + private static final String[] MIN_SIZE_LABELS = { + "off", "1 KB", "10 KB", "100 KB", "1 MB", "10 MB", "100 MB" }; + + private final MonitorContext ctx; + private final TableState tableState = new TableState(); + private final AtomicBoolean loading = new AtomicBoolean(false); + + private State state = State.IDLE; + private RecordingMode recordingMode = RecordingMode.DUAL; + private int duration = 60; + private long recordingStartTime; + private int currentRecordingDuration; + + private String sort = "sampledSize"; + private int sortIndex = 1; + private boolean sortReversed; + + private List<SampleEntry> samples = Collections.emptyList(); + private int sampleCount; + private int gcCount; + private long recordingDurationMs; + private long recordingEndTime; + private String lastPid; + private int detailScroll; + private int minSizeIndex; + + // dual recording mode state + private boolean dualFirstDone; + private int dualRecordingNumber; + private List<ComparisonEntry> comparisons; + private long baselineDurationMs; + private long currentDurationMs; + private double durationRatio; + private int baselineGcCount; + private int currentGcCount; + + JfrOldObjectSampleTab(MonitorContext ctx) { + this.ctx = ctx; + } + + @Override + public void onTabSelected() { + String pid = ctx.selectedPid; + if (pid != null && !pid.equals(lastPid)) { + lastPid = pid; + state = State.IDLE; + samples = Collections.emptyList(); + } + if (state == State.IDLE && samples.isEmpty()) { + checkStatus(); + } + } + + @Override + public void onIntegrationChanged() { + state = State.IDLE; + samples = Collections.emptyList(); + comparisons = null; + dualFirstDone = false; + lastPid = null; + } + + @Override + public boolean handleKeyEvent(KeyEvent ke) { + if (state == State.IDLE || state == State.HAS_RESULTS) { + if (ke.isCharIgnoreCase('r')) { + startRecording(); + return true; + } + } + if (state == State.IDLE || state == State.HAS_RESULTS) { + if (ke.isCharIgnoreCase('d')) { + recordingMode = recordingMode == RecordingMode.SINGLE ? RecordingMode.DUAL : RecordingMode.SINGLE; + return true; + } + } + if (state == State.IDLE || state == State.HAS_RESULTS) { + if (ke.isChar('+') || ke.isChar('=')) { + duration = Math.min(300, duration + 10); + return true; + } + if (ke.isChar('-')) { + duration = Math.max(10, duration - 10); + return true; + } + } + if (state == State.RECORDING) { + if (ke.isCharIgnoreCase('x')) { + stopRecording(); + return true; + } + } + if (state == State.HAS_RESULTS) { + if (ke.isChar('s')) { + sortIndex = (sortIndex + 1) % SORT_COLUMNS.length; + sort = SORT_COLUMNS[sortIndex]; + sortReversed = false; + return true; + } + if (ke.isChar('S')) { + sortReversed = !sortReversed; + return true; + } + if (ke.isChar('m')) { + minSizeIndex = (minSizeIndex + 1) % MIN_SIZE_PRESETS.length; + tableState.select(0); + return true; + } + if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) { + detailScroll = Math.max(0, detailScroll - 10); + return true; + } + if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) { + detailScroll += 10; + return true; + } + } + return false; + } + + @Override + public boolean handleEscape() { + return false; + } + + @Override + public void navigateUp() { + if (state == State.HAS_RESULTS) { + tableState.selectPrevious(); + detailScroll = 0; + } + } + + @Override + public void navigateDown() { + if (state == State.HAS_RESULTS) { + int size = comparisons != null ? comparisons.size() : sortedSamples().size(); + tableState.selectNext(size); + detailScroll = 0; + } + } + + @Override + public void render(Frame frame, Rect area) { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null) { + renderNoSelection(frame, area); + return; + } + + switch (state) { + case IDLE -> renderIdle(frame, area); + case RECORDING -> renderRecording(frame, area); + case LOADING_RESULTS -> renderLoading(frame, area); + case HAS_RESULTS -> renderResults(frame, area); + } + } + + private void renderIdle(Frame frame, Rect area) { + List<Line> lines = new ArrayList<>(); + lines.add(Line.from(Span.raw(""))); + + if (loading.get()) { + lines.add(Line.from( + Span.styled(" Checking for existing JFR results...", Style.EMPTY.dim()))); + } else { + lines.add(Line.from( + Span.styled(" No JFR recording active.", Style.EMPTY.dim()))); + lines.add(Line.from(Span.raw(""))); + lines.add(Line.from( + Span.styled(" Press ", Style.EMPTY.dim()), + Span.styled("R", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled(" to start recording OldObjectSample events.", Style.EMPTY.dim()))); + lines.add(Line.from(Span.raw(""))); + lines.add(Line.from( + Span.styled(" Duration: ", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled(duration + "s", Style.EMPTY.fg(Color.WHITE)), + Span.styled(" (use ", Style.EMPTY.dim()), + Span.styled("+", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled("/", Style.EMPTY.dim()), + Span.styled("-", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled(" to adjust)", Style.EMPTY.dim()))); + String modeLabel = recordingMode == RecordingMode.DUAL ? "dual" : "single"; + lines.add(Line.from( + Span.styled(" Mode: ", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled("[" + modeLabel + "]", Style.EMPTY.fg(Color.WHITE)), + Span.styled(" (press ", Style.EMPTY.dim()), + Span.styled("d", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled(" to toggle)", Style.EMPTY.dim()))); + lines.add(Line.from(Span.raw(""))); + if (recordingMode == RecordingMode.DUAL) { + lines.add(Line.from( + Span.styled(" Dual mode ", Style.EMPTY.dim()), + Span.styled("(recommended)", Style.EMPTY.fg(Color.GREEN)))); + lines.add(Line.from( + Span.styled(" Runs two sequential JFR recordings:", Style.EMPTY.dim()))); + lines.add(Line.from( + Span.styled(" Run 1: ", Style.EMPTY.fg(Color.CYAN)), + Span.styled(duration + "s", Style.EMPTY.fg(Color.WHITE)), + Span.styled(" baseline recording", Style.EMPTY.dim()))); + lines.add(Line.from( + Span.styled(" Run 2: ", Style.EMPTY.fg(Color.CYAN)), + Span.styled((duration * 2) + "s", Style.EMPTY.fg(Color.WHITE)), + Span.styled(" comparison recording (2x duration)", Style.EMPTY.dim()))); + lines.add(Line.from( + Span.styled(" Compares trends to detect leaks: classes growing faster", Style.EMPTY.dim()))); + lines.add(Line.from( + Span.styled(" than the duration ratio are flagged as leak suspects.", Style.EMPTY.dim()))); + } else { + lines.add(Line.from( + Span.styled(" Single mode", Style.EMPTY.dim()))); + lines.add(Line.from( + Span.styled(" Runs one JFR recording and shows captured old objects.", Style.EMPTY.dim()))); + lines.add(Line.from( + Span.styled(" Shows what is retained, but cannot detect trends.", Style.EMPTY.dim()))); + lines.add(Line.from( + Span.styled(" Use dual mode for leak detection.", Style.EMPTY.dim()))); + } + } + + frame.renderWidget( + Paragraph.builder() + .text(Text.from(lines)) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" JFR Old Object Samples ").build()) + .build(), + area); + } + + private void renderRecording(Frame frame, Rect area) { + long elapsedMs = System.currentTimeMillis() - recordingStartTime; + long elapsedSec = Math.min(elapsedMs / 1000, currentRecordingDuration); + long remainingSec = currentRecordingDuration - elapsedSec; + + List<Line> lines = new ArrayList<>(); + lines.add(Line.from(Span.raw(""))); + + if (recordingMode == RecordingMode.DUAL) { + String recLabel = dualFirstDone + ? "Recording 2 of 2 (" + currentRecordingDuration + "s)..." + : "Recording 1 of 2 (" + currentRecordingDuration + "s)..."; + lines.add(Line.from( + Span.styled(" " + recLabel, Style.EMPTY.fg(Color.GREEN).bold()))); + } else { + lines.add(Line.from( + Span.styled(" JFR recording in progress...", Style.EMPTY.fg(Color.GREEN).bold()))); + } + + lines.add(Line.from(Span.raw(""))); + lines.add(Line.from( + Span.styled(" Elapsed: ", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled(elapsedSec + "s", Style.EMPTY.fg(Color.WHITE)))); + lines.add(Line.from( + Span.styled(" Remaining: ", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled(remainingSec + "s", Style.EMPTY.fg(Color.WHITE)))); + lines.add(Line.from( + Span.styled(" Duration: ", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled(currentRecordingDuration + "s", Style.EMPTY.fg(Color.WHITE)))); + lines.add(Line.from(Span.raw(""))); + lines.add(Line.from( + Span.styled(" Press ", Style.EMPTY.dim()), + Span.styled("X", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled(" to stop recording early and view results.", Style.EMPTY.dim()))); + + // progress bar + int barWidth = Math.max(10, area.width() - 6); + double pct = currentRecordingDuration > 0 + ? Math.min(1.0, elapsedMs / (currentRecordingDuration * 1000.0)) + : 0; + int filled = (int) (pct * barWidth); + StringBuilder bar = new StringBuilder(" "); + for (int i = 0; i < barWidth; i++) { + bar.append(i < filled ? '█' : '░'); + } + lines.add(Line.from(Span.raw(""))); + lines.add(Line.from(Span.styled(bar.toString(), Style.EMPTY.fg(Color.GREEN)))); + + String title = recordingMode == RecordingMode.DUAL + ? " JFR Recording [dual] " + : " JFR Recording "; + frame.renderWidget( + Paragraph.builder() + .text(Text.from(lines)) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(title).build()) + .build(), + area); + } + + private void renderLoading(Frame frame, Rect area) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from( + Line.from(Span.styled(" Stopping recording and analyzing results...", + Style.EMPTY.dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" JFR Old Object Samples ").build()) + .build(), + area); + } + + private void renderResults(Frame frame, Rect area) { + if (comparisons != null) { + List<Rect> chunks = Layout.vertical() + .constraints(Constraint.percentage(40), Constraint.fill()) + .split(area); + renderComparisonTable(frame, chunks.get(0)); + renderComparisonDetail(frame, chunks.get(1)); + } else { + List<SampleEntry> visible = sortedSamples(); + List<Rect> chunks = Layout.vertical() + .constraints(Constraint.percentage(40), Constraint.fill()) + .split(area); + renderSampleTable(frame, chunks.get(0), visible); + renderDetail(frame, chunks.get(1), visible); + } + } + + private void renderSampleTable(Frame frame, Rect area, List<SampleEntry> visible) { + List<Row> rows = new ArrayList<>(); + for (SampleEntry e : visible) { + String totalStr = e.sampledSize > 0 ? formatBytes(e.sampledSize) : "-"; + rows.add(Row.from( + rightCell(String.valueOf(e.num), 6), + Cell.from(Span.styled(e.className != null ? e.className : "", Style.EMPTY.fg(Color.CYAN))), + rightCell(String.valueOf(e.count), 8), + rightCell(totalStr, 12), + rightCell(formatDuration(e.objectAge), 12))); + } + + if (rows.isEmpty()) { + String msg = minSizeIndex > 0 ? "No samples above " + MIN_SIZE_LABELS[minSizeIndex] : "No samples captured"; + rows.add(Row.from( + Cell.from(""), Cell.from(Span.styled(msg, Style.EMPTY.dim())), + Cell.from(""), Cell.from(""), Cell.from(""))); + } + + long minSize = MIN_SIZE_PRESETS[minSizeIndex]; + String minLabel = minSize > 0 ? " min:" + MIN_SIZE_LABELS[minSizeIndex] : ""; + String agoLabel = ""; + if (recordingEndTime > 0) { + long agoMin = (System.currentTimeMillis() - recordingEndTime) / 60000; + if (agoMin >= 1) { + agoLabel = " (" + agoMin + "m ago)"; + } + } + String gcLabel = gcCount > 0 ? " gc:" + gcCount : ""; + String title = String.format(" JFR Old Objects [%d] duration:%s%s sort:%s%s%s ", + visible.size(), formatDuration(recordingDurationMs), gcLabel, sort, minLabel, agoLabel); + + Table table = Table.builder() + .rows(rows) + .header(Row.from( + rightCell("#", 6, Style.EMPTY.bold()), + Cell.from(Span.styled(sortLabel("CLASS", "allocationClass"), + sortStyle("allocationClass"))), + rightCell(sortLabel("COUNT", "count"), 8, + sortStyle("count")), + rightCell(sortLabel("SAMPLED", "sampledSize"), 12, + sortStyle("sampledSize")), + rightCell(sortLabel("AGE", "objectAge"), 12, + sortStyle("objectAge")))) + .widths( + Constraint.length(6), + Constraint.fill(), + Constraint.length(8), + Constraint.length(12), + Constraint.length(12)) + .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).build()) + .build(); + + frame.renderStatefulWidget(table, area, tableState); + } + + private void renderDetail(Frame frame, Rect area, List<SampleEntry> visible) { + Integer sel = tableState.selected(); + if (sel == null || sel < 0 || sel >= visible.size()) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from( + Span.styled(" Select a sample to see reference chain details", + Style.EMPTY.dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" Detail ").build()) + .build(), + area); + return; + } + + SampleEntry entry = visible.get(sel); + List<Line> lines = new ArrayList<>(); + + // Sample info + lines.add(Line.from( + Span.styled(" Class: ", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled(entry.className != null ? entry.className : "unknown", Style.EMPTY.fg(Color.CYAN)))); + + List<Span> infoSpans = new ArrayList<>(); + if (entry.count > 1) { + infoSpans.add(Span.styled(" Count: ", Style.EMPTY.fg(Color.YELLOW).bold())); + infoSpans.add(Span.styled(String.valueOf(entry.count), Style.EMPTY.fg(Color.WHITE))); + } + if (entry.sampledSize > 0) { + infoSpans.add(Span.styled(infoSpans.isEmpty() ? " Sampled: " : " Sampled: ", + Style.EMPTY.fg(Color.YELLOW).bold())); + infoSpans.add(Span.styled(formatBytes(entry.sampledSize), Style.EMPTY.fg(Color.WHITE))); + } + if (!infoSpans.isEmpty()) { + lines.add(Line.from(infoSpans)); + } + lines.add(Line.from( + Span.styled(" Age: ", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled(formatDuration(entry.objectAge), Style.EMPTY.fg(Color.WHITE)))); + + // Reference chain + if (entry.referenceChain != null && !entry.referenceChain.isEmpty()) { + lines.add(Line.from(Span.raw(""))); + lines.add(Line.from( + Span.styled(" Reference Chain (Object → GC Root):", Style.EMPTY.fg(Color.YELLOW).bold()))); + + for (int i = 0; i < entry.referenceChain.size(); i++) { + ChainLink link = entry.referenceChain.get(i); + String prefix = i == entry.referenceChain.size() - 1 ? " └─ " : " ├─ "; + String typeName = link.type != null ? abbreviateType(link.type) : "?"; + String fieldInfo = link.field != null ? " (field: " + link.field + ")" : ""; + String descInfo = link.description != null ? " [" + link.description + "]" : ""; + + lines.add(Line.from( + Span.styled(prefix, Style.EMPTY.fg(Color.BLUE)), + Span.styled(typeName, Style.EMPTY.fg(Color.CYAN)), + Span.styled(fieldInfo, Style.EMPTY.fg(Color.GREEN)), + Span.styled(descInfo, Style.EMPTY.dim()))); + } + } + + // Stack trace + if (entry.stackTrace != null && !entry.stackTrace.isEmpty()) { + lines.add(Line.from(Span.raw(""))); + lines.add(Line.from( + Span.styled(" Allocation Stack Trace:", Style.EMPTY.fg(Color.YELLOW).bold()))); + + for (StackEntry frame2 : entry.stackTrace) { + Style methodStyle = isJdkFrame(frame2.method) ? Style.EMPTY.dim() : Style.EMPTY.fg(Color.WHITE); + lines.add(Line.from( + Span.styled(" at ", Style.EMPTY.dim()), + Span.styled(frame2.method, methodStyle), + Span.styled(":" + frame2.line, Style.EMPTY.dim()))); + } + } + + // apply scroll offset + if (detailScroll > 0 && detailScroll < lines.size()) { + lines = new ArrayList<>(lines.subList(detailScroll, lines.size())); + } else if (detailScroll >= lines.size()) { + detailScroll = Math.max(0, lines.size() - 1); + if (!lines.isEmpty()) { + lines = new ArrayList<>(lines.subList(detailScroll, lines.size())); + } + } + + String title = " Detail "; + frame.renderWidget( + Paragraph.builder() + .text(Text.from(lines)) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).build()) + .build(), + area); + } + + private void renderComparisonTable(Frame frame, Rect area) { + List<Row> rows = new ArrayList<>(); + for (int i = 0; i < comparisons.size(); i++) { + ComparisonEntry e = comparisons.get(i); + String run1 = e.baselineSampledSize > 0 ? formatBytes(e.baselineSampledSize) : "-"; + String run2 = e.currentSampledSize > 0 ? formatBytes(e.currentSampledSize) : "-"; + String growth = e.growthRatio > 0 ? String.format(Locale.US, "%.1fx", e.growthRatio) : "-"; + Span trendSpan = trendSpan(e.trend); + + rows.add(Row.from( + rightCell(String.valueOf(i + 1), 4), + Cell.from(Span.styled(e.className != null ? e.className : "", Style.EMPTY.fg(Color.CYAN))), + rightCell(run1, 10), + rightCell(run2, 10), + rightCell(growth, 8), + Cell.from(trendSpan))); + } + + if (rows.isEmpty()) { + rows.add(Row.from( + Cell.from(""), Cell.from(Span.styled("No comparison data", Style.EMPTY.dim())), + Cell.from(""), Cell.from(""), Cell.from(""), Cell.from(""))); + } + + String gcLabel = ""; + if (baselineGcCount > 0 || currentGcCount > 0) { + gcLabel = String.format(" gc:%d/%d", baselineGcCount, currentGcCount); + } + String title = String.format(" Comparison [%d] run1:%s run2:%s ratio:%.1fx%s ", + comparisons.size(), formatDuration(baselineDurationMs), + formatDuration(currentDurationMs), durationRatio, gcLabel); + + Table table = Table.builder() + .rows(rows) + .header(Row.from( + rightCell("#", 4, Style.EMPTY.bold()), + Cell.from(Span.styled("CLASS", Style.EMPTY.bold())), + rightCell("RUN1", 10, Style.EMPTY.bold()), + rightCell("RUN2", 10, Style.EMPTY.bold()), + rightCell("GROWTH", 8, Style.EMPTY.bold()), + Cell.from(Span.styled("TREND", Style.EMPTY.bold())))) + .widths( + Constraint.length(4), + Constraint.fill(), + Constraint.length(10), + Constraint.length(10), + Constraint.length(8), + Constraint.length(10)) + .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).build()) + .build(); + + frame.renderStatefulWidget(table, area, tableState); + } + + private void renderComparisonDetail(Frame frame, Rect area) { + Integer sel = tableState.selected(); + if (sel == null || sel < 0 || sel >= comparisons.size()) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from( + Span.styled(" Select an entry to see details", Style.EMPTY.dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" Detail ").build()) + .build(), + area); + return; + } + + ComparisonEntry entry = comparisons.get(sel); + List<Line> lines = new ArrayList<>(); + + lines.add(Line.from( + Span.styled(" Class: ", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled(entry.className != null ? entry.className : "unknown", Style.EMPTY.fg(Color.CYAN)))); + lines.add(Line.from( + Span.styled(" Trend: ", Style.EMPTY.fg(Color.YELLOW).bold()), + trendSpan(entry.trend))); + lines.add(Line.from( + Span.styled(" Run 1: ", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled(formatBytes(entry.baselineSampledSize) + " (" + entry.baselineCount + " samples)", + Style.EMPTY.fg(Color.WHITE)))); + lines.add(Line.from( + Span.styled(" Run 2: ", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled(formatBytes(entry.currentSampledSize) + " (" + entry.currentCount + " samples)", + Style.EMPTY.fg(Color.WHITE)))); + if (entry.growthRatio > 0) { + lines.add(Line.from( + Span.styled(" Growth: ", Style.EMPTY.fg(Color.YELLOW).bold()), + Span.styled(String.format(Locale.US, "%.2fx", entry.growthRatio), + entry.growthRatio > 1.3 ? Style.EMPTY.fg(Color.RED).bold() : Style.EMPTY.fg(Color.WHITE)))); + } + + // reference chain from the entry + if (entry.referenceChain != null && !entry.referenceChain.isEmpty()) { + lines.add(Line.from(Span.raw(""))); + lines.add(Line.from( + Span.styled(" Reference Chain (Object -> GC Root):", Style.EMPTY.fg(Color.YELLOW).bold()))); + for (int i = 0; i < entry.referenceChain.size(); i++) { + ChainLink link = entry.referenceChain.get(i); + String prefix = i == entry.referenceChain.size() - 1 ? " └─ " : " ├─ "; + String typeName = link.type != null ? abbreviateType(link.type) : "?"; + String fieldInfo = link.field != null ? " (field: " + link.field + ")" : ""; + String descInfo = link.description != null ? " [" + link.description + "]" : ""; + lines.add(Line.from( + Span.styled(prefix, Style.EMPTY.fg(Color.BLUE)), + Span.styled(typeName, Style.EMPTY.fg(Color.CYAN)), + Span.styled(fieldInfo, Style.EMPTY.fg(Color.GREEN)), + Span.styled(descInfo, Style.EMPTY.dim()))); + } + } + + // stack trace + if (entry.stackTrace != null && !entry.stackTrace.isEmpty()) { + lines.add(Line.from(Span.raw(""))); + lines.add(Line.from( + Span.styled(" Allocation Stack Trace:", Style.EMPTY.fg(Color.YELLOW).bold()))); + for (StackEntry se : entry.stackTrace) { + Style methodStyle = isJdkFrame(se.method) ? Style.EMPTY.dim() : Style.EMPTY.fg(Color.WHITE); + lines.add(Line.from( + Span.styled(" at ", Style.EMPTY.dim()), + Span.styled(se.method, methodStyle), + Span.styled(":" + se.line, Style.EMPTY.dim()))); + } + } + + // apply scroll offset + if (detailScroll > 0 && detailScroll < lines.size()) { + lines = new ArrayList<>(lines.subList(detailScroll, lines.size())); + } else if (detailScroll >= lines.size()) { + detailScroll = Math.max(0, lines.size() - 1); + if (!lines.isEmpty()) { + lines = new ArrayList<>(lines.subList(detailScroll, lines.size())); + } + } + + frame.renderWidget( + Paragraph.builder() + .text(Text.from(lines)) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" Detail ").build()) + .build(), + area); + } + + private static Span trendSpan(String trend) { + if (trend == null) { + return Span.styled("-", Style.EMPTY.dim()); + } + return switch (trend) { + case "growing" -> Span.styled("↑ leak!", Style.EMPTY.fg(Color.RED).bold()); + case "suspicious" -> Span.styled("↑ leak?", Style.EMPTY.fg(Color.YELLOW).bold()); + case "stable" -> Span.styled("→ stable", Style.EMPTY.fg(Color.GREEN)); + case "shrinking" -> Span.styled("↓", Style.EMPTY.dim()); + case "new" -> Span.styled("new", Style.EMPTY.fg(Color.YELLOW)); + case "gone" -> Span.styled("gone", Style.EMPTY.dim()); + default -> Span.styled(trend, Style.EMPTY.dim()); + }; + } + + @Override + public void renderFooter(List<Span> spans) { + String modeLabel = recordingMode == RecordingMode.DUAL ? "dual" : "single"; + switch (state) { + case IDLE -> { + hint(spans, "R", "record"); + hint(spans, "d", "mode [" + modeLabel + "]"); + hint(spans, "+/-", "duration [" + duration + "s]"); + hintLast(spans, "Esc", "back"); + } + case RECORDING -> { + hint(spans, "X", "stop"); + hintLast(spans, "Esc", "back"); + } + case HAS_RESULTS -> { + hint(spans, "Esc", "back"); + if (comparisons == null) { + hint(spans, "s", "sort"); + hint(spans, "m", "min-size [" + MIN_SIZE_LABELS[minSizeIndex] + "]"); + } + hint(spans, "R", "new recording"); + hint(spans, "d", "mode [" + modeLabel + "]"); + hint(spans, "+/-", "duration [" + duration + "s]"); + hintLast(spans, "PgUp/Dn", "scroll detail"); + } + default -> hintLast(spans, "Esc", "back"); + } + } + + @Override + public SelectionContext getSelectionContext() { + if (state != State.HAS_RESULTS) { + return null; + } + List<String> items; + if (comparisons != null) { + if (comparisons.isEmpty()) { + return null; + } + items = comparisons.stream() + .map(e -> e.className != null ? e.className : "").toList(); + } else { + List<SampleEntry> visible = sortedSamples(); + if (visible.isEmpty()) { + return null; + } + items = visible.stream() + .map(e -> e.className != null ? e.className : "").toList(); + } + Integer sel = tableState.selected(); + return new SelectionContext( + "table", items, sel != null ? sel : -1, items.size(), + comparisons != null ? "JFR Comparison" : "JFR Old Objects"); + } + + @Override + public JsonObject getTableDataAsJson() { + if (state != State.HAS_RESULTS) { + return null; + } + if (comparisons != null) { + return getComparisonDataAsJson(); + } + if (samples.isEmpty()) { + return null; + } + List<SampleEntry> visible = sortedSamples(); + JsonObject result = new JsonObject(); + result.put("tab", "JFR Old Objects"); + JsonArray rows = new JsonArray(); + for (SampleEntry e : visible) { + JsonObject row = new JsonObject(); + row.put("num", e.num); + row.put("className", e.className); + row.put("count", e.count); + row.put("sampledSize", e.sampledSize); + row.put("allocationSize", e.allocationSize); + row.put("objectAge", e.objectAge); + row.put("chainSummary", e.chainSummary); + if (e.referenceChain != null) { + JsonArray chain = new JsonArray(); + for (ChainLink link : e.referenceChain) { + JsonObject cl = new JsonObject(); + if (link.type != null) { + cl.put("type", link.type); + } + if (link.field != null) { + cl.put("field", link.field); + } + if (link.description != null) { + cl.put("description", link.description); + } + chain.add(cl); + } + row.put("referenceChain", chain); + } + rows.add(row); + } + result.put("rows", rows); + result.put("totalRows", sampleCount); + result.put("recordingDurationMs", recordingDurationMs); + Integer sel = tableState.selected(); + result.put("selectedIndex", sel != null ? sel : -1); + return result; + } + + private JsonObject getComparisonDataAsJson() { + JsonObject result = new JsonObject(); + result.put("tab", "JFR Comparison"); + result.put("baselineDurationMs", baselineDurationMs); + result.put("currentDurationMs", currentDurationMs); + result.put("durationRatio", durationRatio); + JsonArray rows = new JsonArray(); + for (ComparisonEntry e : comparisons) { + JsonObject row = new JsonObject(); + row.put("className", e.className); + row.put("baselineSampledSize", e.baselineSampledSize); + row.put("baselineCount", e.baselineCount); + row.put("currentSampledSize", e.currentSampledSize); + row.put("currentCount", e.currentCount); + row.put("growthRatio", e.growthRatio); + row.put("trend", e.trend); + rows.add(row); + } + result.put("rows", rows); + result.put("totalRows", comparisons.size()); + Integer sel = tableState.selected(); + result.put("selectedIndex", sel != null ? sel : -1); + return result; + } + + @Override + public String getHelpText() { + return """ + # JFR Old Object Samples + + The JFR Old Object Samples tab uses Java Flight Recorder to capture objects + that have survived multiple GC cycles (long-lived objects) and traces their + reference chains back to GC roots. This helps diagnose memory leaks by + answering "why is this object still alive?" + + ## How To Use + + 1. Press **R** to start a JFR recording (default 60 seconds) + 2. Use **+**/**-** to adjust the duration before starting + 3. Wait for the recording to complete (or press **X** to stop early) + 4. Browse the samples table and select entries to see reference chains + + ## Table Columns + + Samples from the same class and allocation site (stack trace) are + grouped together automatically. + + - **#** — Group number + - **CLASS** — The class of the sampled long-lived object + - **COUNT** — Number of samples from the same allocation site + - **SAMPLED** — Sum of sampled allocation sizes during the recording + - **AGE** — Maximum age across samples in the group + + ## Detail Panel + + The detail panel shows the full reference chain and allocation stack trace + for the selected sample: + + - **Reference Chain** — Path from the object to its GC root, showing + each referencing type and field name + - **Allocation Stack Trace** — Where the object was originally allocated + + ## Important: Sizes Are Sampled, Not Totals + + The SAMPLED column shows the sum of allocation sizes that JFR captured + during the recording window — it is NOT the total heap footprint of + that class. A 60-second recording will show roughly twice the size of + a 30-second one for the same leak. Use the values to compare classes + relative to each other and to spot trends across recordings, not as + absolute heap usage numbers. + + ## What To Look For + + - **Objects with very long ages**: These have survived many GC cycles + - **Unexpected reference chains**: Objects held by caches, maps, or + static fields that prevent garbage collection + - **Growing collections**: HashMap, ArrayList, ConcurrentHashMap entries + that keep accumulating + - **Thread-local references**: Objects held by thread-local variables + + ## Dual Recording Mode (Default) + + Dual mode is the default because it is the most effective way to + detect memory leaks. Press **d** to toggle between **dual** and + **single** mode. If a previous dual recording exists, the TUI + automatically loads the comparison results on startup. + + In **dual** mode, pressing **R** runs two sequential recordings: + - **Run 1** at the configured duration (e.g. 60s) + - **Run 2** at 2x the duration (e.g. 120s) + + After both complete, a comparison table shows how each class behaved + across the two runs. The **GROWTH** column is the normalized growth + ratio: (size₂ / size₁) / durationRatio. Entries under 1KB in both + runs are filtered out as noise. + + ### Trend Indicators + + - **↑ leak!** (red) — Growth ratio >= 1.2, very likely leak + - **↑ leak?** (yellow) — Growth ratio 1.1–1.2, suspicious + - **→ stable** (green) — Growth ratio 0.8–1.1, normal + - **↓** (dim) — Growth ratio < 0.8, shrinking + - **new** (yellow) — Only appeared in Run 2 + - **gone** (dim) — Only appeared in Run 1 + + ## Comparison With Heap Histogram + + The Heap Histogram tab shows WHAT is using memory (class instance counts + and sizes). This tab shows WHY objects are still alive (reference chains + to GC roots). Use both together: find suspicious classes in Heap Histogram, + then use JFR Old Objects to trace why they are not being collected. + + ## Keys + + | Key | Action | + |-----|--------| + | R | Start/restart JFR recording | + | X | Stop recording early | + | d | Toggle single/dual recording mode | + | +/- | Adjust recording duration | + | Up/Down | Select sample | + | s | Cycle sort column (class, size, age) | + | S | Reverse sort order | + | m | Cycle minimum size filter | + | PgUp/PgDn | Scroll detail panel | + | Esc | Back | + """; + } + + // ---- Action methods ---- + + private void startRecording() { + if (ctx.selectedPid == null || ctx.runner == null) { + return; + } + if (!loading.compareAndSet(false, true)) { + return; + } + state = State.RECORDING; + recordingStartTime = System.currentTimeMillis(); + comparisons = null; + dualFirstDone = false; + dualRecordingNumber = 1; + currentRecordingDuration = duration; + + String pid = ctx.selectedPid; + int dur = duration; + startDaemonThread("jfr-start-" + pid, () -> { + try { + sendStartCommand(pid, dur); + } finally { + loading.set(false); + } + }); + } + + private void sendStartCommand(String pid, int dur) { + Path outputFile = ctx.getOutputFile(pid); + PathUtils.deleteFile(outputFile); + + JsonObject root = new JsonObject(); + root.put("action", "jfr-old-objects"); + root.put("command", "start"); + root.put("duration", String.valueOf(dur)); + + Path actionFile = ctx.getActionFile(pid); + PathUtils.writeTextSafely(root.toJson(), actionFile); + + JsonObject jo = pollJsonResponse(outputFile, 15000); + PathUtils.deleteFile(outputFile); + + if (jo != null && "recording".equals(jo.getString("status"))) { + if (ctx.runner != null) { + ctx.runner.runOnRenderThread(() -> { + state = State.RECORDING; + lastPid = pid; + }); + } + scheduleResultsPoll(pid, dur); + } else { + if (ctx.runner != null) { + ctx.runner.runOnRenderThread(() -> state = State.IDLE); + } + } + } + + private void stopRecording() { + if (ctx.selectedPid == null || ctx.runner == null) { + return; + } + if (!loading.compareAndSet(false, true)) { + return; + } + state = State.LOADING_RESULTS; + + String pid = ctx.selectedPid; + if (recordingMode == RecordingMode.DUAL && dualFirstDone) { + // user stopped second recording early — load comparison + startDaemonThread("jfr-stop-" + pid, () -> { + try { + sendStopAndLoadComparison(pid); + } finally { + loading.set(false); + } + }); + } else if (recordingMode == RecordingMode.DUAL && !dualFirstDone) { + // user stopped first recording early — stop it and auto-start second + int dur2 = duration * 2; + startDaemonThread("jfr-stop-" + pid, () -> { + try { + boolean ok = sendStopAndLoadResults(pid); + if (ok && ctx.runner != null) { + ctx.runner.runOnRenderThread(() -> { + dualFirstDone = true; + dualRecordingNumber = 2; + currentRecordingDuration = dur2; + state = State.RECORDING; + recordingStartTime = System.currentTimeMillis(); + }); + sendStartCommand(pid, dur2); + } + } finally { + loading.set(false); + } + }); + } else { + startDaemonThread("jfr-stop-" + pid, () -> { + try { + sendStopAndLoadResults(pid); + } finally { + loading.set(false); + } + }); + } + } + + private boolean sendStopAndLoadResults(String pid) { + Path outputFile = ctx.getOutputFile(pid); + PathUtils.deleteFile(outputFile); + + JsonObject root = new JsonObject(); + root.put("action", "jfr-old-objects"); + root.put("command", "stop"); + root.put("stacktrace", "true"); + + Path actionFile = ctx.getActionFile(pid); + PathUtils.writeTextSafely(root.toJson(), actionFile); + + JsonObject jo = pollJsonResponse(outputFile, 30000); + PathUtils.deleteFile(outputFile); + + if (jo != null && "completed".equals(jo.getString("status"))) { + List<SampleEntry> result = parseSamples(jo); + int count = jo.getIntegerOrDefault("sampleCount", 0); + int gc = jo.getIntegerOrDefault("gcCount", 0); + long durationMs = jo.getLongOrDefault("recordingDurationMs", 0); + long endTime = jo.getLongOrDefault("recordingEndTime", System.currentTimeMillis()); + + if (ctx.runner != null) { + ctx.runner.runOnRenderThread(() -> { + samples = result; + sampleCount = count; + gcCount = gc; + recordingDurationMs = durationMs; + recordingEndTime = endTime; + state = State.HAS_RESULTS; + tableState.select(0); + lastPid = pid; + }); + } + return true; + } else { + if (ctx.runner != null) { + ctx.runner.runOnRenderThread(() -> state = State.IDLE); + } + return false; + } + } + + private void scheduleResultsPoll(String pid, int dur) { + if (ctx.runner == null) { + return; + } + startDaemonThread("jfr-poll-" + pid, () -> { + try { + Thread.sleep((dur + 3) * 1000L); Review Comment: Polling status at shorter intervals (e.g., every 2–3 seconds) instead of sleeping for the full `(dur + 3)` seconds would also let the TUI refresh the progress bar with actual server-reported elapsed/remaining time, rather than relying on locally-calculated estimates. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
