From 2dca319e730cc62d4705e617c385e3c22f219745 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Thu, 6 Aug 2026 09:42:55 +0200 Subject: [PATCH 1/7] Add repro/sweep tooling and chaos-harness build wiring for reference chains Co-Authored-By: Claude Sonnet 5 --- .gitignore | 12 ++ ddprof-stresstest/build.gradle.kts | 39 +++++ utils/compare-refchains-repro.sh | 163 ++++++++++++++++++ utils/refchains-jfr-metrics.py | 79 +++++++++ utils/refchains-report-multi.py | 261 +++++++++++++++++++++++++++++ utils/refchains-report.py | 231 +++++++++++++++++++++++++ utils/run-chaos-harness.sh | 35 ++-- utils/run-refchains-repro.sh | 136 +++++++++++++++ utils/sweep-refchains-all.sh | 149 ++++++++++++++++ utils/sweep-refchains-budgets.sh | 139 +++++++++++++++ utils/vendor/chart.umd.min.js | 14 ++ 11 files changed, 1248 insertions(+), 10 deletions(-) create mode 100755 utils/compare-refchains-repro.sh create mode 100644 utils/refchains-jfr-metrics.py create mode 100644 utils/refchains-report-multi.py create mode 100644 utils/refchains-report.py create mode 100755 utils/run-refchains-repro.sh create mode 100644 utils/sweep-refchains-all.sh create mode 100644 utils/sweep-refchains-budgets.sh create mode 100644 utils/vendor/chart.umd.min.js diff --git a/.gitignore b/.gitignore index 152d4dfa29..80a392deea 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,15 @@ doc/temp/ # CLAUDE.md is auto-generated from AGENTS.md bootstrap instructions CLAUDE.md + +# OS/editor cruft +.DS_Store + +# AI review/agent tooling scratch state +.sphinx/ +.skill-builder-temp/ +.claude/scheduled_tasks.lock + +# Python bytecode cache +__pycache__/ +*.pyc diff --git a/ddprof-stresstest/build.gradle.kts b/ddprof-stresstest/build.gradle.kts index 550baee43b..67896f2b7d 100644 --- a/ddprof-stresstest/build.gradle.kts +++ b/ddprof-stresstest/build.gradle.kts @@ -101,6 +101,45 @@ tasks.register("chaosJar") { } } +// --- reference-chains repro app --------------------------------------------- +// Plain, dependency-free demo app for manually reproducing the reference-chains +// feature: launched directly with `-agentpath:libjavaProfiler.so=start,...` +// (no dd-trace-java agent, no dynamic attach), so it deliberately has zero +// compile/runtime dependency on ddprof-lib or dd-trace - the profiler is +// entirely out-of-process from this app's own point of view. + +sourceSets { + create("repro") +} + +dependencies { + // ddprof-lib's Java API only - not the native library itself (that's the one + // already loaded in-process via -agentpath). Used solely to call + // JavaProfiler.getInstance().dump(...) periodically: Profiler::dump() + // (profiler.cpp) is the only code path that drains ReferenceChainTracker's + // pending chain-event queue and actually writes datadog.ReferenceChain into + // the JFR file - nothing does this automatically on a timer, and in + // production it's dd-trace-java's own recording-chunk rotation that calls + // it. Without this dependency+call, chain events get built and enqueued + // (visible in TEST_LOG output) but are silently dropped when the process + // exits, and the JFR file never shows a single datadog.ReferenceChain event. + "reproImplementation"(project(mapOf("path" to ":ddprof-lib", "configuration" to "debug"))) +} + +tasks.register("reproJar") { + group = "build" + description = "Demo app that leaks memory in a controlled way, for manually reproducing reference-chains via -agentpath" + archiveFileName.set("refchains_repro.jar") + from(sourceSets["repro"].output) + from({ + configurations["reproRuntimeClasspath"].map { if (it.isDirectory) it else zipTree(it) } + }) + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + manifest { + attributes("Main-Class" to "com.datadoghq.profiler.repro.ReferenceChainLeakDemo") + } +} + tasks.register("runStressTests") { dependsOn(tasks.named("jmhJar")) diff --git a/utils/compare-refchains-repro.sh b/utils/compare-refchains-repro.sh new file mode 100755 index 0000000000..0eaee3c90f --- /dev/null +++ b/utils/compare-refchains-repro.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# +# Runs the reference-chains repro app (run-refchains-repro.sh) twice, back to +# back, over an identical bounded window - once with referencechains=true and +# once with referencechains=false - and diffs the two runs' throughput, +# safepoint/GC pause times, and heap growth, to answer: +# - is it slower overall (throughput/round latency)? +# - is it imposing lengthy STW safepoints (safepoint/GC log)? +# - is it increasing memory usage significantly (heap growth, peak RSS)? +# +# Prints a markdown comparison table plus explicit answers to those three +# questions, and saves the same content as report.md in the run's temp +# working directory alongside the raw stdout/safepoint logs. +# +# Usage: compare-refchains-repro.sh [duration-seconds] +# +# Env vars: same REFCHAINS_SO/REFCHAINS_JAR/REFCHAINS_ARGS/REFCHAINS_JAVA_HOME/ +# REFCHAINS_GC as run-refchains-repro.sh - passed through unchanged to both +# variants so the two runs stay comparable (REFCHAINS_ENABLED is set by this +# script itself, per run - don't set it yourself). + +set -euo pipefail + +HERE="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +DURATION_SECONDS="${1:-120}" +WORKDIR="$(mktemp -d /tmp/refchains_compare.XXXXXX)" + +JDK_DESC="${REFCHAINS_JAVA_HOME:-java on PATH}" +GC_DESC="${REFCHAINS_GC:-JVM ergonomic default}" + +echo "Comparing referencechains=true vs. false over ${DURATION_SECONDS}s each" +echo "JDK: ${JDK_DESC}" +echo "GC: ${GC_DESC}" +echo "Working directory: ${WORKDIR}" + +run_variant() { + local label="$1" enabled="$2" jfr="${WORKDIR}/${1}.jfr" + echo + echo "=== running variant '${label}' (referencechains=${enabled}) ===" + REFCHAINS_ENABLED="${enabled}" "${HERE}/run-refchains-repro.sh" "${jfr}" "${DURATION_SECONDS}" \ + > "${WORKDIR}/${label}.stdout.log" 2>&1 & + local pid=$! + + # Peak RSS while the JVM runs - the -Xlog heap numbers only cover Java heap, + # not native/off-heap growth (e.g. the tracker's own chain-storage arena). + local peak_rss_kb=0 + while kill -0 "${pid}" 2>/dev/null; do + local rss + rss=$(ps -o rss= -p "${pid}" 2>/dev/null | tr -d ' ' || true) + if [ -n "${rss}" ] && [ "${rss}" -gt "${peak_rss_kb}" ]; then + peak_rss_kb="${rss}" + fi + sleep 1 + done + wait "${pid}" || echo "WARN: variant '${label}' exited non-zero" + echo "${peak_rss_kb}" > "${WORKDIR}/${label}.peak_rss_kb" +} + +run_variant "on" "true" +run_variant "off" "false" + +# Parses one variant's logs and echoes a single space-separated line of +# metrics (entriesPerSec avgRoundMs maxRoundMs heapGrowthMb stwCount stwTotalSec +# stwAvgSec stwMaxSec peakRssKb wallSeconds), so both variants can be captured +# into shell variables and diffed numerically rather than eyeballed as text. +parse_variant() { + local label="$1" + local stdout="${WORKDIR}/${label}.stdout.log" + local safepoint_log="${WORKDIR}/${label}.jfr.safepoint.log" + local peak_rss_kb + peak_rss_kb=$(cat "${WORKDIR}/${label}.peak_rss_kb" 2>/dev/null || echo 0) + + local metrics_line + metrics_line=$(grep '^\[metrics\]' "${stdout}" || true) + local wall_seconds entries_per_sec avg_round_ms max_round_ms heap_growth_mb + wall_seconds=$(echo "${metrics_line}" | grep -oE 'wallSeconds=[0-9.]+' | cut -d= -f2 || true) + entries_per_sec=$(echo "${metrics_line}" | grep -oE 'entriesPerSec=[0-9.]+' | cut -d= -f2 || true) + avg_round_ms=$(echo "${metrics_line}" | grep -oE 'avgRoundMs=[0-9.]+' | cut -d= -f2 || true) + max_round_ms=$(echo "${metrics_line}" | grep -oE 'maxRoundMs=[0-9.]+' | cut -d= -f2 || true) + heap_growth_mb=$(echo "${metrics_line}" | grep -oE 'heapGrowthMb=-?[0-9]+' | cut -d= -f2 || true) + + # Unified-logging safepoint format (JDK 17+, -Xlog:safepoint*): one line per + # safepoint event, e.g. `[safepoint] Safepoint "G1CollectFull", Time since + # last: ... ns, ..., Total: 11011880 ns`. "Total" is the full STW window for + # that safepoint (sync + cleanup + vmop), which includes GC pauses since GC + # itself runs at a safepoint. Sum + max (converted ns -> s) across the run + # gives total/longest STW time. + # Two -Xlog safepoint line shapes across JDK versions: + # - JDK 17+: `Safepoint "name", ... Total: N ns` (one line, everything on it) + # - JDK <=16 (e.g. 11): `Total time for which application threads were + # stopped: N seconds, Stopping threads took: ...` (separate summary line, + # no per-name "Safepoint" prefix, value already in seconds not ns) + # Try the ns-based JDK17+ shape first; fall back to the seconds-based one. + local stw_stats + stw_stats=$(grep -oE 'Safepoint "[^"]+".*Total: [0-9]+ ns' "${safepoint_log}" 2>/dev/null \ + | grep -oE 'Total: [0-9]+ ns' \ + | grep -oE '[0-9]+' \ + | awk '{sec=$1/1e9; sum+=sec; if(sec>max) max=sec; n+=1} END {if(n>0) printf "%d %.4f %.4f %.4f", n, sum, sum/n, max}') + if [ -z "${stw_stats}" ]; then + stw_stats=$(grep -oE 'Total time for which application threads were stopped: [0-9.]+ seconds' "${safepoint_log}" 2>/dev/null \ + | grep -oE '[0-9.]+' \ + | awk '{sec=$1; sum+=sec; if(sec>max) max=sec; n+=1} END {if(n>0) printf "%d %.4f %.4f %.4f", n, sum, sum/n, max}') + fi + read -r stw_count stw_total stw_avg stw_max <<< "${stw_stats:-0 0 0 0}" + + echo "${entries_per_sec:-0} ${avg_round_ms:-0} ${max_round_ms:-0} ${heap_growth_mb:-0} ${stw_count} ${stw_total} ${stw_avg} ${stw_max} ${peak_rss_kb} ${wall_seconds:-0}" +} + +read -r on_entries on_avg_round on_max_round on_heap_growth on_stw_count on_stw_total on_stw_avg on_stw_max on_rss on_wall \ + <<< "$(parse_variant "on")" +read -r off_entries off_avg_round off_max_round off_heap_growth off_stw_count off_stw_total off_stw_avg off_stw_max off_rss off_wall \ + <<< "$(parse_variant "off")" + +# Percentage delta of "on" relative to "off": positive means "on" is worse +# (slower/longer-paused/more memory). Guards div-by-zero by falling back to 0. +pct_delta() { + awk -v on="$1" -v off="$2" 'BEGIN { + if (off == 0) { printf "n/a"; exit } + printf "%+.1f%%", ((on - off) / off) * 100.0 + }' +} + +throughput_delta_pct=$(pct_delta "${off_entries}" "${on_entries}") # off/on: lower throughput with refchains on is the "cost" +avg_round_delta_pct=$(pct_delta "${on_avg_round}" "${off_avg_round}") +stw_total_delta_pct=$(pct_delta "${on_stw_total}" "${off_stw_total}") +stw_max_delta_pct=$(pct_delta "${on_stw_max}" "${off_stw_max}") +heap_growth_delta_mb=$((on_heap_growth - off_heap_growth)) +rss_delta_kb=$((on_rss - off_rss)) +rss_delta_pct=$(pct_delta "${on_rss}" "${off_rss}") + +REPORT="${WORKDIR}/report.md" +{ + echo "# reference-chains overhead comparison" + echo + echo "referencechains=true vs. false, ${DURATION_SECONDS}s each. Raw logs: \`${WORKDIR}\`" + echo + echo "JDK: ${JDK_DESC} | GC: ${GC_DESC}" + echo + echo "| metric | on (refchains=true) | off (refchains=false) | delta (on vs. off) |" + echo "|---|---|---|---|" + echo "| entries/sec (throughput) | ${on_entries} | ${off_entries} | ${throughput_delta_pct} |" + echo "| avg round latency (ms) | ${on_avg_round} | ${off_avg_round} | ${avg_round_delta_pct} |" + echo "| max round latency (ms) | ${on_max_round} | ${off_max_round} | - |" + echo "| safepoint count | ${on_stw_count} | ${off_stw_count} | - |" + echo "| total STW stop time (s) | ${on_stw_total} | ${off_stw_total} | ${stw_total_delta_pct} |" + echo "| longest single STW pause (s) | ${on_stw_max} | ${off_stw_max} | ${stw_max_delta_pct} |" + echo "| heap growth (MB) | ${on_heap_growth} | ${off_heap_growth} | ${heap_growth_delta_mb} MB |" + echo "| peak RSS (KB) | ${on_rss} | ${off_rss} | ${rss_delta_pct} (${rss_delta_kb} KB) |" + echo + echo "## Answers" + echo + echo "- **Slower overall?** throughput ${throughput_delta_pct}, avg round latency ${avg_round_delta_pct} with referencechains on." + echo "- **Lengthy STW safepoints?** total stop time ${stw_total_delta_pct}, longest single pause ${on_stw_max}s (vs. ${off_stw_max}s off)." + echo "- **Memory usage up significantly?** heap grew ${heap_growth_delta_mb} MB more, peak RSS ${rss_delta_pct} (${rss_delta_kb} KB) with referencechains on." +} > "${REPORT}" + +echo +echo "===================== results =====================" +cat "${REPORT}" +echo +echo "Report saved to: ${REPORT}" +echo "Raw logs kept in: ${WORKDIR}" diff --git a/utils/refchains-jfr-metrics.py b/utils/refchains-jfr-metrics.py new file mode 100644 index 0000000000..68197c7c5b --- /dev/null +++ b/utils/refchains-jfr-metrics.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Extracts reference-chains discovery-latency metrics from one repro run's +JFR snapshot: how many datadog.ReferenceChain/ReferenceChainAbandoned events +got recorded, and the wall-clock time from the recording's start to the +first ReferenceChain event (the feature's actual "time to first chain"). + +`jfr print`/`jfr summary` require a ".jfr" filename extension, which the +repro app's snapshot files (foo.jfr.snapshot) don't have - the caller must +pass a path already renamed/copied to end in .jfr. + +Usage: refchains-jfr-metrics.py +Prints one line: " " +(time_to_first_chain_s is -1 if no ReferenceChain event was recorded). +""" +import json +import re +import subprocess +import sys +from datetime import datetime, timezone + + +def run_jfr(args, path): + try: + result = subprocess.run(["jfr", *args, path], capture_output=True, text=True, check=True) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + stderr = getattr(e, "stderr", None) or str(e) + print(f"error: 'jfr {' '.join(args)} {path}' failed: {stderr}", file=sys.stderr) + sys.exit(1) + return result.stdout + + +def jfr_recording_start(path): + out = run_jfr(["summary"], path) + m = re.search(r"^ Start:\s+(.+?)\s*\(UTC\)\s*$", out, re.MULTILINE) + if not m: + return None + return datetime.strptime(m.group(1).strip(), "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) + + +def jfr_event_times(path, event_name): + out = run_jfr(["print", "--events", event_name, "--json"], path) + try: + data = json.loads(out) + except json.JSONDecodeError as e: + print(f"error: could not parse 'jfr print --events {event_name} --json {path}' " + f"output as JSON: {e}", file=sys.stderr) + sys.exit(1) + times = [] + for ev in data.get("recording", {}).get("events", []): + st = ev.get("values", {}).get("startTime") or ev.get("startTime") + if st is None: + continue + # jfr --json timestamps look like "2026-07-24T11:38:20.123+00:00" + times.append(datetime.fromisoformat(st.replace("Z", "+00:00"))) + return times + + +def main(): + if len(sys.argv) < 2: + print("Usage: refchains-jfr-metrics.py ", file=sys.stderr) + sys.exit(1) + path = sys.argv[1] + start = jfr_recording_start(path) + chain_times = jfr_event_times(path, "datadog.ReferenceChain") + abandoned_times = jfr_event_times(path, "datadog.ReferenceChainAbandoned") + + chain_count = len(chain_times) + abandoned_count = len(abandoned_times) + if chain_times and start is not None: + first = min(chain_times) + time_to_first_s = max(0.0, (first - start).total_seconds()) + else: + time_to_first_s = -1 + + print(f"{chain_count} {abandoned_count} {time_to_first_s:.3f}") + + +if __name__ == "__main__": + main() diff --git a/utils/refchains-report-multi.py b/utils/refchains-report-multi.py new file mode 100644 index 0000000000..300b05c819 --- /dev/null +++ b/utils/refchains-report-multi.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Renders sweep-refchains-all.sh's combined (knob,param_value,) CSV +into a single self-contained HTML report: one tab per knob, one Chart.js line +chart per metric within each tab. Chart.js is vendored under utils/vendor and +inlined directly into the report so it opens standalone, offline, from the +sweep's temp working directory (no CDN dependency at view time). +""" +import argparse +import csv +import html +import json +import os + +ACCENT = "#2a78d6" # dataviz palette categorical slot 1 ("blue") - one series per chart, no legend needed +GRID = "#dedcd3" +TEXT_PRIMARY = "#0b0b0b" +TEXT_SECONDARY = "#52514e" +SURFACE = "#fcfcfb" + +HERE = os.path.dirname(os.path.abspath(__file__)) +CHARTJS_PATH = os.path.join(HERE, "vendor", "chart.umd.min.js") + +METRICS = [ + ("time_to_first_chain_s", "Time to first reference chain", "s", True), + ("chain_count", "Reference chains found", "count", False), + ("abandoned_count", "Searches abandoned", "count", True), + ("entries_per_sec", "Throughput", "entries/sec", False), + ("avg_round_ms", "Avg round latency", "ms", True), + ("max_round_ms", "Max round latency", "ms", True), + ("stw_total_s", "Total STW stop time", "s", True), + ("stw_max_s", "Longest single STW pause", "s", True), + ("heap_growth_mb", "Heap growth", "MB", True), + ("peak_rss_kb", "Peak RSS", "KB", True), +] + +# time_to_first_chain_s uses -1 as a sentinel for "no chain found within the +# run's duration" (see refchains-jfr-metrics.py) - never plot that as a +# real value, and never let it participate in the min/max Y bounds. +NOT_FOUND_SENTINEL = -1.0 + +KNOB_LABELS = { + "budget": "budget", + "firstpassbudget": "firstpassbudget", + "pausetarget": "pausetarget", + "painbudget": "painbudget", +} + + +def fnum(s): + try: + return float(s) + except (TypeError, ValueError): + return 0.0 + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("csv_path") + ap.add_argument("html_path") + ap.add_argument("--jdk", default="") + ap.add_argument("--gc", default="") + ap.add_argument("--duration", default="") + args = ap.parse_args() + + with open(args.csv_path, newline="") as f: + rows = list(csv.DictReader(f)) + + knobs = [] + for r in rows: + if r["knob"] not in knobs: + knobs.append(r["knob"]) + + with open(CHARTJS_PATH) as f: + chartjs_src = f.read() + + tabs_nav = [] + tabs_content = [] + chart_configs = [] # list of (canvas_id, config_dict) + + for ti, knob in enumerate(knobs): + knob_rows = [r for r in rows if r["knob"] == knob] + active = "active" if ti == 0 else "" + tabs_nav.append( + f'' + ) + + xs_raw = [r["param_value"] for r in knob_rows] + xs_num = [fnum(v) for v in xs_raw] + use_log = min(xs_num) > 0 and (max(xs_num) / min(xs_num) >= 10) + + cards = [] + for field, title, unit, higher_is_worse in METRICS: + canvas_id = f"chart-{knob}-{field}" + raw_ys = [fnum(r[field]) for r in knob_rows] + if field == "time_to_first_chain_s": + not_found = sum(1 for y in raw_ys if y == NOT_FOUND_SENTINEL) + ys = [None if y == NOT_FOUND_SENTINEL else y for y in raw_ys] + badge = ( + f'{not_found}/{len(raw_ys)} runs never found a chain' + if not_found else "" + ) + else: + ys = raw_ys + badge = "" + note = "higher is worse" if higher_is_worse else "" + cards.append(f''' +
+

{title} ({unit}{', ' + note if note else ''}) {badge}

+
+
''') + + config = { + "type": "line", + "data": { + "labels": xs_raw, + "datasets": [{ + "label": title, + "data": ys, + "borderColor": ACCENT, + "backgroundColor": ACCENT, + "borderWidth": 2, + "pointRadius": 4, + "pointHoverRadius": 6, + "tension": 0, + "fill": False, + "spanGaps": False, + }], + }, + "options": { + "responsive": True, + "maintainAspectRatio": False, + "plugins": { + "legend": {"display": False}, + "tooltip": { + "callbacks": {}, + "backgroundColor": "#1a1a19", + "titleColor": "#ffffff", + "bodyColor": "#ffffff", + }, + }, + "scales": { + "x": { + "type": "logarithmic" if use_log else "linear", + "title": {"display": True, "text": f"{KNOB_LABELS.get(knob, knob)}" + (" (log scale)" if use_log else "")}, + "grid": {"color": GRID}, + "ticks": {"color": TEXT_SECONDARY}, + }, + "y": { + "title": {"display": True, "text": unit}, + "grid": {"color": GRID}, + "ticks": {"color": TEXT_SECONDARY}, + "beginAtZero": True, + }, + }, + }, + } + chart_configs.append((canvas_id, config, xs_num if use_log else None)) + + tabs_content.append(f''' +
+
{"".join(cards)}
+
''') + + table_header = "".join(f"{html.escape(str(k))}" for k in rows[0]) if rows else "" + table_rows = "\n".join( + "" + "".join(f"{html.escape(str(r[k]))}" for k in r) + "" for r in rows + ) + + # Chart.js logarithmic scale needs numeric x values, not category labels - + # use a scatter-with-lines dataset (x/y pairs) instead of the labels array + # for any knob using log scale, since category+log axis isn't supported. + js_chart_inits = [] + for canvas_id, config, xs_num in chart_configs: + if xs_num is not None: + ys = config["data"]["datasets"][0]["data"] + config["data"]["datasets"][0]["data"] = [{"x": x, "y": y} for x, y in zip(xs_num, ys)] + del config["data"]["labels"] + js_chart_inits.append( + f'new Chart(document.getElementById("{canvas_id}"), {json.dumps(config)});' + ) + + out = f""" + + + +reference-chains OFAT budget-knob sweep + + + +

reference-chains OFAT budget-knob sweep

+
JDK: {html.escape(str(args.jdk))}  |  GC: {html.escape(str(args.gc))}  |  duration/point: {html.escape(str(args.duration))}s  |  one-factor-at-a-time: each knob swept independently, others left at their built-in defaults
+
{"".join(tabs_nav)}
+{"".join(tabs_content)} +
+ Raw data ({len(rows)} runs) + + {table_header} + {table_rows} +
+
+ + + +""" + + with open(args.html_path, "w") as f: + f.write(out) + + +if __name__ == "__main__": + main() diff --git a/utils/refchains-report.py b/utils/refchains-report.py new file mode 100644 index 0000000000..3f7cf3e0a3 --- /dev/null +++ b/utils/refchains-report.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Renders sweep-refchains-budgets.sh's CSV into a self-contained HTML report +with one line chart per metric (param value on x, metric on y). No external +JS/CSS dependencies - everything (SVG chart bodies, tooltip behavior) is +inlined so the report opens standalone from the sweep's temp working dir. +""" +import argparse +import csv +import html +import sys + +ACCENT = "#2a78d6" # dataviz palette categorical slot 1 ("blue") - single series, no legend needed +GRID = "#dedcd3" +TEXT_PRIMARY = "#0b0b0b" +TEXT_SECONDARY = "#52514e" +SURFACE = "#fcfcfb" + +CHARTS = [ + ("entries_per_sec", "Throughput", "entries/sec", False), + ("avg_round_ms", "Avg round latency", "ms", False), + ("max_round_ms", "Max round latency", "ms", False), + ("stw_total_s", "Total STW stop time", "s", False), + ("stw_max_s", "Longest single STW pause", "s", False), + ("heap_growth_mb", "Heap growth", "MB", True), + ("peak_rss_kb", "Peak RSS", "KB", False), +] + +CHART_W, CHART_H = 640, 260 +MARGIN_L, MARGIN_R, MARGIN_T, MARGIN_B = 56, 24, 20, 32 + + +def fnum(s): + try: + return float(s) + except (TypeError, ValueError): + # Coercing an unparsable CSV cell to 0.0 lets the chart render + # instead of crashing, but a silent 0.0 looks like a real + # measurement - flag it so a malformed sweep row doesn't masquerade + # as a genuine zero data point. + print(f"warning: could not parse {s!r} as a number, using 0.0", file=sys.stderr) + return 0.0 + + +def render_chart(rows, field, title, unit): + xs = [fnum(r["param_value"]) for r in rows] + ys = [fnum(r[field]) for r in rows] + plot_w = CHART_W - MARGIN_L - MARGIN_R + plot_h = CHART_H - MARGIN_T - MARGIN_B + + # Budget-style sweep values are usually log-spaced (1000, 10000, 100000, ...). + # A linear x-scale collapses all but the top value into the left few + # pixels, so switch to log scale whenever the sweep spans more than a + # decade and every value is positive. + use_log_x = min(xs) > 0 and (max(xs) / min(xs) >= 10) + x_min, x_max = min(xs), max(xs) + y_min, y_max = min(0.0, min(ys)), max(ys) if ys else 1.0 + if y_max == y_min: + y_max = y_min + 1.0 + if x_max == x_min: + x_max = x_min + 1.0 + + def px(x): + if use_log_x: + import math + lo, hi, v = math.log10(x_min), math.log10(x_max), math.log10(x) + return MARGIN_L + (v - lo) / (hi - lo) * plot_w + return MARGIN_L + (x - x_min) / (x_max - x_min) * plot_w + + def py(y): + return MARGIN_T + plot_h - (y - y_min) / (y_max - y_min) * plot_h + + points = [(px(x), py(y)) for x, y in zip(xs, ys)] + path_d = "M " + " L ".join(f"{x:.1f},{y:.1f}" for x, y in points) + + # 4 recessive horizontal gridlines with y-axis value labels. + gridlines = [] + for i in range(5): + gy = MARGIN_T + plot_h * i / 4 + gval = y_max - (y_max - y_min) * i / 4 + gridlines.append( + f'' + f'{gval:.3g}' + ) + + x_labels = [] + for x, r in zip(xs, rows): + x_labels.append( + f'{r["param_value"]}' + ) + + markers = [] + for i, ((x, y), r) in enumerate(zip(points, rows)): + val = fnum(r[field]) + markers.append( + f'' + ) + + x_scale_note = ( + f'x: log scale' + if use_log_x else "" + ) + + svg = f''' + + {"".join(gridlines)} + + {"".join(markers)} + {"".join(x_labels)} + {x_scale_note} +''' + return svg + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("csv_path") + ap.add_argument("html_path") + ap.add_argument("--param", default="budget") + ap.add_argument("--jdk", default="") + ap.add_argument("--gc", default="") + ap.add_argument("--duration", default="") + args = ap.parse_args() + + with open(args.csv_path, newline="") as f: + rows = list(csv.DictReader(f)) + + chart_sections = [] + for field, title, unit, higher_is_worse in CHARTS: + note = "higher is worse" if higher_is_worse else "" + svg = render_chart(rows, field, title, unit) + chart_sections.append(f""" +
+

{html.escape(title)} ({html.escape(unit)}{', ' + note if note else ''})

+
{svg}
+
""") + + table_rows = "\n".join( + "" + "".join(f"{html.escape(r[k])}" for k in r) + "" for r in rows + ) + table_header = "".join(f"{html.escape(k)}" for k in rows[0]) if rows else "" + + out = f""" + + + +reference-chains budget sweep — {html.escape(args.param)} + + + +

reference-chains budget sweep — {html.escape(args.param)}

+
JDK: {html.escape(args.jdk)}  |  GC: {html.escape(args.gc)}  |  duration/point: {html.escape(str(args.duration))}s  |  x-axis: {html.escape(args.param)} value
+
+{"".join(chart_sections)} +
+ +{table_header} + +{table_rows} + +
+
+ + +""" + + with open(args.html_path, "w") as f: + f.write(out) + + +if __name__ == "__main__": + main() diff --git a/utils/run-chaos-harness.sh b/utils/run-chaos-harness.sh index b1a62139c7..2f9ff2d390 100755 --- a/utils/run-chaos-harness.sh +++ b/utils/run-chaos-harness.sh @@ -177,16 +177,26 @@ case $ALLOCATOR in if [ -n "${GLIBC_VERSION}" ] && [ "$(printf '%s\n' "2.34" "${GLIBC_VERSION}" | sort -V | head -1)" = "2.34" ]; then MALLOC_DEBUG_LIB=$(find /usr/lib/ /usr/lib64/ /lib/ /lib64/ -maxdepth 4 -name 'libc_malloc_debug.so*' 2>/dev/null | head -1) if [ -z "${MALLOC_DEBUG_LIB}" ]; then - echo "FAIL:glibc ${GLIBC_VERSION} requires libc_malloc_debug to be preloaded for MALLOC_CHECK_ to take effect, but it could not be found" >&2 - exit 1 + # Non-fatal: this is a secondary safety net (MALLOC_CHECK_ enforcement) + # on top of the run's main purpose, not a hard dependency of the chaos + # run itself — a missing libc_malloc_debug package on this image should + # not fail the whole run, just lose this one extra corruption-detection + # layer (glibc >= 2.34's MALLOC_CHECK_ is a silent no-op without it). + echo "WARN: glibc ${GLIBC_VERSION} requires libc_malloc_debug to be preloaded for MALLOC_CHECK_ to take effect, but it could not be found — continuing without MALLOC_CHECK_ enforcement" >&2 + else + export LD_PRELOAD="${MALLOC_DEBUG_LIB}${LD_PRELOAD:+:${LD_PRELOAD}}" + echo "glibc ${GLIBC_VERSION} detected — preloading ${MALLOC_DEBUG_LIB} for MALLOC_CHECK_" fi - export LD_PRELOAD="${MALLOC_DEBUG_LIB}${LD_PRELOAD:+:${LD_PRELOAD}}" - echo "glibc ${GLIBC_VERSION} detected — preloading ${MALLOC_DEBUG_LIB} for MALLOC_CHECK_" fi fi ;; tcmalloc) - export LD_PRELOAD=$(find /usr/lib/ /usr/lib64/ /opt/homebrew/lib/ /usr/local/lib/ -maxdepth 4 -name 'libtcmalloc_minimal.so.4' -o -name 'libtcmalloc.dylib' 2>/dev/null | head -1) + TCMALLOC_LIB=$(find /usr/lib/ /usr/lib64/ /opt/homebrew/lib/ /usr/local/lib/ -maxdepth 4 -name 'libtcmalloc_minimal.so.4' -o -name 'libtcmalloc.dylib' 2>/dev/null | head -1) + if [ -z "${TCMALLOC_LIB}" ]; then + echo "FAIL: allocator=tcmalloc requested but libtcmalloc_minimal.so.4/libtcmalloc.dylib could not be found" >&2 + exit 1 + fi + export LD_PRELOAD="${TCMALLOC_LIB}" # thread-churn/dump-storm antagonists cycle many short-lived threads; # tcmalloc's defaults are slow to return their per-thread caches to the # OS, which was inflating container RSS past the OOM limit on aarch64. @@ -194,7 +204,12 @@ case $ALLOCATOR in export TCMALLOC_AGGRESSIVE_DECOMMIT=1 ;; jemalloc) - export LD_PRELOAD=$(find /usr/lib/ /usr/lib64/ /opt/homebrew/lib/ /usr/local/lib/ -maxdepth 4 -name 'libjemalloc.so' -o -name 'libjemalloc.dylib' 2>/dev/null | head -1) + JEMALLOC_LIB=$(find /usr/lib/ /usr/lib64/ /opt/homebrew/lib/ /usr/local/lib/ -maxdepth 4 -name 'libjemalloc.so' -o -name 'libjemalloc.dylib' 2>/dev/null | head -1) + if [ -z "${JEMALLOC_LIB}" ]; then + echo "FAIL: allocator=jemalloc requested but libjemalloc.so/libjemalloc.dylib could not be found" >&2 + exit 1 + fi + export LD_PRELOAD="${JEMALLOC_LIB}" # Same aarch64 RSS-inflation issue as tcmalloc above: jemalloc's default # decay times leave dirty/muzzy pages resident under heavy thread churn. export MALLOC_CONF="background_thread:true,dirty_decay_ms:1000,muzzy_decay_ms:1000" @@ -247,7 +262,7 @@ echo "disk usage ($(dirname "${DDPROF_ROOT}")): $(df -h "$(dirname "${DDPROF_ROO # OOME instead, so a failure is a single, diagnosable event. CHAOS_START=$(date +%s) timeout "$((RUNTIME + 300))" \ -java -javaagent:${PATCHED_AGENT} \ +java -javaagent:"${PATCHED_AGENT}" \ --add-opens java.base/java.lang=ALL-UNNAMED \ ${ENABLEMENT} \ -Ddd.profiling.upload.period=10 \ @@ -261,11 +276,11 @@ java -javaagent:${PATCHED_AGENT} \ -Xmx${HEAP_MB}m -Xms${HEAP_MB}m \ -XX:MaxMetaspaceSize=384m \ -XX:NativeMemoryTracking=summary \ - -XX:ErrorFile=${HS_ERR} \ + -XX:ErrorFile="${HS_ERR}" \ -XX:+ExitOnOutOfMemoryError \ - -jar ${CHAOS_JAR} \ + -jar "${CHAOS_JAR}" \ --duration ${RUNTIME}s \ - --antagonists ${ANTAGONISTS} + --antagonists "${ANTAGONISTS}" RC=$? CHAOS_ELAPSED=$(( $(date +%s) - CHAOS_START )) diff --git a/utils/run-refchains-repro.sh b/utils/run-refchains-repro.sh new file mode 100755 index 0000000000..1425bd9ef0 --- /dev/null +++ b/utils/run-refchains-repro.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# +# Runs the standalone reference-chains repro app (ddprof-stresstest/src/repro) +# with -agentpath and a set of referencechains sub-options known to actually +# reach the app's leaking CachedEntry population - see ReferenceChainLeakDemo's +# own class comment for why the tracker's auto-scaled first-pass budget +# (referenceChains.h's AUTO_FIRST_PASS_BUDGET_MULTIPLIER/_CAP) matters here. +# +# The jar takes the JFR output path as its own arg and periodically calls +# JavaProfiler.dump() on it - also load-bearing, per ReferenceChainLeakDemo's +# comment: without a dump() call nothing ever drains the tracker's pending +# chain-event queue, so datadog.ReferenceChain never actually lands in the file. +# +# Usage: run-refchains-repro.sh [jfr-output-path] [duration-seconds] +# +# duration-seconds (optional) bounds the run and makes the app print a single +# "[metrics]" summary line (throughput/round-latency/heap growth) at exit +# instead of running forever - see ReferenceChainLeakDemo.reportMetrics(). Also +# enables -Xlog safepoint+GC logging to .safepoint.log and +# .gc.log for STW-pause analysis. Used by compare-refchains-repro.sh to A/B +# referencechains=true vs. false over an identical bounded window. +# +# Env vars: +# REFCHAINS_SO path to libjavaProfiler.so/.dylib (default: locate the +# locally built debug artifact under ddprof-lib/build/lib) +# REFCHAINS_JAR path to refchains_repro.jar (default: build/rebuild via +# ./gradlew :ddprof-stresstest:reproJar) +# REFCHAINS_ENABLED "true" (default) or "false" - toggles the +# referencechains=... agent sub-option itself, for A/B +# comparison against a baseline with the feature off. +# REFCHAINS_ARGS extra referencechains=... sub-options appended after the +# baked-in defaults below (e.g. "hops=32") +# REFCHAINS_JAVA_HOME JDK home to launch the repro with (default: "java" on +# PATH). Lets you A/B a specific JDK, e.g. to reproduce a +# version-specific VMStructs bug: REFCHAINS_JAVA_HOME=/usr/local/sdkman/candidates/java/25.0.3-tem +# REFCHAINS_GC GC to force via -XX:+UseGC (default: JVM's own +# ergonomic default - no flag added). Accepts either the +# bare name ("Serial", "G1", "Parallel", "Shenandoah", +# "Z", "Epsilon") or the full flag name ("SerialGC"). +# e.g. REFCHAINS_GC=Serial or REFCHAINS_GC=ZGC + +set -euo pipefail + +HERE="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +ROOT="$( cd "${HERE}/.." >/dev/null 2>&1 && pwd )" + +JFR_OUT="${1:-/tmp/refchains_repro.jfr}" +DURATION_SECONDS="${2:-}" + +JAVA_BIN="java" +if [ -n "${REFCHAINS_JAVA_HOME:-}" ]; then + JAVA_BIN="${REFCHAINS_JAVA_HOME}/bin/java" + if [ ! -x "${JAVA_BIN}" ]; then + echo "FAIL: ${JAVA_BIN} not found/executable - check REFCHAINS_JAVA_HOME" >&2 + exit 1 + fi +fi +echo "Using JDK: $("${JAVA_BIN}" -version 2>&1 | head -1)" + +GC_JVM_OPTS=() +if [ -n "${REFCHAINS_GC:-}" ]; then + case "${REFCHAINS_GC}" in + *GC) GC_FLAG="-XX:+Use${REFCHAINS_GC}" ;; + *) GC_FLAG="-XX:+Use${REFCHAINS_GC}GC" ;; + esac + GC_JVM_OPTS=("${GC_FLAG}") + echo "Forcing GC: ${GC_FLAG}" +else + echo "GC: JVM ergonomic default" +fi + +if [ -z "${REFCHAINS_SO:-}" ]; then + # Prefer the debug artifact: this repro is built with `assembleDebug`, and a + # stale `release/` build left over from an earlier `assembleAll` sits under + # the same build/lib tree. A bare find|head -1 picks whichever the directory + # walk hits first (observed: release), silently running the OLD binary and + # making source edits appear to have no effect - so match debug/ first and + # only fall back to any build if no debug artifact exists. + REFCHAINS_SO=$(find "${ROOT}/ddprof-lib/build/lib" \ + \( -name 'libjavaProfiler.so' -o -name 'libjavaProfiler.dylib' \) \ + -path '*/debug/*' 2>/dev/null | head -1) + if [ -z "${REFCHAINS_SO}" ]; then + REFCHAINS_SO=$(find "${ROOT}/ddprof-lib/build/lib" \ + \( -name 'libjavaProfiler.so' -o -name 'libjavaProfiler.dylib' \) \ + 2>/dev/null | head -1) + fi +fi +if [ -z "${REFCHAINS_SO}" ] || [ ! -f "${REFCHAINS_SO}" ]; then + echo "FAIL:libjavaProfiler.so/.dylib not found - build one first: ./gradlew :ddprof-lib:debugSharedLibrary" >&2 + exit 1 +fi +echo "Using agent: ${REFCHAINS_SO}" + +if [ -z "${REFCHAINS_JAR:-}" ]; then + REFCHAINS_JAR="${ROOT}/ddprof-stresstest/build/libs/refchains_repro.jar" + if [ ! -f "${REFCHAINS_JAR}" ]; then + echo "refchains_repro.jar not present - building it" + ( cd "${ROOT}" && ./gradlew :ddprof-stresstest:reproJar -q --no-daemon ) + fi +fi +if [ ! -f "${REFCHAINS_JAR}" ]; then + echo "FAIL:refchains_repro.jar unavailable" >&2 + exit 1 +fi +echo "Using jar: ${REFCHAINS_JAR}" + +# No firstpassbudget here: the tracker auto-scales the first pass's budget +# from budget=4000 (currently x50, capped at 200000 - referenceChains.h) so +# it can actually reach this app's leaking population. Pass +# REFCHAINS_ARGS="firstpassbudget=N" to override that auto-scaled default. +REFCHAINS_ENABLED="${REFCHAINS_ENABLED:-true}" +REFERENCECHAINS_OPTS="${REFCHAINS_ENABLED}:hops=64:budget=10000:ttl=120000:framecap=2000000:pausetarget=500:painbudget=100" +if [ -n "${REFCHAINS_ARGS:-}" ]; then + REFERENCECHAINS_OPTS="${REFERENCECHAINS_OPTS}:${REFCHAINS_ARGS}" +fi + +echo "JFR output: ${JFR_OUT}" +echo "referencechains enabled: ${REFCHAINS_ENABLED}" +rm -f "${JFR_OUT}" + +JAVA_LOG_OPTS=() +if [ -n "${DURATION_SECONDS}" ]; then + # Unified-logging safepoint+GC pause detail, kept per-run alongside the JFR file so + # compare-refchains-repro.sh can parse "Total time for which application threads were + # stopped" (STW safepoint pauses, includes GC) and per-GC pause times out of one file. + SAFEPOINT_LOG="${JFR_OUT}.safepoint.log" + rm -f "${SAFEPOINT_LOG}" + JAVA_LOG_OPTS=(-Xlog:safepoint*=info,gc*=info:file="${SAFEPOINT_LOG}":time,uptime,level,tags) + echo "Safepoint/GC log: ${SAFEPOINT_LOG}" +fi + +exec "${JAVA_BIN}" \ + "${JAVA_LOG_OPTS[@]}" \ + "${GC_JVM_OPTS[@]}" \ + -agentpath:"${REFCHAINS_SO}"=start,memory=64:l,generations=true,referencechains=${REFERENCECHAINS_OPTS},jfr,file="${JFR_OUT}" \ + -jar "${REFCHAINS_JAR}" "${JFR_OUT}" "${REFCHAINS_SO}" ${DURATION_SECONDS} diff --git a/utils/sweep-refchains-all.sh b/utils/sweep-refchains-all.sh new file mode 100644 index 0000000000..76cd7b7f73 --- /dev/null +++ b/utils/sweep-refchains-all.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# One-factor-at-a-time (OFAT) sweep across all four referencechains budget +# knobs (budget, firstpassbudget, pausetarget, painbudget): for each knob, +# sweeps its own value list while holding the other three at their built-in +# defaults (i.e. omitted from the sub-option string, letting the tracker's +# own defaults/auto-scaling apply - see referenceChains.h). A full cartesian +# product across all four knobs is combinatorially far more runs for little +# extra insight over OFAT at this stage, so this is deliberately OFAT, not a +# grid search. +# +# Writes one combined CSV (knob,param_value,) covering all four +# sweeps, then renders it via refchains-report-multi.py into a single +# Chart.js-based HTML report with one tab per knob. +# +# Usage: sweep-refchains-all.sh [duration-seconds] +# +# Env vars: +# REFCHAINS_SO/REFCHAINS_JAR/REFCHAINS_JAVA_HOME/REFCHAINS_GC same as +# run-refchains-repro.sh - passed through unchanged +# to every run so all sweeps stay comparable. +# REFCHAINS_ARGS extra referencechains=... sub-options held fixed +# across every run (in addition to the defaults +# baked into run-refchains-repro.sh). + +set -euo pipefail + +HERE="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +DURATION_SECONDS="${1:-60}" + +KNOBS=(budget firstpassbudget pausetarget painbudget) +declare -A KNOB_VALUES=( + [budget]="${REFCHAINS_SWEEP_BUDGET_VALUES:-1000,4000,10000,40000,100000}" + [firstpassbudget]="${REFCHAINS_SWEEP_FIRSTPASSBUDGET_VALUES:-2000,10000,50000,200000}" + [pausetarget]="${REFCHAINS_SWEEP_PAUSETARGET_VALUES:-50,100,250,500,1000}" + [painbudget]="${REFCHAINS_SWEEP_PAINBUDGET_VALUES:-10,50,100,500,1000}" +) + +WORKDIR="$(mktemp -d /tmp/refchains_sweep_all.XXXXXX)" +CSV="${WORKDIR}/sweep.csv" + +JDK_DESC="${REFCHAINS_JAVA_HOME:-java on PATH}" +GC_DESC="${REFCHAINS_GC:-JVM ergonomic default}" + +TOTAL_RUNS=0 +for k in "${KNOBS[@]}"; do + IFS=',' read -r -a vals <<< "${KNOB_VALUES[${k}]}" + TOTAL_RUNS=$((TOTAL_RUNS + ${#vals[@]})) +done + +echo "OFAT sweep across knobs: ${KNOBS[*]}" +echo "JDK: ${JDK_DESC}" +echo "GC: ${GC_DESC}" +echo "Duration per point: ${DURATION_SECONDS}s, total runs: ${TOTAL_RUNS} (~$((TOTAL_RUNS * DURATION_SECONDS / 60)) min)" +echo "Working directory: ${WORKDIR}" + +echo "knob,param_value,entries_per_sec,avg_round_ms,max_round_ms,heap_growth_mb,stw_count,stw_total_s,stw_avg_s,stw_max_s,peak_rss_kb,wall_seconds,chain_count,abandoned_count,time_to_first_chain_s" > "${CSV}" + +# Same metrics extraction as sweep-refchains-budgets.sh/compare-refchains-repro.sh - +# kept in sync across all three since they read the same stdout/-Xlog sources. +parse_run() { + local label="$1" + local stdout="${WORKDIR}/${label}.stdout.log" + local safepoint_log="${WORKDIR}/${label}.jfr.safepoint.log" + local peak_rss_kb + peak_rss_kb=$(cat "${WORKDIR}/${label}.peak_rss_kb" 2>/dev/null || echo 0) + + local metrics_line + metrics_line=$(grep '^\[metrics\]' "${stdout}" || true) + local wall_seconds entries_per_sec avg_round_ms max_round_ms heap_growth_mb + wall_seconds=$(echo "${metrics_line}" | grep -oE 'wallSeconds=[0-9.]+' | cut -d= -f2) + entries_per_sec=$(echo "${metrics_line}" | grep -oE 'entriesPerSec=[0-9.]+' | cut -d= -f2) + avg_round_ms=$(echo "${metrics_line}" | grep -oE 'avgRoundMs=[0-9.]+' | cut -d= -f2) + max_round_ms=$(echo "${metrics_line}" | grep -oE 'maxRoundMs=[0-9.]+' | cut -d= -f2) + heap_growth_mb=$(echo "${metrics_line}" | grep -oE 'heapGrowthMb=-?[0-9]+' | cut -d= -f2) + + local stw_stats + stw_stats=$(grep -oE 'Safepoint "[^"]+".*Total: [0-9]+ ns' "${safepoint_log}" 2>/dev/null \ + | grep -oE 'Total: [0-9]+ ns' \ + | grep -oE '[0-9]+' \ + | awk '{sec=$1/1e9; sum+=sec; if(sec>max) max=sec; n+=1} END {if(n>0) printf "%d %.4f %.4f %.4f", n, sum, sum/n, max}') + if [ -z "${stw_stats}" ]; then + stw_stats=$(grep -oE 'Total time for which application threads were stopped: [0-9.]+ seconds' "${safepoint_log}" 2>/dev/null \ + | grep -oE '[0-9.]+' \ + | awk '{sec=$1; sum+=sec; if(sec>max) max=sec; n+=1} END {if(n>0) printf "%d %.4f %.4f %.4f", n, sum, sum/n, max}') + fi + read -r stw_count stw_total stw_avg stw_max <<< "${stw_stats:-0 0 0 0}" + + # jfr print/summary require a ".jfr" extension - the repro app's periodic + # dump target (".snapshot", see ReferenceChainLeakDemo's own header + # comment on SNAPSHOT_SUFFIX) doesn't have one, so copy it under a .jfr + # name before handing it to refchains-jfr-metrics.py. + local snapshot="${WORKDIR}/${label}.jfr.snapshot" + local chain_count=0 abandoned_count=0 time_to_first_chain_s=-1 + if [ -f "${snapshot}" ]; then + local tmp_jfr="${WORKDIR}/${label}.metrics.jfr" + cp "${snapshot}" "${tmp_jfr}" + local chain_stats + chain_stats=$(python3 "${HERE}/refchains-jfr-metrics.py" "${tmp_jfr}" 2>/dev/null || echo "0 0 -1") + read -r chain_count abandoned_count time_to_first_chain_s <<< "${chain_stats}" + rm -f "${tmp_jfr}" + fi + + echo "${entries_per_sec:-0} ${avg_round_ms:-0} ${max_round_ms:-0} ${heap_growth_mb:-0} ${stw_count} ${stw_total} ${stw_avg} ${stw_max} ${peak_rss_kb} ${wall_seconds:-0} ${chain_count} ${abandoned_count} ${time_to_first_chain_s}" +} + +run_idx=0 +for knob in "${KNOBS[@]}"; do + IFS=',' read -r -a vals <<< "${KNOB_VALUES[${knob}]}" + for value in "${vals[@]}"; do + run_idx=$((run_idx + 1)) + label="${knob}_${value}" + jfr="${WORKDIR}/${label}.jfr" + echo + echo "=== [${run_idx}/${TOTAL_RUNS}] ${knob}=${value} ===" + + REFCHAINS_ENABLED="true" REFCHAINS_ARGS="${knob}=${value}${REFCHAINS_ARGS:+:${REFCHAINS_ARGS}}" \ + "${HERE}/run-refchains-repro.sh" "${jfr}" "${DURATION_SECONDS}" \ + > "${WORKDIR}/${label}.stdout.log" 2>&1 & + pid=$! + + peak_rss_kb=0 + while kill -0 "${pid}" 2>/dev/null; do + rss=$(ps -o rss= -p "${pid}" 2>/dev/null | tr -d ' ' || true) + if [ -n "${rss}" ] && [ "${rss}" -gt "${peak_rss_kb}" ]; then + peak_rss_kb="${rss}" + fi + sleep 1 + done + wait "${pid}" || echo "WARN: run ${knob}=${value} exited non-zero" + echo "${peak_rss_kb}" > "${WORKDIR}/${label}.peak_rss_kb" + + read -r entries avg_round max_round heap_growth stw_count stw_total stw_avg stw_max rss wall \ + chain_count abandoned_count time_to_first_chain \ + <<< "$(parse_run "${label}")" + echo "${knob},${value},${entries},${avg_round},${max_round},${heap_growth},${stw_count},${stw_total},${stw_avg},${stw_max},${rss},${wall},${chain_count},${abandoned_count},${time_to_first_chain}" >> "${CSV}" + done +done + +echo +echo "CSV written to: ${CSV}" + +REPORT_HTML="${WORKDIR}/report.html" +python3 "${HERE}/refchains-report-multi.py" "${CSV}" "${REPORT_HTML}" \ + --jdk "${JDK_DESC}" --gc "${GC_DESC}" --duration "${DURATION_SECONDS}" + +echo "HTML report written to: ${REPORT_HTML}" +echo "Raw logs kept in: ${WORKDIR}" diff --git a/utils/sweep-refchains-budgets.sh b/utils/sweep-refchains-budgets.sh new file mode 100644 index 0000000000..1d795fb6e5 --- /dev/null +++ b/utils/sweep-refchains-budgets.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# +# Sweeps referencechains=... budget-related sub-options (budget, +# firstpassbudget, pausetarget, painbudget) across a matrix of values and +# runs the repro app (run-refchains-repro.sh) once per combination, to +# answer: how does each budget knob trade off throughput/round-latency, +# STW pause time, and heap/RSS growth? +# +# This is the multi-point sibling of compare-refchains-repro.sh (which only +# ever does one on/off comparison at a single fixed set of sub-options). +# Reuses the same repro app and the same metric sources (stdout +# "[metrics]" line + -Xlog safepoint/GC log + peak RSS sampling), but drives +# N runs instead of 2 and writes them to a CSV plus a self-contained HTML +# report with charts (see refchains-report.py). +# +# Usage: sweep-refchains-budgets.sh [duration-seconds] +# +# Env vars: +# REFCHAINS_SWEEP_PARAM which sub-option to sweep: "budget" (default), +# "firstpassbudget", "pausetarget", or "painbudget". +# REFCHAINS_SWEEP_VALUES comma-separated values for that sub-option +# (default depends on REFCHAINS_SWEEP_PARAM - see +# below). +# REFCHAINS_SO/REFCHAINS_JAR/REFCHAINS_JAVA_HOME/REFCHAINS_GC same as +# run-refchains-repro.sh - passed through unchanged +# to every run so they stay comparable. +# REFCHAINS_ARGS extra referencechains=... sub-options held fixed +# across the whole sweep (in addition to the +# defaults baked into run-refchains-repro.sh). + +set -euo pipefail + +HERE="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +DURATION_SECONDS="${1:-120}" +SWEEP_PARAM="${REFCHAINS_SWEEP_PARAM:-budget}" + +case "${SWEEP_PARAM}" in + budget) DEFAULT_VALUES="1000,4000,10000,40000,100000" ;; + firstpassbudget) DEFAULT_VALUES="2000,10000,50000,200000" ;; + pausetarget) DEFAULT_VALUES="50,100,250,500,1000" ;; + painbudget) DEFAULT_VALUES="10,50,100,500,1000" ;; + *) + echo "FAIL: unknown REFCHAINS_SWEEP_PARAM=${SWEEP_PARAM} (expected: budget, firstpassbudget, pausetarget, painbudget)" >&2 + exit 1 + ;; +esac +IFS=',' read -r -a SWEEP_VALUES <<< "${REFCHAINS_SWEEP_VALUES:-${DEFAULT_VALUES}}" + +WORKDIR="$(mktemp -d /tmp/refchains_sweep.XXXXXX)" +CSV="${WORKDIR}/sweep.csv" + +JDK_DESC="${REFCHAINS_JAVA_HOME:-java on PATH}" +GC_DESC="${REFCHAINS_GC:-JVM ergonomic default}" + +echo "Sweeping referencechains ${SWEEP_PARAM} over: ${SWEEP_VALUES[*]}" +echo "JDK: ${JDK_DESC}" +echo "GC: ${GC_DESC}" +echo "Duration per point: ${DURATION_SECONDS}s" +echo "Working directory: ${WORKDIR}" + +echo "param_value,entries_per_sec,avg_round_ms,max_round_ms,heap_growth_mb,stw_count,stw_total_s,stw_avg_s,stw_max_s,peak_rss_kb,wall_seconds" > "${CSV}" + +# Parses one run's logs into a single space-separated metrics line - same +# fields/sources as compare-refchains-repro.sh's parse_variant, factored out +# here since this script iterates N runs instead of a fixed on/off pair. +parse_run() { + local label="$1" + local stdout="${WORKDIR}/${label}.stdout.log" + local safepoint_log="${WORKDIR}/${label}.jfr.safepoint.log" + local peak_rss_kb + peak_rss_kb=$(cat "${WORKDIR}/${label}.peak_rss_kb" 2>/dev/null || echo 0) + + local metrics_line + metrics_line=$(grep '^\[metrics\]' "${stdout}" || true) + local wall_seconds entries_per_sec avg_round_ms max_round_ms heap_growth_mb + wall_seconds=$(echo "${metrics_line}" | grep -oE 'wallSeconds=[0-9.]+' | cut -d= -f2) + entries_per_sec=$(echo "${metrics_line}" | grep -oE 'entriesPerSec=[0-9.]+' | cut -d= -f2) + avg_round_ms=$(echo "${metrics_line}" | grep -oE 'avgRoundMs=[0-9.]+' | cut -d= -f2) + max_round_ms=$(echo "${metrics_line}" | grep -oE 'maxRoundMs=[0-9.]+' | cut -d= -f2) + heap_growth_mb=$(echo "${metrics_line}" | grep -oE 'heapGrowthMb=-?[0-9]+' | cut -d= -f2) + + # Two -Xlog safepoint line shapes across JDK versions: + # - JDK 17+: `Safepoint "name", ... Total: N ns` (one line, everything on it) + # - JDK <=16 (e.g. 11): `Total time for which application threads were + # stopped: N seconds, Stopping threads took: ...` (separate summary line, + # no per-name "Safepoint" prefix, value already in seconds not ns) + # Try the ns-based JDK17+ shape first; fall back to the seconds-based one. + local stw_stats + stw_stats=$(grep -oE 'Safepoint "[^"]+".*Total: [0-9]+ ns' "${safepoint_log}" 2>/dev/null \ + | grep -oE 'Total: [0-9]+ ns' \ + | grep -oE '[0-9]+' \ + | awk '{sec=$1/1e9; sum+=sec; if(sec>max) max=sec; n+=1} END {if(n>0) printf "%d %.4f %.4f %.4f", n, sum, sum/n, max}') + if [ -z "${stw_stats}" ]; then + stw_stats=$(grep -oE 'Total time for which application threads were stopped: [0-9.]+ seconds' "${safepoint_log}" 2>/dev/null \ + | grep -oE '[0-9.]+' \ + | awk '{sec=$1; sum+=sec; if(sec>max) max=sec; n+=1} END {if(n>0) printf "%d %.4f %.4f %.4f", n, sum, sum/n, max}') + fi + read -r stw_count stw_total stw_avg stw_max <<< "${stw_stats:-0 0 0 0}" + + echo "${entries_per_sec:-0} ${avg_round_ms:-0} ${max_round_ms:-0} ${heap_growth_mb:-0} ${stw_count} ${stw_total} ${stw_avg} ${stw_max} ${peak_rss_kb} ${wall_seconds:-0}" +} + +for value in "${SWEEP_VALUES[@]}"; do + label="${SWEEP_PARAM}_${value}" + jfr="${WORKDIR}/${label}.jfr" + echo + echo "=== running ${SWEEP_PARAM}=${value} ===" + + REFCHAINS_ENABLED="true" REFCHAINS_ARGS="${SWEEP_PARAM}=${value}${REFCHAINS_ARGS:+:${REFCHAINS_ARGS}}" \ + "${HERE}/run-refchains-repro.sh" "${jfr}" "${DURATION_SECONDS}" \ + > "${WORKDIR}/${label}.stdout.log" 2>&1 & + pid=$! + + peak_rss_kb=0 + while kill -0 "${pid}" 2>/dev/null; do + rss=$(ps -o rss= -p "${pid}" 2>/dev/null | tr -d ' ' || true) + if [ -n "${rss}" ] && [ "${rss}" -gt "${peak_rss_kb}" ]; then + peak_rss_kb="${rss}" + fi + sleep 1 + done + wait "${pid}" || echo "WARN: run ${SWEEP_PARAM}=${value} exited non-zero" + echo "${peak_rss_kb}" > "${WORKDIR}/${label}.peak_rss_kb" + + read -r entries avg_round max_round heap_growth stw_count stw_total stw_avg stw_max rss wall \ + <<< "$(parse_run "${label}")" + echo "${value},${entries},${avg_round},${max_round},${heap_growth},${stw_count},${stw_total},${stw_avg},${stw_max},${rss},${wall}" >> "${CSV}" +done + +echo +echo "CSV written to: ${CSV}" + +REPORT_HTML="${WORKDIR}/report.html" +python3 "${HERE}/refchains-report.py" "${CSV}" "${REPORT_HTML}" \ + --param "${SWEEP_PARAM}" --jdk "${JDK_DESC}" --gc "${GC_DESC}" --duration "${DURATION_SECONDS}" + +echo "HTML report written to: ${REPORT_HTML}" +echo "Raw logs kept in: ${WORKDIR}" diff --git a/utils/vendor/chart.umd.min.js b/utils/vendor/chart.umd.min.js new file mode 100644 index 0000000000..008464faae --- /dev/null +++ b/utils/vendor/chart.umd.min.js @@ -0,0 +1,14 @@ +/*! + * Chart.js v4.5.1 + * https://www.chartjs.org + * (c) 2025 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Jo},get Decimation(){return ta},get Filler(){return ba},get Legend(){return Ma},get SubTitle(){return Pa},get Title(){return ka},get Tooltip(){return Na}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!function(t){return"symbol"==typeof t||"object"==typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const n=e.length;let o=0,a=n;if(t._sorted){const{iScale:r,vScale:l,_parsed:h}=t,c=t.dataset&&t.dataset.options?t.dataset.options.spanGaps:null,d=r.axis,{min:u,max:f,minDefined:g,maxDefined:p}=r.getUserBounds();if(g){if(o=Math.min(it(h,d,u).lo,i?n:it(e,d,r.getPixelForValue(u)).lo),c){const t=h.slice(0,o+1).reverse().findIndex((t=>!s(t[l.axis])));o-=Math.max(0,t)}o=Z(o,0,n-1)}if(p){let t=Math.max(it(h,r.axis,f,!0).hi+1,i?0:it(e,d,r.getPixelForValue(f),!0).hi+1);if(c){const e=h.slice(t-1).findIndex((t=>!s(t[l.axis])));t+=Math.max(0,e)}a=Z(t,o,n)-o}else a=n-o}return{start:o,count:a}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class xt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var bt=new xt; +/*! + * @kurkle/color v0.3.2 + * https://github.com/kurkle/color#readme + * (c) 2023 Jukka Kurkela + * Released under the MIT License + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Jt{constructor(t){if(t instanceof Jt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Jt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Zt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Zt(t)?t:new Jt(t)}function te(t){return Zt(t)?t:new Jt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function xe(t,e){return me(t).getPropertyValue(e)}const be=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=be[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=t&&ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Me(t.height*s),o=Me(t.width*s);t.height=Me(t.height),t.width=Me(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};fe()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch(t){}return t}();function Pe(t,e){const i=xe(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Ze(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Ze(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Ze(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e)||"constructor"===e)return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Je(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Ze(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Je(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const xi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,bi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(xi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(bi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:J,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hb||l(n,x,p)&&0!==r(n,x),v=()=>!b||0===r(o,p)||l(o,x,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==x&&(b=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,x=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r!s(t[e.axis])));n.lo-=Math.max(0,a);const r=i.slice(n.hi).findIndex((t=>!s(t[e.axis])));n.hi+=Math.max(0,r)}return n}if(o._sharedOptions){const t=a[0],s="function"==typeof t.getRange&&t.getRange(e);if(s){const t=r(a,e,i-s),n=r(a,e,i+s);return{lo:t.lo,hi:n.hi}}}}return{lo:0,hi:a.length-1}}function $i(t,e,i,s,n){const o=t.getSortedVisibleDatasetMetas(),a=i[e];for(let t=0,i=o.length;t{t[a]&&t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Ki={evaluateInteractionItems:$i,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?Yi(t,n,o,s,a):Xi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?Yi(t,n,o,s,a):Xi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tYi(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Xi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>qi(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>qi(t,ve(e,t),"y",i.intersect,s)}};const Gi=["left","top","right","bottom"];function Ji(t,e){return t.filter((t=>t.pos===e))}function Zi(t,e){return t.filter((t=>-1===Gi.indexOf(t.pos)&&t.box.axis===e))}function Qi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function ts(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!Gi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function os(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Qi(Ji(e,"left"),!0),n=Qi(Ji(e,"right")),o=Qi(Ji(e,"top"),!0),a=Qi(Ji(e,"bottom")),r=Zi(e,"x"),l=Zi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ji(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);is(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=ts(l.concat(h),d);os(r.fullSize,g,d,p),os(l,g,d,p),os(h,g,d,p)&&os(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),rs(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,rs(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class hs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class cs extends hs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const ds="$chartjs",us={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},fs=t=>null===t||""===t;const gs=!!Se&&{passive:!0};function ps(t,e,i){t&&t.canvas&&t.canvas.removeEventListener(e,i,gs)}function ms(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function xs(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ms(i.addedNodes,s),e=e&&!ms(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function bs(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ms(i.removedNodes,s),e=e&&!ms(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const _s=new Map;let ys=0;function vs(){const t=window.devicePixelRatio;t!==ys&&(ys=t,_s.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function Ms(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){_s.size||window.addEventListener("resize",vs),_s.set(t,e)}(t,o),a}function ws(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){_s.delete(t),_s.size||window.removeEventListener("resize",vs)}(t)}function ks(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=us[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t&&t.addEventListener(e,i,gs)}(s,e,n),n}class Ss extends hs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[ds]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",fs(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(fs(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[ds])return!1;const i=e[ds].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[ds],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:xs,detach:bs,resize:Ms}[e]||ks;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:ws,detach:ws,resize:ws}[e]||ps)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=t&&ge(t);return!(!e||!e.isConnected)}}function Ps(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?cs:Ss}var Ds=Object.freeze({__proto__:null,BasePlatform:hs,BasicPlatform:cs,DomPlatform:Ss,_detectPlatform:Ps});const Cs="transparent",Os={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Cs),n=s.valid&&Qt(e||Cs);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class As{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Os[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new As(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(bt.add(this._chart,i),!0):void 0}}function Ls(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Es(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function Vs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Ws(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Ns=t=>"reset"===t||"none"===t,Hs=(t,e)=>e?t:Object.assign({},t);class js{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Is(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Ws(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Bs(t,"x")),o=e.yAxisID=l(i.yAxisID,Bs(t,"y")),a=e.rAxisID=l(i.rAxisID,Bs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Ws(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e)){const t=this._cachedMeta;this._data=function(t,e){const{iScale:i,vScale:s}=e,n="x"===i.axis?"x":"y",o="x"===s.axis?"x":"y",a=Object.keys(t),r=new Array(a.length);let l,h,c;for(l=0,h=a.length;l0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Es(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Hs(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Ts(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Ns(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Ns(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Ns(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function Ys(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for(Us(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,qs=(t,e)=>Math.min(e||t,t);function Ks(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Js(t){return t.drawTicks?t.tickLength:0}function Zs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Qs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class tn extends $s{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=Z(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Js(t.grid)-e.padding-Zs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(Z((h.highest.height+6)/o,-1,1)),Math.asin(Z(a/r,-1,1))-Math.asin(Z(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Zs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Js(n)+o):(t.height=this.maxHeight,t.width=Js(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Js(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,x=function(t){return Ae(i,t,p)};let b,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)b=x(this.bottom),w=this.bottom-u,S=b-m,D=x(t.top)+m,O=t.bottom;else if("bottom"===a)b=x(this.top),D=t.top,O=x(t.bottom)-m,w=b+m,S=this.top+u;else if("left"===a)b=x(this.right),M=this.right-u,k=b-m,P=x(t.left)+m,C=t.right;else if("right"===a)b=x(this.left),P=t.left,C=x(t.right)-m,M=b+m,k=this.left+u;else if("x"===e){if("center"===a)b=x((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];b=x(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=b+m,S=w+u}else if("y"===e){if("center"===a)b=x((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];b=x(this.chart.scales[t].getPixelForValue(e))}M=b-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_0&&(o-=s/2)}d={left:o,top:n,width:s+e.width,height:i+e.height,color:t.backdropColor}}x.push({label:v,font:P,textOffset:O,options:{rotation:m,color:i,strokeColor:o,strokeWidth:h,textAlign:f,textBaseline:A,translation:[M,w],backdrop:d}})}return x}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options;if(-$(this.labelRotation))return"top"===t?"left":"right";let i="center";return"start"===e.align?i="left":"end"===e.align?i="right":"inner"===e.align&&(i="inner"),i}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:i,mirror:s,padding:n}}=this.options,o=t+n,a=this._getLabelSizes().widest.width;let r,l;return"left"===e?s?(l=this.right+n,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l+=a)):(l=this.right-o,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l=this.left)):"right"===e?s?(l=this.left+n,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l-=a)):(l=this.left+o,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l=this.right)):r="right",{textAlign:r,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:i,top:s,width:n,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(i,s,n,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const i=this.ticks.findIndex((e=>e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class sn{constructor(){this.controllers=new en(js,"datasets",!0),this.elements=new en($s,"elements"),this.plugins=new en(Object,"plugins"),this.scales=new en(tn,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function an(t,e){return e||!1!==t?!0===t?{}:t:null}function rn(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function ln(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function hn(t){if("x"===t||"y"===t||"r"===t)return t}function cn(t,...e){if(hn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&hn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function dn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function un(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=ln(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=cn(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return dn(t,"x",i[0])||dn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=b(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||ln(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),b(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];b(e,[ue.scales[e.type],ue.scale])})),a}function fn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=un(t,e)}function gn(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const pn=new Map,mn=new Set;function xn(t,e){let i=pn.get(t);return i||(i=e(),pn.set(t,i),mn.add(i)),i}const bn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class _n{constructor(t){this._config=function(t){return(t=t||{}).data=gn(t.data),fn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=gn(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),fn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return xn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return xn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return xn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return xn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>bn(r,t,e)))),e.forEach((t=>bn(r,s,t))),e.forEach((t=>bn(r,re[n]||{},t))),e.forEach((t=>bn(r,ue,t))),e.forEach((t=>bn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),mn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=yn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||vn(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=yn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function yn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const vn=t=>o(t)&&Object.getOwnPropertyNames(t).some((e=>S(t[e])));const Mn=["top","bottom","left","right","chartArea"];function wn(t,e){return"top"===t||"bottom"===t||-1===Mn.indexOf(t)&&"x"===e}function kn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function Sn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function Pn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Dn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Cn={},On=t=>{const e=Dn(t);return Object.values(Cn).filter((t=>t.canvas===e)).pop()};function An(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}class Tn{static defaults=ue;static instances=Cn;static overrides=re;static registry=nn;static version="4.5.1";static getChart=On;static register(...t){nn.add(...t),Ln()}static unregister(...t){nn.remove(...t),Ln()}constructor(t,e){const s=this.config=new _n(e),n=Dn(t),o=On(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||Ps(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new on,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Cn[this.id]=this,r&&l?(bt.listen(this,"complete",Sn),bt.listen(this,"progress",Pn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return nn}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return bt.stop(this),this}resize(t,e){bt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=cn(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=cn(o,n),r=l(n.type,e.dtype);void 0!==n.position&&wn(n.position,a)===wn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(nn.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{ls.configure(this,t,t.options),ls.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(kn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{ls.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){An(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;ls.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i={meta:t,index:t.index,cancelable:!0},s=Ni(this,t);!1!==this.notifyPlugins("beforeDatasetDraw",i)&&(s&&Ie(e,s),t.controller.draw(),s&&ze(e),i.cancelable=!1,this.notifyPlugins("afterDatasetDraw",i))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Ki.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),bt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Ln(){return u(Tn.instances,(t=>t._plugins.invalidate()))}function En(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class Rn{static override(t){Object.assign(Rn.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return En()}parse(){return En()}format(){return En()}add(){return En()}diff(){return En()}startOf(){return En()}endOf(){return En()}}var In={_date:Rn};function zn(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Vn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data,{labels:{pointStyle:i,textAlign:s,color:n,useBorderRadius:o,borderRadius:a}}=t.legend.options;return e.labels.length&&e.datasets.length?e.labels.map(((e,r)=>{const l=t.getDatasetMeta(0).controller.getStyle(r);return{text:e,fillStyle:l.backgroundColor,fontColor:n,hidden:!t.getDataVisibility(r),lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:l.borderWidth,strokeStyle:l.borderColor,textAlign:s,pointStyle:i,borderRadius:o&&(a||l.borderRadius),index:r}})):[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nJ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>J(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),x=g(C,h,d),b=g(C+E,c,u);s=(p-x)/2,n=(m-b)/2,o=-(p+x)/2,a=-(m+b)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),x=(i.width-o)/f,b=(i.height-o)/g,_=Math.max(Math.min(x,b)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Un=Object.freeze({__proto__:null,BarController:class extends js{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Vn(t,e,i,s)}parseArrayData(t,e,i,s){return Vn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=this._cachedMeta.controller.getParsed(e),l=r&&r[i.axis],h=t=>{const e=t._parsed.find((t=>t[i.axis]===l)),n=e&&e[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!h(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getAxisCount(){return this._getAxis().length}getFirstScaleIdForIndexAxis(){const t=this.chart.scales,e=this.chart.options.indexAxis;return Object.keys(t).filter((i=>t[i].axis===e)).shift()}_getAxis(){const t={},e=this.getFirstScaleIdForIndexAxis();for(const i of this.chart.data.datasets)t[l("x"===this.chart.options.indexAxis?i.xAxisID:i.yAxisID,e)]=!0;return Object.keys(t)}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(x-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);x=Math.max(Math.min(x,h),o),d=x+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(x))}if(x===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;x+=t,u-=t}return{size:u,base:x,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;const c=this._getAxisCount();if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,d="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=x?g:{};if(i=b){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),x||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends $n{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:Yn,RadarController:class extends js{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>x,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),b||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Xn(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return Z(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:Z(n.innerStart,0,a),innerEnd:Z(n.innerEnd,0,a)}}function qn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function Kn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,x=n-p-f,{outerStart:b,outerEnd:_,innerStart:y,innerEnd:v}=Xn(e,u,d,x-m),M=d-b,w=d-_,k=m+b/M,S=x-_/w,P=u+y,D=u+v,O=m+y/P,A=x-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=qn(w,S,a,r);t.arc(e.x,e.y,_,S,x+E)}const i=qn(D,x,a,r);if(t.lineTo(i.x,i.y),v>0){const e=qn(D,A,a,r);t.arc(e.x,e.y,v,x+E,A+Math.PI)}const s=(x-v/u+(m+y/u))/2;if(t.arc(a,r,u,x-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=qn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=qn(M,m,a,r);if(t.lineTo(n.x,n.y),b>0){const e=qn(M,k,a,r);t.arc(e.x,e.y,b,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Gn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u,borderRadius:f}=l,g="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,g?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let p=e.endAngle;if(o){Kn(t,e,i,s,p,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,p),l.selfJoin&&p-a>=C&&0===f&&"miter"!==c&&function(t,e,i){const{startAngle:s,x:n,y:o,outerRadius:a,innerRadius:r,options:l}=e,{borderWidth:h,borderJoinStyle:c}=l,d=Math.min(h/a,G(s-i));if(t.beginPath(),t.arc(n,o,a-h/2,s+d/2,i-d/2),r>0){const e=Math.min(h/r,G(s-i));t.arc(n,o,r+h/2,i-e/2,s+e/2,!0)}else{const e=Math.min(h/2,a*G(s-i));if("round"===c)t.arc(n,o,e,i-C/2,s+C/2,!0);else if("bevel"===c){const a=2*e*e,r=-a*Math.cos(i+C/2)+n,l=-a*Math.sin(i+C/2)+o,h=a*Math.cos(s+C/2)+n,c=a*Math.sin(s+C/2)+o;t.lineTo(r,l),t.lineTo(h,c)}}t.closePath(),t.moveTo(0,0),t.rect(0,0,t.canvas.width,t.canvas.height),t.clip("evenodd")}(t,e,p),o||(Kn(t,e,i,s,p,n),t.stroke())}function Jn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Qn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[b(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[b(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(x*m+e)/++x):(_(),t.lineTo(e,i),u=s,x=0,f=g=i),p=i}_()}function io(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?eo:to}const so="function"==typeof Path2D;function no(t,e,i,s){so&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Jn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=io(e);for(const r of n)Jn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class oo extends $s{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a),g=J(n,a,r)&&a!==r,p=f>=O||g,m=tt(o,h+u,c+u);return p&&m}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){Kn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function mo(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,x=!s(a),b=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!x&&!b)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),x&&b&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=x?a:M,w=b?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(x&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return b&&u&&w!==r?i.length&&V(i[i.length-1].value,r,xo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):b&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class _o extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const yo=t=>Math.floor(z(t)),vo=(t,e)=>Math.pow(10,yo(t)+e);function Mo(t){return 1===t/Math.pow(10,yo(t))}function wo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function ko(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=yo(e);let o=function(t,e){let i=yo(e-t);for(;wo(t,e,i)>10;)i++;for(;wo(t,e,i)<10;)i--;return Math.min(i,yo(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:Mo(g),significand:u}),s}class So extends tn{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===vo(this.min,0)?vo(this.min,-1):vo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(vo(i,-1)),o(vo(s,1)))),i<=0&&n(vo(s,-1)),s<=0&&o(vo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=ko({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function Po(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Do(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Co(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Ao(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function To(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function Lo(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Eo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(Po(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/Po(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Co(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));Lo(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e||0===e&&this.min<0){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash||[]),o.lineDashOffset=n.dashOffset,o.beginPath(),Eo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&this.min>=0&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Io={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},zo=Object.keys(Io);function Fo(t,e){return t-e}function Vo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Bo(t,e,i,s){const n=zo.length;for(let o=zo.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function No(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class Ho extends tn{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new In._date(t.adapters.date);s.init(e),b(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Vo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Bo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=zo.length-1;o>=zo.indexOf(i);o--){const i=zo[o];if(Io[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return zo[i?zo.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=zo.indexOf(t)+1,i=zo.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=Z(s,0,o),n=Z(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Bo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var $o=Object.freeze({__proto__:null,CategoryScale:class extends tn{static id="category";static defaults={ticks:{callback:mo}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:Z(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:po(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return mo.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:_o,LogarithmicScale:So,RadialLinearScale:Ro,TimeScale:Ho,TimeSeriesScale:class extends Ho{static id="timeseries";static defaults=Ho.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=jo(e,this.min),this._tableRange=jo(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(jo(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return jo(this._table,i*this._tableRange+this._minPos,!0)}}});const Yo=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Uo=Yo.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Xo(t){return Yo[t%Yo.length]}function qo(t){return Uo[t%Uo.length]}function Ko(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n instanceof Yn?e=function(t,e){return t.backgroundColor=t.data.map((()=>qo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Xo(e),t.backgroundColor=qo(e),++e}(i,e))}}function Go(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Jo={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n,a=Go(s)||(r=n)&&(r.borderColor||r.backgroundColor)||o&&Go(o)||"rgba(0,0,0,0.1)"!==ue.borderColor||"rgba(0,0,0,0.1)"!==ue.backgroundColor;var r;if(!i.forceOverride&&a)return;const l=Ko(t);s.forEach(l)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Qo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var ta={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Qo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=Z(it(e,o.axis,a).lo,0,i-1)),s=h?Z(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const x=[],b=e+i-1,_=t[e].x,y=t[b].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&x.push({...t[e],x:p}),s!==u&&s!==i&&x.push({...t[s],x:p})}o>0&&i!==u&&x.push(t[i]),x.push(a),h=e,m=0,f=g=l,c=d=u=o}}return x}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Qo(t)}};function ea(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ia(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function sa(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function na(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ia(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new oo({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function oa(t){return t&&!1!==t.fill}function aa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function ra(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function la(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&ua(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;oa(i)&&ua(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;oa(s)&&"beforeDatasetDraw"===i.drawTime&&ua(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const _a=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class ya extends $s{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=_a(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=va(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=_a(o,d),x=this.isHorizontal(),b=this._computeTitleHeight();f=x?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+b,line:0}:{x:this.left+c,y:ft(n,this.top+b+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),x?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+b+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,x?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),x)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=va(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class wa extends $s{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var ka={id:"title",_element:wa,start(t,e,i){!function(t,e){const i=new wa({ctx:t.ctx,options:e,chart:t});ls.configure(t,i,e),ls.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;ls.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;ls.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa=new WeakMap;var Pa={id:"subtitle",start(t,e,i){const s=new wa({ctx:t.ctx,options:i,chart:t});ls.configure(t,s,i),ls.addBox(t,s),Sa.set(t,s)},stop(t){ls.removeBox(t,Sa.get(t)),Sa.delete(t)},beforeUpdate(t,e,i){const s=Sa.get(t);ls.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Da={average(t){if(!t.length)return!1;let e,i,s=new Set,n=0,o=0;for(e=0,i=t.length;et+e))/s.size,y:n/o}},nearest(t,e){if(!t.length)return!1;let i,s,n,o=e.x,a=e.y,r=Number.POSITIVE_INFINITY;for(i=0,s=t.length;i-1?t.split("\n"):t}function Aa(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Ta(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,x=0,b=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(b+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),b){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(b-g)*l.lineHeight+(b-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){x=Math.max(x,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),x+=p.width,{width:x,height:m}}function La(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ea(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||La(t,e,i,s),yAlign:s}}function Ra(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:Z(g,0,s.width-e.width),y:Z(p,0,s.height-e.height)}}function Ia(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function za(t){return Ca([],Oa(t))}function Fa(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const Va={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Fa(i,t);Ca(e.before,Oa(Ba(n,"beforeLabel",this,t))),Ca(e.lines,Ba(n,"label",this,t)),Ca(e.after,Oa(Ba(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return za(Ba(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Ba(i,"beforeFooter",this,t),n=Ba(i,"footer",this,t),o=Ba(i,"afterFooter",this,t);let a=[];return a=Ca(a,Oa(s)),a=Ca(a,Oa(n)),a=Ca(a,Oa(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Fa(t.callbacks,e);s.push(Ba(i,"labelColor",this,e)),n.push(Ba(i,"labelPointStyle",this,e)),o.push(Ba(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Da[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Ta(this,i),a=Object.assign({},t,e),r=Ea(this.chart,i,a),l=Ra(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,x,b,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,b=_+o,y=_-o):(p=d+f,m=p+o,b=_-o,y=_+o),x=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(b=u,_=b-o,p=m-o,x=m+o):(b=u+g,_=b+o,p=m+o,x=m-o),y=b),{x1:p,x2:m,x3:x,y1:b,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ia(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let x,b,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ia(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Da[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Ta(this,t),a=Object.assign({},i,this._size),r=Ea(e,t,a),l=Ra(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e.filter((t=>this.chart.data.datasets[t.datasetIndex]&&void 0!==this.chart.getDatasetMeta(t.datasetIndex).controller.getParsed(t.index)));const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Da[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Na={id:"tooltip",_element:Wa,positioners:Da,afterInit(t,e,i){i&&(t.tooltip=new Wa({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:Va},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return Tn.register(Un,$o,go,t),Tn.helpers={...Hi},Tn._adapters=In,Tn.Animation=As,Tn.Animations=Ts,Tn.animator=bt,Tn.controllers=nn.controllers.items,Tn.DatasetController=js,Tn.Element=$s,Tn.elements=go,Tn.Interaction=Ki,Tn.layouts=ls,Tn.platforms=Ds,Tn.Scale=tn,Tn.Ticks=ae,Object.assign(Tn,Un,$o,go,t,Ds),Tn.Chart=Tn,"undefined"!=typeof window&&(window.Chart=Tn),Tn})); +//# sourceMappingURL=chart.umd.min.js.map From 2c1e13a685ac72c4e3112a03740df60837a56242 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Thu, 6 Aug 2026 09:43:48 +0200 Subject: [PATCH 2/7] Implement reference chains for surviving live-heap samples Adds a manual VMStructs-based heap walk that discovers reference chains from GC roots to live-heap sample objects, recorded as datadog.ReferenceChain / datadog.ReferenceChainAbandoned JFR events. Co-Authored-By: Claude Sonnet 5 --- ddprof-lib/src/main/cpp/arguments.cpp | 106 + ddprof-lib/src/main/cpp/arguments.h | 122 + ddprof-lib/src/main/cpp/callTraceHashTable.h | 2 +- ddprof-lib/src/main/cpp/callTraceStorage.cpp | 1 + ddprof-lib/src/main/cpp/counters.h | 24 + ddprof-lib/src/main/cpp/event.h | 50 + ddprof-lib/src/main/cpp/flightRecorder.cpp | 162 +- ddprof-lib/src/main/cpp/flightRecorder.h | 35 + ddprof-lib/src/main/cpp/javaApi.cpp | 146 + ddprof-lib/src/main/cpp/jfrMetadata.cpp | 23 + ddprof-lib/src/main/cpp/jfrMetadata.h | 6 + ddprof-lib/src/main/cpp/livenessTracker.cpp | 661 +++- ddprof-lib/src/main/cpp/livenessTracker.h | 521 +++- ddprof-lib/src/main/cpp/objectSampler.cpp | 22 +- ddprof-lib/src/main/cpp/os_linux.cpp | 3 + ddprof-lib/src/main/cpp/painBudget.h | 83 + ddprof-lib/src/main/cpp/profiler.cpp | 164 + ddprof-lib/src/main/cpp/profiler.h | 18 +- ddprof-lib/src/main/cpp/referenceChains.cpp | 2671 +++++++++++++++++ ddprof-lib/src/main/cpp/referenceChains.h | 1783 +++++++++++ ddprof-lib/src/main/cpp/safeAccess.h | 2 +- ddprof-lib/src/main/cpp/stringDictionary.h | 9 + ddprof-lib/src/main/cpp/symbols_linux.cpp | 32 +- ddprof-lib/src/main/cpp/vmEntry.cpp | 13 +- .../com/datadoghq/profiler/JavaProfiler.java | 116 + 25 files changed, 6722 insertions(+), 53 deletions(-) create mode 100644 ddprof-lib/src/main/cpp/painBudget.h create mode 100644 ddprof-lib/src/main/cpp/referenceChains.cpp create mode 100644 ddprof-lib/src/main/cpp/referenceChains.h diff --git a/ddprof-lib/src/main/cpp/arguments.cpp b/ddprof-lib/src/main/cpp/arguments.cpp index b43f99fccb..af5fc1d958 100644 --- a/ddprof-lib/src/main/cpp/arguments.cpp +++ b/ddprof-lib/src/main/cpp/arguments.cpp @@ -18,6 +18,7 @@ #include "arguments.h" #include "vmEntry.h" +#include #include #include #include @@ -81,6 +82,26 @@ static const Multiplier UNIVERSAL[] = { // and keep the liveness track of 10% of the allocation // samples // generations - track surviving generations +// referencechains[=BOOL[:hops=N][:budget=N][:ttl=N][:framecap=N][:pausetarget=N][:painbudget=N][:firstpassbudget=N]] +// - (PROF-15341, off by default) tag/BFS-walk live-heap +// samples' referrer chains back toward a GC root. +// pausetarget=N (ms) is the pause-time-SLO ceiling +// ReferenceChainTracker::updatePacing() adapts the +// effective budget/cadence toward (pause-time pacing +// controller). painbudget=N (percent) bounds how much +// wall-clock time a *restarted* search (one begun +// after a prior search already completed/abandoned) +// may spend on average - see PainBudget (painBudget.h). +// firstpassbudget=N overrides just the search's +// one-shot, root-seeded first pass's edge budget +// (default 0 - auto-scales from budget=N instead, +// see ReferenceChainTracker::AUTO_FIRST_PASS_BUDGET_* +// in referenceChains.h) since that pass alone +// decides which GC roots ever enter the frontier at +// all, unlike every later pass's cheap, incremental +// per-node expansion. +// Sub-options are placeholders pending future tuning; +// see doc/architecture/LiveHeapReferenceChains*.md // lightweight[=BOOL] - enable lightweight profiling - events without // stacktraces (default: true) // remotesym[=BOOL] - enable remote symbolication for native frames @@ -428,6 +449,91 @@ Error Arguments::parse(const char *args) { _nativesocket = true; } + CASE("referencechains") + { + // Sub-options are colon-delimited key=value pairs after the boolean, + // e.g. "referencechains=true:hops=64:budget=2000". Parsed manually + // (not via strtok) because the outer arg loop above is itself mid + // strtok(..., ",") over the same buffer - a nested strtok call would + // clobber its saved state. + char *config = value ? strchr(value, ':') : nullptr; + if (config) { + *(config++) = 0; + } + if (value != NULL) { + switch (value[0]) { + case 'n': // no + case 'f': // false + case '0': // 0 + _reference_chains = false; + break; + default: + _reference_chains = true; + } + } else { + _reference_chains = true; + } + char *cursor = config; + while (cursor != NULL) { + char *next = strchr(cursor, ':'); + if (next) { + *(next++) = 0; + } + char *eq = strchr(cursor, '='); + if (eq) { + *(eq++) = 0; + // Floor every sub-option at the parse boundary rather than + // trusting a downstream cast/clamp to make an operator-supplied + // negative value safe: a negative hops value in particular gets + // compared as `depth >= (u32)ctx->hop_cap` (referenceChains.cpp), + // so an unclamped negative wraps to ~4e9 and silently disables + // the hop cap entirely - the opposite of the flag's intent, and + // it removes the one guard that otherwise bounds how long a + // single reference chain (and therefore its + // datadog.ReferenceChain JFR event) can grow. A negative budget + // similarly collapses ReferenceChainTracker::_effective_budget + // to 0 (updatePacing()'s own PID-clamp logic), which truncates + // every pass immediately and leaves the search RUNNING + // (re-walking the whole graph each cadence) until TTL instead of + // making progress. A negative framecap is handed straight to + // FrontierTable's constructor, which floors it to a + // zero-capacity table (that class's own std::max(max_cap, 0)), + // silently disabling tracking rather than erroring. ttl/ + // pausetarget/painbudget already have incidental downstream + // clamps (runPass()'s `_ttl_ms > 0` gate, this class's own + // PidController/PainBudget std::max(..., 0) calls) but are + // floored here too so every sub-option's validation lives at one + // boundary instead of being split between here and several + // unrelated call sites. hops/budget/framecap are also ceiling- + // clamped (MAX_REFERENCE_CHAINS_HOP_CAP/_BUDGET/_FRONTIER_CAP, + // arguments.h) for the same reason painbudget/firstpassbudget + // are below: an unbounded operator-supplied value would otherwise + // flow straight into a loop bound or FrontierTable's allocation. + if (strcasecmp(cursor, "hops") == 0) { + _reference_chains_hop_cap = + std::min(std::max(atoi(eq), 1), MAX_REFERENCE_CHAINS_HOP_CAP); + } else if (strcasecmp(cursor, "budget") == 0) { + _reference_chains_budget = + std::min(std::max(atoi(eq), 1), MAX_REFERENCE_CHAINS_BUDGET); + } else if (strcasecmp(cursor, "ttl") == 0) { + _reference_chains_ttl_ms = std::max(atol(eq), 0L); + } else if (strcasecmp(cursor, "framecap") == 0) { + _reference_chains_frontier_cap = std::min( + std::max(atoi(eq), 1), MAX_REFERENCE_CHAINS_FRONTIER_CAP); + } else if (strcasecmp(cursor, "pausetarget") == 0) { + _reference_chains_pause_target_ms = std::max(atol(eq), 0L); + } else if (strcasecmp(cursor, "painbudget") == 0) { + _reference_chains_pain_budget_percent = + std::min(std::max(atoi(eq), 0), 100); + } else if (strcasecmp(cursor, "firstpassbudget") == 0) { + _reference_chains_first_pass_budget = std::min( + std::max(atoi(eq), 0), MAX_REFERENCE_CHAINS_FIRST_PASS_BUDGET); + } + } + cursor = next; + } + } + DEFAULT() if (_unknown_arg == NULL) _unknown_arg = arg; diff --git a/ddprof-lib/src/main/cpp/arguments.h b/ddprof-lib/src/main/cpp/arguments.h index 16efe9c8ba..efab22b8af 100644 --- a/ddprof-lib/src/main/cpp/arguments.h +++ b/ddprof-lib/src/main/cpp/arguments.h @@ -29,6 +29,105 @@ const long DEFAULT_ALLOC_INTERVAL = 524287; // 512 KiB const int DEFAULT_WALL_THREADS_PER_TICK = 16; const int DEFAULT_JSTACKDEPTH = 2048; +// Every constant below is a provisional default pending empirical +// tuning (see doc/architecture/LiveHeapReferenceChains-ImplementationPlan.md) +// - none of these values are backed by a benchmark run against this +// codebase. Each is chosen conservatively from cited precedent or from the +// shape of an existing, already-tuned subsystem, per the rationale below; +// a future JMH/async-profiler benchmark matrix (see +// doc/architecture/LiveHeapReferenceChains-BenchmarkPlan.md) is the intended +// path to replacing them with measured values. +// +// Hop cap: mirrors HotSpot's own JFR leak-profiler chain cap (~200 hops, +// split 100/100 from leaf and from root), cited in +// doc/architecture/LiveHeapReferenceChains.md's "Approach B" section - the +// closest real-world precedent for "how many hops does a referrer-type +// chain typically need" that this codebase can cite without measuring it +// itself. +const int DEFAULT_REFERENCE_CHAINS_HOP_CAP = 200; +// Per-pass edge budget: no cited precedent gives a number for this (JFR's +// leak profiler does not bound itself by a per-pass edge count - it runs to +// completion inside one already-scheduled GC pause). Chosen as a round, +// conservative middle value intended to keep a single FollowReferences- +// triggered safepoint short without so small a budget that a search needs +// an impractical number of passes to make progress. A future benchmark pass +// should measure per-pass wall-clock pause distribution at this value and adjust. +const int DEFAULT_REFERENCE_CHAINS_BUDGET = 1000; // edges expanded per BFS pass +// Per-search TTL: a conservative round number (one minute) chosen so a +// slow-moving or stalled search is bounded to a human-noticeable but not +// excessive lifetime, in the absence of any measured "passes needed to +// reach a target sample at various depths" data (a future benchmark's stated goal). +const long DEFAULT_REFERENCE_CHAINS_TTL_MS = 60000; // per-search wall-clock TTL +// Frontier-size cap: sized relative to LivenessTracker's own tuned ceiling +// (MAX_TRACKING_TABLE_SIZE = 262144, livenessTracker.h) rather than derived +// from any BFS-specific measurement - the design doc explicitly flags that +// LivenessTracker's allocation-sample-rate sizing formula does not transfer +// to a graph-search frontier (Open Question 2), so this only borrows the +// same order of magnitude, quartered as a conservative starting point since +// a FrontierEntry is smaller than a TrackingEntry but per-hop fan-out could +// still be large. Not a scaled/derived value - just a conservative guess +// pending a future frontier-table peak-occupancy measurement. +const int DEFAULT_REFERENCE_CHAINS_FRONTIER_CAP = 65536; // max live frontier entries per search +// Pause-time-SLO ceiling (pause-time pacing controller, doc/architecture/ +// LiveHeapReferenceChains-RemainingWorkPlan.md): target ceiling, per pass, on +// wall-clock time spent inside the safepoint-triggering +// FollowReferences/GetObjectsWithTags call +// (ReferenceChainTracker::updatePacing(), referenceChains.cpp). Like every +// other constant in this block this is a round, provisional default with no +// benchmark behind it - picking the real number is explicitly a future +// measurement question (design doc's Open Question 2), not a value to guess +// here; this only exists so the feedback loop this ceiling drives has +// something to target end-to-end before that measurement happens. +const long DEFAULT_REFERENCE_CHAINS_PAUSE_TARGET_MS = 5; // ms per pass +// Pain budget refill rate (ReferenceChainTracker::PainBudget, painBudget.h): +// the fraction of wall-clock time a *restarted* search is allowed to spend +// inside FollowReferences/GetObjectsWithTags safepoints, on average, before a +// later restart must wait for the debt from the previous search's cost to +// drain. Expressed as an integer percent (1 = 1%) for readability - see +// PainBudget's own header comment for why this single ratio needs no +// benchmark-derived tuning the way the per-pass constants above do, only a +// choice of how much background cost is acceptable. Round, provisional +// default like every other constant in this block. +const int DEFAULT_REFERENCE_CHAINS_PAIN_BUDGET_PERCENT = 1; +// First-pass edge budget override: the search's one-and-only root-seeded +// FollowReferences(0, nullptr, nullptr, ...) call (ReferenceChainTracker::runPass()'s +// !_search_started branch) enumerates every GC root in one JVMTI-controlled +// traversal order and stops admitting once this budget is spent - any root +// FollowReferences had not yet reached is excluded from the frontier for the +// rest of that search (every later pass only expands forward from already- +// admitted frontier entries, see expandFrontier()'s own comment). Unlike +// DEFAULT_REFERENCE_CHAINS_BUDGET, which bounds every pass including the many +// cheap, per-node expansion passes that follow, this only ever spends once +// per search, so a much larger one-time ceiling is affordable. 0 (the +// default) means "no override - use the same budget as every other pass", +// preserving prior behavior for anyone not setting this explicitly. +const int DEFAULT_REFERENCE_CHAINS_FIRST_PASS_BUDGET = 0; +// Upper clamp for an explicit firstpassbudget override: like painbudget just +// above, firstpassbudget was previously only floored at 0 with no ceiling. +// Unlike painbudget (a percentage, naturally bounded at 100), this is a raw +// edge count, so the ceiling is expressed relative to +// DEFAULT_REFERENCE_CHAINS_BUDGET (the per-pass budget every later pass is +// bounded by) rather than as its own standalone guess: a generous but finite +// multiple still lets the one-time root pass be far larger than a normal +// pass (its intended purpose) while keeping an operator from disabling the +// safepoint-pause-bounding mechanism entirely for that first FollowReferences +// call. +const int MAX_REFERENCE_CHAINS_FIRST_PASS_BUDGET = + DEFAULT_REFERENCE_CHAINS_BUDGET * 1000; +// Upper clamps for hops/budget/framecap: like MAX_REFERENCE_CHAINS_FIRST_PASS_BUDGET +// just above, these were previously only floored at 1 with no ceiling, so an +// operator typo (an extra digit) or a mistaken value flows straight into a +// loop bound (hops), a per-pass edge count (budget), or FrontierTable's +// capacity (framecap) unchecked. Same generous-but-finite-multiple-of-the- +// default approach as the first-pass budget clamp: large enough that no +// legitimate configuration should ever hit the ceiling, small enough to +// still fail a badly mistyped value safely instead of feeding it straight +// into an allocation or loop bound. +const int MAX_REFERENCE_CHAINS_HOP_CAP = DEFAULT_REFERENCE_CHAINS_HOP_CAP * 1000; +const int MAX_REFERENCE_CHAINS_BUDGET = DEFAULT_REFERENCE_CHAINS_BUDGET * 1000; +const int MAX_REFERENCE_CHAINS_FRONTIER_CAP = + DEFAULT_REFERENCE_CHAINS_FRONTIER_CAP * 1000; + const char *const EVENT_NOOP = "noop"; const char *const EVENT_CPU = "cpu"; const char *const EVENT_ALLOC = "alloc"; @@ -177,6 +276,21 @@ class Arguments { double _live_samples_ratio; bool _record_heap_usage; bool _gc_generations; + // Reference-chain tracking (PROF-15341 - see + // doc/architecture/LiveHeapReferenceChains-ImplementationPlan.md and + // -RemainingWorkPlan.md). Read by ReferenceChainTracker::start() + // (referenceChains.cpp) to size the frontier table and seed the per-search + // hop/budget/TTL tunables and the pause-time-SLO ceiling that + // updatePacing() adapts the effective budget/cadence toward. + bool _reference_chains; + int _reference_chains_hop_cap; + int _reference_chains_budget; + long _reference_chains_ttl_ms; + int _reference_chains_frontier_cap; + long _reference_chains_pause_target_ms; + int _reference_chains_pain_budget_percent; + int _reference_chains_first_pass_budget; + // Explicit opt-in for the legacy whole-graph JVMTI FollowReferences walk. long _nativemem; int _jstackdepth; int _safe_mode; @@ -218,6 +332,14 @@ class Arguments { _live_samples_ratio(0.1), // default to liveness-tracking 10% of the allocation samples _record_heap_usage(false), _gc_generations(false), + _reference_chains(false), + _reference_chains_hop_cap(DEFAULT_REFERENCE_CHAINS_HOP_CAP), + _reference_chains_budget(DEFAULT_REFERENCE_CHAINS_BUDGET), + _reference_chains_ttl_ms(DEFAULT_REFERENCE_CHAINS_TTL_MS), + _reference_chains_frontier_cap(DEFAULT_REFERENCE_CHAINS_FRONTIER_CAP), + _reference_chains_pause_target_ms(DEFAULT_REFERENCE_CHAINS_PAUSE_TARGET_MS), + _reference_chains_pain_budget_percent(DEFAULT_REFERENCE_CHAINS_PAIN_BUDGET_PERCENT), + _reference_chains_first_pass_budget(DEFAULT_REFERENCE_CHAINS_FIRST_PASS_BUDGET), _nativemem(-1), _jstackdepth(DEFAULT_JSTACKDEPTH), _safe_mode(0), diff --git a/ddprof-lib/src/main/cpp/callTraceHashTable.h b/ddprof-lib/src/main/cpp/callTraceHashTable.h index ae7286f2a2..80f1b63ff2 100644 --- a/ddprof-lib/src/main/cpp/callTraceHashTable.h +++ b/ddprof-lib/src/main/cpp/callTraceHashTable.h @@ -86,7 +86,7 @@ class CallTraceHashTable { // - ACQUIRE loads in collect(), put(), and putWithExistingId() // Required for correct visibility on weakly-ordered architectures (aarch64). LongHashTable* _table; - + volatile u64 _overflow; u64 calcHash(int num_frames, ASGCT_CallFrame *frames, bool truncated); diff --git a/ddprof-lib/src/main/cpp/callTraceStorage.cpp b/ddprof-lib/src/main/cpp/callTraceStorage.cpp index b648367b6e..e2f5279298 100644 --- a/ddprof-lib/src/main/cpp/callTraceStorage.cpp +++ b/ddprof-lib/src/main/cpp/callTraceStorage.cpp @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +#include #include "callTraceStorage.h" #include "counters.h" #include "log.h" diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index a3b3ea34f7..9ce07d4d4f 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -139,6 +139,30 @@ * signal for spotting a recurrence. */ \ X(METADATA_TREE_NULL_CHILD, "metadata_tree_null_child") \ X(METADATA_TREE_DEPTH_EXCEEDED, "metadata_tree_depth_exceeded") \ + /* A resolved datadog.ReferenceChain could not be cached in \ + * ReferenceChainTracker::_resolved_chains (referenceChains.h): a brand-new \ + * leak-candidate klass arrived with the cache already at \ + * MAX_RESOLVED_CHAINS, so its chain is dropped rather than evicting some \ + * other still-live sample's chain. See that constant's own comment. */ \ + X(REFERENCE_CHAIN_EVENTS_DROPPED, "reference_chain_events_dropped") \ + /* ReferenceChainTracker::releaseSearchTags() (referenceChains.cpp) failed \ + * to call GetObjectsWithTags() for at least one batch - the search's tag \ + * release is retried on a later call rather than proceeding, but this \ + * counts how often that retry path is taken. */ \ + X(REFERENCE_CHAIN_TAG_RELEASE_FAILED, "reference_chain_tag_release_failed") \ + /* Profiler::writeReferenceChain() (profiler.cpp) could not acquire a \ + * sample-record lock within its bounded retry budget and dropped the \ + * already-dequeued datadog.ReferenceChain event for this dump - not \ + * permanently lost, since ReferenceChainTracker::_resolved_chains (see \ + * REFERENCE_CHAIN_EVENTS_DROPPED above) keeps the resolved chain cached \ + * and re-emits it on a later dump while the leak candidate is still \ + * live. */ \ + X(REFERENCE_CHAIN_WRITE_DROPPED, "reference_chain_write_dropped") \ + /* FrontierTable's own calloc/realloc-backed storage (referenceChains.cpp) - \ + * outside NMT's visibility since it bypasses os::malloc, so this is the only \ + * way to attribute its native RSS contribution. */ \ + X(REFERENCE_CHAIN_FRONTIER_TABLE_BYTES, "reference_chain_frontier_table_bytes") \ + X(REFERENCE_CHAIN_FRONTIER_TABLE_CAPACITY, "reference_chain_frontier_table_capacity") \ DD_COUNTER_TABLE_FAULT_INJECTION(X) \ DD_COUNTER_TABLE_FI_DEBUG(X) \ DD_COUNTER_TABLE_DEBUG(X) diff --git a/ddprof-lib/src/main/cpp/event.h b/ddprof-lib/src/main/cpp/event.h index 67ff97b381..3e028a3934 100644 --- a/ddprof-lib/src/main/cpp/event.h +++ b/ddprof-lib/src/main/cpp/event.h @@ -24,6 +24,7 @@ #include #include #include +#include using namespace std; #define MAX_STRING_LEN 8191 @@ -90,6 +91,55 @@ class ObjectLivenessEvent : public Event { Context _ctx; }; +// Reporting surface for ReferenceChainTracker's bounded +// BFS (referenceChains.h/.cpp). `_target_tag` is the FrontierTable tag the +// chain was reconstructed for (FrontierTable::reconstructChain()); `_chain` +// holds the referrer-klass StringDictionary ids it returns, in the same +// leaf(target)-to-root order. `_depth` is the target entry's own +// FrontierEntry::depth (hop count from the search's root-side seed). +// `_root_kind` is the jvmtiHeapReferenceKind of whichever edge first +// admitted this chain into the frontier (FrontierEntry::root_kind, via +// FrontierTable::reconstructChain()'s out_root_kind) - labels *why* the +// chain is reachable at all (JNI global, thread stack, static field, ...), +// written out as a string (Recording::recordReferenceChain(), +// flightRecorder.cpp) rather than a synthetic node in `_chain` itself, +// since that array is a T_CLASS cpool array with no room for a +// non-class placeholder. +class ReferenceChainEvent : public Event { +public: + u64 _start_time; + u64 _target_tag; + u32 _depth; + u8 _root_kind; + std::vector _chain; + + ReferenceChainEvent() + : Event(), _start_time(0), _target_tag(0), _depth(0), _root_kind(0) {} +}; + +// Search-level abandonment signal (design doc's Termination section: +// "explicit reporting of abandoned searches ... no silent truncation"). +// Unlike ReferenceChainEvent this does not report any one object's chain - +// it reports why ReferenceChainTracker's current search stopped before +// every frontier entry could be resolved, using the same counters +// runPass()/expandFrontier() already maintain (referenceChains.h/.cpp). +class ReferenceChainAbandonedEvent : public Event { +public: + u64 _start_time; + u8 _reason; // SearchAbandonReason (referenceChains.h) + u32 _passes_run; + u32 _frontier_size; + int _hop_cap; + int _budget; + long _ttl_ms; + u64 _elapsed_ns; + + ReferenceChainAbandonedEvent() + : Event(), _start_time(0), _reason(0), _passes_run(0), + _frontier_size(0), _hop_cap(0), _budget(0), _ttl_ms(0), + _elapsed_ns(0) {} +}; + class MallocEvent : public Event { public: u64 _start_time; diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index 7d096dc6cf..3b6276eefc 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -48,14 +48,18 @@ static const char *const SETTING_RING[] = {NULL, "kernel", "user", "any"}; static const char *const SETTING_CSTACK[] = {NULL, "no", "fp", "dwarf", "lbr"}; -// JVM spec SS4.7.3 caps a method's bytecode (code_length) at 65535 bytes (u2), -// so a well-formed LineNumberTable can never have more entries than that. -// Used to sanity-bound line_number_table_size before it drives the byte-count -// passed to SafeAccess::safeCopy(): if GetLineNumberTable() -// returns a corrupted pointer for a stale jmethodID (see the TOCTOU race -// documented in fillJavaMethodInfo below), the paired out-param size is just -// as likely to be corrupted, and an implausible size should be rejected -// before it is trusted to compute a byte range. +// JVM spec SS4.7.3 caps a method's bytecode (code_length) at 65535 bytes (u2). +// A LineNumberTable entry maps a bytecode offset to a source line, so a +// well-formed table can have at most one entry per bytecode offset -- the +// 65535 bound here is inherited indirectly through that one-entry-per-offset +// invariant, not a direct spec cap on entry count (the numeric equivalence +// with code_length's own u2 cap is coincidental). Used to sanity-bound +// line_number_table_size before it drives the byte-count passed to +// SafeAccess::safeCopy(): if GetLineNumberTable() returns a corrupted +// pointer for a stale jmethodID (see the TOCTOU race documented in +// fillJavaMethodInfo below), the paired out-param size is just as likely to +// be corrupted, and an implausible size should be rejected before it is +// trusted to compute a byte range. static const jint MAX_LINE_NUMBER_TABLE_ENTRIES = 65535; // Compute a non-negative event duration from TSC timestamps. Unsigned u64 @@ -418,7 +422,9 @@ void Lookup::fillJavaMethodInfo(MethodInfo *mi, jmethodID method, } if (line_number_table != nullptr) { jvmtiError dealloc_err = jvmti->Deallocate((unsigned char *)line_number_table); - assert(dealloc_err == JVMTI_ERROR_NONE && "Unexpected error while deallocating linenumber table"); + if (dealloc_err != JVMTI_ERROR_NONE) { + TEST_LOG("Unexpected error %d while deallocating linenumber table", dealloc_err); + } } if (owned_table != nullptr) { mi->_line_number_table = std::make_shared( @@ -910,6 +916,8 @@ void Recording::switchChunk(int fd) { _chunk_start = finishChunk(/*end_recording=*/true, /*do_cleanup=*/true); TEST_LOG("MethodMap: %zu methods after cleanup", _method_map.size()); + TEST_LOG("Recording::switchChunk copying [0, %lld) from _fd=%d to fd=%d", + (long long)_chunk_start, _fd, fd); _start_time = _stop_time; _start_ticks = _stop_ticks; @@ -1249,6 +1257,7 @@ void Recording::writeSettings(Buffer *buf, Arguments &args) { writeBoolSetting(buf, T_ALLOC, "enabled", args._record_allocations); writeBoolSetting(buf, T_HEAP_LIVE_OBJECT, "enabled", args._record_liveness); + writeBoolSetting(buf, T_REFERENCE_CHAIN, "enabled", args._reference_chains); writeBoolSetting(buf, T_MALLOC, "enabled", args._nativemem >= 0); if (args._nativemem >= 0) { writeIntSetting(buf, T_MALLOC, "nativemem", args._nativemem); @@ -2105,6 +2114,115 @@ void Recording::recordHeapLiveObject(Buffer *buf, int tid, u64 call_trace_id, flushIfNeeded(buf); } +// Maps a FrontierEntry::root_kind byte (a jvmtiHeapReferenceKind value, +// referenceChains.h/.cpp's heapReferenceCallback()) to a human-readable +// label for datadog.ReferenceChain's rootKind field - only the values +// heapReferenceCallback() can actually produce (a root reference's own +// jvmtiHeapReferenceKind, or JVMTI_HEAP_REFERENCE_STATIC_FIELD for the +// "referrer is a pre-tagged class" root-like case, see that method's own +// comment) have entries; anything else (including 0, root_kind's +// "not set" default) reports "unknown" rather than crashing on an +// out-of-range index. +// +// STACK_LOCAL (24) and JNI_LOCAL (25) are labeled "first_observed_via:..." +// rather than plain "stack_local"/"jni_local" (design doc's "Honest labeling +// in output", point 2): both are evidence this object was reachable from a +// live frame/local handle at the moment a pass observed it, not a durable +// retention reason - the frame can pop or the handle can be freed the +// instant the pass ends, so "rooted by" would overstate what is actually +// known. Every other kind here is durable enough for the plain "rooted by" +// framing this field's name already implies. +static const char *rootKindName(u8 root_kind) { + switch (root_kind) { + case 8: + return "static_field"; + case 21: + return "jni_global"; + case 22: + return "system_class"; + case 23: + return "monitor"; + case 24: + return "first_observed_via:stack_local"; + case 25: + return "first_observed_via:jni_local"; + case 26: + return "thread"; + case 27: + return "other"; + default: + return "unknown"; + } +} + +void Recording::recordReferenceChain(Buffer *buf, ReferenceChainEvent *event) { + // event->_chain's length is bounded only by FrontierTable::maxCapacity() + // (tens of thousands of entries, referenceChains.h) - NOT by + // MAX_JFR_EVENT_SIZE, so this event cannot use writeEventSizePrefix()'s + // single-byte size field (its assert(size < MAX_JFR_EVENT_SIZE) is + // compiled out in release builds, making an oversize chain a silent + // corrupt size byte rather than a caught bug) nor rely on the trailing + // flushIfNeeded(buf) every fixed-size event above uses (that only flushes + // *after* already writing past the buffer). Truncate to + // MAX_REFERENCE_CHAIN_EVENT_HOPS (that constant's own comment) and + // reserve room for the truncated worst case up front instead. + u32 chain_size = (u32)event->_chain.size(); + u32 emitted_size = chain_size < (u32)MAX_REFERENCE_CHAIN_EVENT_HOPS + ? chain_size + : (u32)MAX_REFERENCE_CHAIN_EVENT_HOPS; + + // rootKindName() never returns a string longer than + // "first_observed_via:stack_local" (31 bytes) - reserve a fixed, generous + // 32 bytes for its putUtf8() length prefix + payload rather than computing + // strlen() up front. + const char *root_kind_name = rootKindName(event->_root_kind); + flushIfNeeded( + buf, RECORDING_BUFFER_LIMIT - + (MAX_VAR32_LENGTH /* multi-byte size prefix, below */ + + 3 * MAX_VAR64_LENGTH /* type id, start_time, target_tag */ + + 2 * MAX_VAR32_LENGTH /* depth, chain count */ + + 32 /* rootKind string */ + + (int)emitted_size * MAX_VAR32_LENGTH)); + // Multi-byte size prefix (like writeDatadogSetting() above), not + // writeEventSizePrefix()'s single byte - this event's size can exceed + // MAX_JFR_EVENT_SIZE (255) once the chain is more than a few dozen hops. + int start = buf->skip(MAX_VAR32_LENGTH); + buf->putVar64(T_REFERENCE_CHAIN); + buf->putVar64(event->_start_time); + buf->putVar64(event->_target_tag); + buf->putVar32(event->_depth); + buf->putUtf8(root_kind_name); + // T_CLASS array field (F_CPOOL|F_ARRAY, jfrMetadata.cpp) - each entry is a + // StringDictionary class id, same encoding as a scalar objectClass field + // (e.g. recordAllocation() above), just repeated `count` times. + buf->putVar32(emitted_size); + for (u32 i = 0; i < emitted_size; i++) { + buf->putVar32(event->_chain[i]); + } + buf->putVar32(start, (u32)(buf->offset() - start)); + flushIfNeeded(buf); +} + +void Recording::recordReferenceChainAbandoned( + Buffer *buf, ReferenceChainAbandonedEvent *event) { + int start = buf->skip(1); + buf->putVar64(T_REFERENCE_CHAIN_ABANDONED); + buf->putVar64(event->_start_time); + // SearchAbandonReason (referenceChains.h) - kept as a small fixed table + // here rather than a T_XXX enum type, mirroring NativeSocketEvent's + // _operation -> kOpNames string mapping above. + static const char *const kReasons[] = {"none", "frontier_cap", "ttl"}; + buf->putUtf8(event->_reason < 3 ? kReasons[event->_reason] : "unknown"); + buf->putVar32(event->_passes_run); + buf->putVar32(event->_frontier_size); + buf->putVar32(event->_hop_cap); + buf->putVar32(event->_budget); + buf->putVar64(event->_ttl_ms); + buf->putVar64(event->_elapsed_ns / 1000000); + writeEventSizePrefix(buf, start); + flushIfNeeded(buf); +} + void Recording::recordMonitorBlocked(Buffer *buf, int tid, u64 call_trace_id, LockEvent *event) { int start = buf->skip(1); @@ -2288,6 +2406,32 @@ void FlightRecorder::recordHeapUsage(int lock_index, long value, bool live) { } } +void FlightRecorder::recordReferenceChainAbandoned( + int lock_index, ReferenceChainAbandonedEvent *event) { + DEBUG_ASSERT_NOT_IN_SIGNAL(); + OptionalSharedLockGuard locker(&_rec_lock); + if (locker.ownsLock()) { + Recording* rec = _rec; + if (rec != nullptr) { + Buffer *buf = rec->buffer(lock_index); + rec->recordReferenceChainAbandoned(buf, event); + } + } +} + +void FlightRecorder::recordReferenceChain(int lock_index, + ReferenceChainEvent *event) { + DEBUG_ASSERT_NOT_IN_SIGNAL(); + OptionalSharedLockGuard locker(&_rec_lock); + if (locker.ownsLock()) { + Recording* rec = _rec; + if (rec != nullptr) { + Buffer *buf = rec->buffer(lock_index); + rec->recordReferenceChain(buf, event); + } + } +} + bool FlightRecorder::recordEvent(int lock_index, int tid, u64 call_trace_id, int event_type, Event *event) { OptionalSharedLockGuard locker(&_rec_lock); diff --git a/ddprof-lib/src/main/cpp/flightRecorder.h b/ddprof-lib/src/main/cpp/flightRecorder.h index f7319f55af..0335f31941 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -40,6 +40,20 @@ const int JFR_EVENT_FLUSH_THRESHOLD = RECORDING_BUFFER_LIMIT; const int MAX_VAR64_LENGTH = 10; const int MAX_VAR32_LENGTH = 5; +// Chain length Recording::recordReferenceChain() (flightRecorder.cpp) will +// actually serialize per datadog.ReferenceChain event, independent of +// ReferenceChainTracker's own _hop_cap/frontier-table cap - the frontier +// table's own defensive walk bound (FrontierTable::reconstructChain(), +// referenceChains.h) is maxCapacity(), which can run into the tens of +// thousands of entries, and neither that cap nor _hop_cap is itself +// range-validated against a buffer-safe maximum (see arguments.cpp's own +// sub-option parsing). recordReferenceChain() truncates event->_chain to +// this many entries before writing, so its own worst-case size never +// depends on trusting either of those upstream caps to stay small - a chain +// longer than this is still truncated defense-in-depth even if a caller +// changes those caps later. +const int MAX_REFERENCE_CHAIN_EVENT_HOPS = 4096; + #ifndef CONCURRENCY_LEVEL const int CONCURRENCY_LEVEL = 16; #endif @@ -340,6 +354,9 @@ class Recording { NativeSocketEvent *event); void recordHeapLiveObject(Buffer *buf, int tid, u64 call_trace_id, ObjectLivenessEvent *event); + void recordReferenceChain(Buffer *buf, ReferenceChainEvent *event); + void recordReferenceChainAbandoned(Buffer *buf, + ReferenceChainAbandonedEvent *event); void recordMonitorBlocked(Buffer *buf, int tid, u64 call_trace_id, LockEvent *event); void recordThreadPark(Buffer *buf, int tid, u64 call_trace_id, @@ -459,6 +476,24 @@ class FlightRecorder { const char *value, const char *unit); void recordHeapUsage(int lock_index, long value, bool live); + + // Mirrors recordHeapUsage()'s shape exactly - ReferenceChainAbandonedEvent + // is not stack-sample-shaped (no tid/call_trace_id), same as HeapUsage. + // Called from Profiler::writeReferenceChainAbandoned() (profiler.cpp), + // wired from Profiler::dump() the same way LivenessTracker::flush() is. + void recordReferenceChainAbandoned(int lock_index, + ReferenceChainAbandonedEvent *event); + + // Mirrors recordReferenceChainAbandoned() above exactly, for + // ReferenceChainEvent instead. Called from Profiler::writeReferenceChain() + // (profiler.cpp), itself called from + // ReferenceChainTracker::pollWatchedTargets() (referenceChains.cpp) for + // each chain event discovered this poll cycle - unlike + // recordReferenceChainAbandoned() (only reached from dump()), chain events + // are produced continuously as candidates are discovered, not only at + // dump time, so this needs its own call site rather than piggybacking on + // dump()'s flush-on-dump pattern. + void recordReferenceChain(int lock_index, ReferenceChainEvent *event); }; #endif // _FLIGHTRECORDER_H diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index 3f71a1b74c..69a7283e63 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -1036,6 +1036,152 @@ Java_com_datadoghq_profiler_JavaProfiler_dumpContext(JNIEnv* env, jclass unused) TEST_LOG("===> Context: tid:%lu, spanId=%lu, rootSpanId=%lu", OS::threadId(), spanId, rootSpanId); } +// PROF-15341: LivenessTracker/ReferenceChainTracker test seams. Unlike +// testlog()/dumpContext() above (harmless no-ops in release, via TEST_LOG's +// own release-mode expansion to nothing), these mutate real tracker state +// (tagging objects, seeding population history) - shipping them into a +// release build would let a caller corrupt the actual leak-detection state, +// not just add a silent no-op. Guarded out entirely instead, so they only +// exist in the debug build ddprof-test's `testdebug` Gradle task loads +// (`-DDEBUG`, see ConfigurationPresets.kt's configureDebug()) - never in the +// `-DNDEBUG` release build. +#ifdef DEBUG +#include "livenessTracker.h" +#include "referenceChains.h" +#include + +extern "C" DLLEXPORT jboolean JNICALL +Java_com_datadoghq_profiler_JavaProfiler_setGcGenerationsEnabled0( + JNIEnv *env, jclass unused, jboolean enabled) { + LivenessTracker::instance()->setGcGenerationsForTest(enabled); + return JNI_TRUE; +} + +extern "C" DLLEXPORT void JNICALL +Java_com_datadoghq_profiler_JavaProfiler_seedKlassPopulationSample0( + JNIEnv *env, jclass unused, jint klassId, jint count, jlong epoch) { + int slot; + bool created; + LivenessTracker::instance()->klassPopulationRecordForTest( + (u32)klassId, (u16)count, (u64)epoch, &slot, &created); +} + +// Wires a real, caller-chosen live object in as klassId's leak-candidate +// representative, so a test-seeded slope signal (seedKlassPopulationSample0 +// above) and a directly-tagged frontier root (tagAsReferenceChainRoot0 +// below) can be joined into one deterministic end-to-end run of +// pollWatchedTargets()'s bridging step - without either LivenessTracker's +// real allocation sampler or ReferenceChainTracker's root-seeded walk ever +// running. Takes its own weak global ref (klassPopulationSetRepresentativeForTest()'s +// own contract, livenessTracker.h) rather than aliasing any handle the +// caller manages. +extern "C" DLLEXPORT void JNICALL +Java_com_datadoghq_profiler_JavaProfiler_setKlassPopulationRepresentativeForTest0( + JNIEnv *env, jclass unused, jint klassId, jobject representative) { + jweak rep = env->NewWeakGlobalRef(representative); + LivenessTracker::instance()->klassPopulationSetRepresentativeForTest( + env, (u32)klassId, rep); +} + +extern "C" DLLEXPORT void JNICALL +Java_com_datadoghq_profiler_JavaProfiler_resetKlassPopulationForTest0( + JNIEnv *env, jclass unused) { + LivenessTracker::instance()->klassPopulationResetForTest(); +} + +extern "C" DLLEXPORT jintArray JNICALL +Java_com_datadoghq_profiler_JavaProfiler_selectLeakCandidateKlassIds0( + JNIEnv *env, jclass unused) { + KlassCandidate candidates[5]; + int n = LivenessTracker::instance()->selectLeakCandidates(candidates, 5); + jintArray result = env->NewIntArray(n); + if (result == nullptr || n == 0) { + return result; + } + jint ids[5]; + for (int i = 0; i < n; i++) { + ids[i] = (jint)candidates[i].klass_id; + } + env->SetIntArrayRegion(result, 0, n, ids); + return result; +} + +extern "C" DLLEXPORT jlong JNICALL +Java_com_datadoghq_profiler_JavaProfiler_tagAsReferenceChainRoot0( + JNIEnv *env, jclass unused, jobject target) { + jvmtiEnv *jvmti = VM::jvmti(); + if (jvmti == nullptr) { + return 0; + } + return ReferenceChainTracker::instance()->tagAsRootForTest(jvmti, env, + target); +} + +extern "C" DLLEXPORT jboolean JNICALL +Java_com_datadoghq_profiler_JavaProfiler_runReferenceChainPass0( + JNIEnv *env, jclass unused) { + jvmtiEnv *jvmti = VM::jvmti(); + if (jvmti == nullptr) { + return JNI_FALSE; + } + return ReferenceChainTracker::instance()->runPass(jvmti, env); +} + +extern "C" DLLEXPORT void JNICALL +Java_com_datadoghq_profiler_JavaProfiler_pollReferenceChainTargets0( + JNIEnv *env, jclass unused) { + jvmtiEnv *jvmti = VM::jvmti(); + if (jvmti == nullptr) { + return; + } + ReferenceChainTracker::instance()->pollWatchedTargets(jvmti, env); +} + +extern "C" DLLEXPORT jint JNICALL +Java_com_datadoghq_profiler_JavaProfiler_drainReferenceChainEventCount0( + JNIEnv *env, jclass unused) { + std::vector events; + ReferenceChainTracker::instance()->drainPendingChainEvents(&events); + return (jint)events.size(); +} + +extern "C" DLLEXPORT void JNICALL +Java_com_datadoghq_profiler_JavaProfiler_resetReferenceChainSearchForTest0( + JNIEnv *env, jclass unused) { + jvmtiEnv *jvmti = VM::jvmti(); + ReferenceChainTracker::instance()->resetSearchStateForTest(jvmti, env); +} + +// Diagnostic-only: reads target's existing JVMTI tag (does NOT tag it - +// unlike tagAsReferenceChainRoot0 above, a target the real search has not +// reached yet must be left untagged) and reports its FIFO distance from the +// front of ReferenceChainTracker's pending-expansion queue. See +// ReferenceChainTracker::pendingExpandPositionForTest()'s own comment for +// the return-value contract. +extern "C" DLLEXPORT jlong JNICALL +Java_com_datadoghq_profiler_JavaProfiler_getReferenceChainPendingPositionForTest0( + JNIEnv *env, jclass unused, jobject target) { + jvmtiEnv *jvmti = VM::jvmti(); + if (jvmti == nullptr || target == nullptr) { + return -2; + } + jlong tag = 0; + jvmtiError err = jvmti->GetTag(target, &tag); + if (err != JVMTI_ERROR_NONE) { + return -2; + } + return (jlong)ReferenceChainTracker::instance()->pendingExpandPositionForTest( + tag); +} + +extern "C" DLLEXPORT jlong JNICALL +Java_com_datadoghq_profiler_JavaProfiler_getReferenceChainPendingSizeForTest0( + JNIEnv *env, jclass unused) { + return (jlong)ReferenceChainTracker::instance()->pendingExpandSizeForTest(); +} + +#endif // DEBUG + // ---- Test-only reads of the current thread's OTEP record ----------------------------------- // Each reads the current carrier's record directly via ProfiledThread::current(), with no // detach/attach (diagnostic-only, not on any signal-handler or hot write path). diff --git a/ddprof-lib/src/main/cpp/jfrMetadata.cpp b/ddprof-lib/src/main/cpp/jfrMetadata.cpp index 4064ba241d..33501dfafa 100644 --- a/ddprof-lib/src/main/cpp/jfrMetadata.cpp +++ b/ddprof-lib/src/main/cpp/jfrMetadata.cpp @@ -205,6 +205,29 @@ void JfrMetadata::initialize( << field("localRootSpanId", T_LONG, "Local Root Span ID") || contextAttributes) + << (type("datadog.ReferenceChain", T_REFERENCE_CHAIN, + "Live Object Reference Chain") + << category("Datadog", "Profiling") + << field("startTime", T_LONG, "Start Time", F_TIME_TICKS) + << field("targetTag", T_LONG, "Frontier Tag", F_UNSIGNED) + << field("depth", T_INT, "Depth") + << field("rootKind", T_STRING, "GC Root Kind") + << field("chain", T_CLASS, "Referrer Chain (Leaf to Root)", + F_CPOOL | F_ARRAY)) + + << (type("datadog.ReferenceChainAbandoned", + T_REFERENCE_CHAIN_ABANDONED, + "Live Object Reference Chain Search Abandoned") + << category("Datadog", "Profiling") + << field("startTime", T_LONG, "Start Time", F_TIME_TICKS) + << field("reason", T_STRING, "Abandonment Reason") + << field("passesRun", T_INT, "Passes Run") + << field("frontierSize", T_INT, "Frontier Size") + << field("hopCap", T_INT, "Hop Cap") + << field("budget", T_INT, "Per-Pass Budget") + << field("ttl", T_LONG, "Search TTL", F_DURATION_MILLIS) + << field("elapsed", T_LONG, "Elapsed Time", F_DURATION_MILLIS)) + << (type("datadog.Endpoint", T_ENDPOINT, "Endpoint") << category("Datadog") << field("startTime", T_LONG, "Start Time", F_TIME_TICKS) diff --git a/ddprof-lib/src/main/cpp/jfrMetadata.h b/ddprof-lib/src/main/cpp/jfrMetadata.h index 4d3f2a86e9..7a3fb72579 100644 --- a/ddprof-lib/src/main/cpp/jfrMetadata.h +++ b/ddprof-lib/src/main/cpp/jfrMetadata.h @@ -81,6 +81,12 @@ enum JfrType { T_UNWIND_FAILURE = 126, T_MALLOC = 127, T_NATIVE_SOCKET = 128, + // PROF-15341 Phase 6: reporting surface for ReferenceChainTracker + // (referenceChains.h/.cpp) - a reconstructed referrer-type chain, and a + // distinct event for a search abandoned before reaching a target (design + // doc's "no silent truncation" requirement), see jfrMetadata.cpp. + T_REFERENCE_CHAIN = 129, + T_REFERENCE_CHAIN_ABANDONED = 130, T_ANNOTATION = 200, T_LABEL = 201, T_CATEGORY = 202, diff --git a/ddprof-lib/src/main/cpp/livenessTracker.cpp b/ddprof-lib/src/main/cpp/livenessTracker.cpp index 17dd48b691..c3ca047376 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.cpp +++ b/ddprof-lib/src/main/cpp/livenessTracker.cpp @@ -4,11 +4,14 @@ */ #include +#include +#include #include #include #include #include "arch.h" +#include "common.h" #include "context.h" #include "context_api.h" #include "hotspot/vmStructs.h" @@ -27,16 +30,98 @@ constexpr int LivenessTracker::MAX_TRACKING_TABLE_SIZE; constexpr int LivenessTracker::MIN_SAMPLING_INTERVAL; -void LivenessTracker::cleanup_table(bool forced) { +namespace { + +// Earliest-third/recent-third mean and minimum of a chronological ring +// window - the one computation hasQualifyingGrowth() (per-klass count_ring) +// and heapFloorRising() (the aggregate _heap_floor_ring) both need, factored +// out so the window/index derivation and the two aggregation loops exist in +// exactly one place rather than three near-identical copies. Templated on +// the reader rather than the ring's element type or storage: the per-klass +// ring is a plain array read under the caller's already-held _table_lock, +// while the heap-floor ring is lock-free and read via loadAcquire() (see +// _heap_floor_ring's own comment, livenessTracker.h) - `read(i)` lets each +// caller supply its own access discipline for physical slot `i` without +// this shared loop needing to know which one applies. +struct RingThirdsStats { + double earliest_mean; + double recent_mean; + double earliest_min; + double recent_min; +}; + +template +bool ringThirdsStats(int head, int fill, int ring_size, int min_fill, + Reader read, RingThirdsStats *out) { + if (fill < min_fill) { + return false; + } + // Chronological (oldest-first) index of the window's first sample: while + // the ring hasn't wrapped yet (fill < ring_size), head == fill and the + // oldest sample sits at physical index 0; once wrapped, head is exactly + // the oldest (next-to-be-overwritten) slot. Both cases collapse to the + // same modular formula. + int start = (head - fill + ring_size) % ring_size; + // Integer division deliberately drops the remainder into an untouched + // middle third when fill isn't a multiple of 3 - "earliest third vs + // recent third" is already a cheap approximation (design doc's own + // rationale for not using least-squares), so this extra imprecision is + // consistent with that choice rather than a bug to round away. + int third = fill / 3; + if (third == 0) { + return false; + } + + double earliest_sum = 0, recent_sum = 0; + double earliest_min = std::numeric_limits::max(); + double recent_min = std::numeric_limits::max(); + for (int i = 0; i < third; i++) { + double v = read((start + i) % ring_size); + earliest_sum += v; + if (v < earliest_min) { + earliest_min = v; + } + } + for (int i = fill - third; i < fill; i++) { + double v = read((start + i) % ring_size); + recent_sum += v; + if (v < recent_min) { + recent_min = v; + } + } + + out->earliest_mean = earliest_sum / third; + out->recent_mean = recent_sum / third; + out->earliest_min = earliest_min; + out->recent_min = recent_min; + return true; +} + +} // namespace + +void LivenessTracker::cleanup_table(bool forced, bool allow_resolve) { u64 current = load(_last_gc_epoch); u64 target_gc_epoch = load(_gc_epoch); - - if ((target_gc_epoch == _last_gc_epoch || - !__atomic_compare_exchange_n(&_last_gc_epoch, ¤t, - target_gc_epoch, false, __ATOMIC_RELAXED, __ATOMIC_RELAXED)) && - !forced) { + TEST_LOG("LivenessTracker::cleanup_table forced=%d gc_generations=%d current_epoch=%llu " + "target_epoch=%llu table_size=%d", + forced, _gc_generations, (unsigned long long)current, + (unsigned long long)target_gc_epoch, _table_size); + + // is_epoch_owner is true iff this call is the one that moves _last_gc_epoch + // to target_gc_epoch - i.e. the first cleanup_table() call (forced or not) + // to observe this particular GC epoch transition. Population accounting + // below is gated on this rather than on !forced, so a forced (table- + // overflow) sweep still folds one sample per genuinely new epoch instead + // of either skipping it entirely or double-counting the same epoch across + // repeated forced sweeps. + bool is_epoch_owner = target_gc_epoch != current && + __atomic_compare_exchange_n(&_last_gc_epoch, ¤t, target_gc_epoch, + false, __ATOMIC_RELAXED, __ATOMIC_RELAXED); + + if (!is_epoch_owner && !forced) { // if the last processed GC epoch hasn't changed, or if we failed to update // it, there's nothing to do + TEST_LOG("LivenessTracker::cleanup_table early-exit: epoch unchanged and not forced"); return; } @@ -45,6 +130,32 @@ void LivenessTracker::cleanup_table(bool forced) { int epoch_diff = (int)(target_gc_epoch - current); _table_lock.lock(); + + // Detect a class-map reset the same way + // ReferenceChainTracker::resolveLoadedClasses() does (referenceChains.cpp) + // - see _last_class_map_generation's own comment (livenessTracker.h). + // cached_klass_id and _klass_population's klass_id keys are + // StringDictionary ids from whatever generation was current when they were + // resolved; once Profiler::start() clears that dictionary and restarts its + // id namespace, those cached ids can silently collide with a newly + // assigned, unrelated class. Drop every such cache before this pass reads + // or writes any of them. + u64 current_class_map_generation = Profiler::instance()->classMap()->generation(); + if (current_class_map_generation != _last_class_map_generation) { + for (u32 i = 0; i < _table_size; i++) { + _table[i].cached_klass_id = 0; + } + for (int i = 0; i < _klass_population_size; i++) { + jweak rep = _klass_population[i].representative; + if (rep != nullptr) { + env->DeleteWeakGlobalRef(rep); + } + } + _klass_population_size = 0; + _klass_count_scratch_size = 0; + _last_class_map_generation = current_class_map_generation; + } + u32 sz = _table_size; if (sz > 0) { u64 start = OS::nanotime(), end; @@ -61,6 +172,58 @@ void LivenessTracker::cleanup_table(bool forced) { _table[i].call_trace_id = 0; } _table[target].age += epoch_diff; + + if (_gc_generations && is_epoch_owner) { + // Per-klass population tracking (design doc's Open Question 3) - + // gated on _gc_generations so this new cost is paid only when the + // caller actually asked for generation/survival-shaped data + // (arguments.cpp:223-227,244), not for every liveness-tracking + // session. Gated on is_epoch_owner (not !forced) so a forced + // (table-overflow) sweep still contributes one population sample + // per genuinely new GC epoch instead of silently dropping it. + u32 klass_id = 0; + if (allow_resolve) { + // GetObjectClass + Class.getName() + StringDictionary lookup per + // surviving entry, previously paid only at JFR-flush time (see + // flush_table() below). Only affordable off the allocation-hot + // path - flush_table()/stop()'s cadence and + // LivenessTracker::maybeForceCleanup()'s background-thread tick + // both pass allow_resolve=true; track()'s hot-path forced sweep + // does not (see cleanup_table()'s own header comment). + jobject ref = env->NewLocalRef(_table[target].ref); + if (ref != nullptr) { + klass_id = resolveKlassId(env, ref); + if (klass_id != 0) { + // Cache the resolution: flush_table() runs its own + // GetObjectClass+Class.getName()+lookupClass() sequence for + // every surviving entry immediately after cleanup_table() + // returns (flush_table() always calls cleanup_table() first), + // which would otherwise repeat this exact JNI round-trip for + // the same object. An object's class is immutable, so this + // value stays valid for flush_table()'s read below, and for + // a later non-resolving sweep's read right below. + _table[target].cached_klass_id = klass_id; + } + env->DeleteLocalRef(ref); + } + } else { + // track()'s table-overflow branch calls cleanup_table(true, + // false) synchronously from the allocation-sampling call stack + // (JVMTI SampledObjectAlloc callback). resolveKlassId() calls + // Class.getName(), a genuine Java-bytecode upcall (unlike the + // plain native jvmti->GetClassSignature() call + // ObjectSampler::recordAllocation already makes on this same + // callback stack) - too costly, and too re-entrancy-prone via + // the String allocation it can trigger, to run from there. Reuse + // whatever class id an earlier resolving sweep already resolved + // for this entry instead; if it was never resolved, this entry's + // sample for this epoch is dropped rather than resolving now. + klass_id = _table[target].cached_klass_id; + } + if (klass_id != 0) { + accumulateKlassCount(klass_id, _table[target].ref); + } + } } else { jweak tmpRef = _table[i].ref; _table[i].ref = nullptr; @@ -71,6 +234,12 @@ void LivenessTracker::cleanup_table(bool forced) { _table_size = newsz; + TEST_LOG("LivenessTracker::cleanup_table survivors=%u klass_count_scratch_size=%d", + newsz, _klass_count_scratch_size); + if (_gc_generations && is_epoch_owner && _klass_count_scratch_size > 0) { + foldKlassCountsLocked(env, target_gc_epoch); + } + end = OS::nanotime(); Log::debug("Liveness tracker cleanup took %.2fms (%.2fus/element)", 1.0f * (end - start) / 1000 / 1000, @@ -79,6 +248,382 @@ void LivenessTracker::cleanup_table(bool forced) { _table_lock.unlock(); } +u32 LivenessTracker::resolveKlassId(JNIEnv *env, jobject ref) { + // Mirrors flush_table()'s own class-name resolution below (GetObjectClass + + // Class.getName() + Profiler::lookupClass()) - kept duplicated rather than + // factored out because flush_table() also needs to build an + // ObjectLivenessEvent around the result, which this call site does not. + // Unlike flush_table(), this call site also DeleteLocalRef()s name_str: it + // runs once per surviving TrackingEntry per GC epoch (cleanup_table()'s + // survivor loop above) rather than once per JFR flush, so an unreleased + // local ref here accumulates far faster within whatever native frame is + // driving cleanup_table(). + jclass clz = env->GetObjectClass(ref); + jstring name_str = (jstring)env->CallObjectMethod(clz, _Class_getName); + env->DeleteLocalRef(clz); + jniExceptionCheck(env); + u32 id = 0; + // getName() can return null (and leave name_str null) if the call above + // threw and jniExceptionCheck() cleared the pending exception rather than + // propagating it - GetStringUTFChars()/ReleaseStringUTFChars() require a + // non-null jstring, so guard both calls on name_str rather than passing a + // possibly-null reference into them. + if (name_str != nullptr) { + const char *name = env->GetStringUTFChars(name_str, nullptr); + if (name != nullptr) { + int lookup_id = Profiler::instance()->lookupClass(name, strlen(name)); + if (lookup_id > 0) { + id = (u32)lookup_id; + } + env->ReleaseStringUTFChars(name_str, name); + } + env->DeleteLocalRef(name_str); + } + return id; +} + +void LivenessTracker::accumulateKlassCount(u32 klass_id, jweak sample_source) { + for (int i = 0; i < _klass_count_scratch_size; i++) { + if (_klass_count_scratch[i].klass_id == klass_id) { + if (_klass_count_scratch[i].count < UINT16_MAX) { + _klass_count_scratch[i].count++; + } + return; + } + } + if (_klass_count_scratch_size < MAX_KLASS_POPULATION_ENTRIES) { + KlassCountScratch &slot = _klass_count_scratch[_klass_count_scratch_size++]; + slot.klass_id = klass_id; + slot.count = 1; + slot.sample_source = sample_source; + } + // else: this epoch's scratch snapshot already holds + // MAX_KLASS_POPULATION_ENTRIES distinct surviving klasses - klass_id's + // count for this epoch is dropped rather than growing the scratch array, + // the same best-effort tradeoff _klass_population's own fixed capacity + // already accepts. +} + +jweak LivenessTracker::recordKlassPopulationSampleLocked(u32 klass_id, + u16 count, + u64 epoch, + int *out_slot, + bool *out_created) { + // Linear scan is fine: MAX_KLASS_POPULATION_ENTRIES is small enough that a + // full scan is cheap, the same shape NativeSocketSampler's fd LRU + // (nativeSocketSampler.h:141-142) and this class's own cleanup_table() + // pass already accept for bounded tables. + int slot = -1; + int evict_slot = -1; + for (int i = 0; i < _klass_population_size; i++) { + if (_klass_population[i].klass_id == klass_id) { + slot = i; + break; + } + if (evict_slot < 0 || + _klass_population[i].last_updated_epoch < + _klass_population[evict_slot].last_updated_epoch) { + evict_slot = i; + } + } + + jweak evicted_ref = nullptr; + bool created = false; + if (slot < 0) { + created = true; + if (_klass_population_size < MAX_KLASS_POPULATION_ENTRIES) { + slot = _klass_population_size++; + } else { + // Table full - evict the least-recently-updated entry (evict_slot is + // guaranteed set here since MAX_KLASS_POPULATION_ENTRIES > 0 implies + // at least one iteration of the loop above ran). + slot = evict_slot; + evicted_ref = _klass_population[slot].representative; + } + _klass_population[slot].klass_id = klass_id; + _klass_population[slot].representative = nullptr; + _klass_population[slot].ring_head = 0; + _klass_population[slot].ring_fill = 0; + _klass_population[slot].consecutive_positive = 0; + _klass_population[slot].cached_slope = 0.0; + } + + KlassPopulationEntry &entry = _klass_population[slot]; + entry.count_ring[entry.ring_head] = count; + entry.ring_head = (u8)((entry.ring_head + 1) % KLASS_POPULATION_RING_SIZE); + if (entry.ring_fill < KLASS_POPULATION_RING_SIZE) { + entry.ring_fill++; + } + entry.last_updated_epoch = epoch; + + // Updated here (not in selectLeakCandidates()) so both the production path + // (foldKlassCountsLocked(), once per genuine GC epoch) and the + // klassPopulationRecordForTest() test seam - which calls this method + // directly - keep consecutive_positive in sync with the ring they just + // pushed, rather than requiring every caller to remember to do it (see + // this class's own header comment on hasQualifyingGrowth()). + if (hasQualifyingGrowth(entry)) { + if (entry.consecutive_positive < UINT8_MAX) { + entry.consecutive_positive++; + } + } else { + entry.consecutive_positive = 0; + } + + *out_slot = slot; + *out_created = created; + return evicted_ref; +} + +void LivenessTracker::foldKlassCountsLocked(JNIEnv *env, u64 epoch) { + TEST_LOG("LivenessTracker::foldKlassCountsLocked epoch=%llu scratch_size=%d", + (unsigned long long)epoch, _klass_count_scratch_size); + for (int i = 0; i < _klass_count_scratch_size; i++) { + KlassCountScratch &s = _klass_count_scratch[i]; + TEST_LOG("LivenessTracker::foldKlassCountsLocked scratch[%d] klass_id=%u count=%u", i, + s.klass_id, s.count); + int slot; + bool created; + jweak evicted = recordKlassPopulationSampleLocked(s.klass_id, s.count, + epoch, &slot, &created); + if (evicted != nullptr) { + env->DeleteWeakGlobalRef(evicted); + } + // Also retry minting when an existing entry's representative is stale: + // either the field itself is still nullptr (a klass whose first-epoch + // sample_source died in the brief window between cleanup_table()'s + // survival check and the mint attempt below), or the field holds a jweak + // handle whose referent has since died - a jweak's own pointer value + // never becomes nullptr just because its referent was collected, so a + // representative pinned to one specific instance that later dies (while + // other instances of the same still-growing klass keep surviving, so + // this entry keeps being re-selected as a leak candidate) would + // otherwise be left permanently unresolvable: the `created || + // representative == nullptr` check alone can only ever be true once per + // slot. last_updated_epoch keeps advancing every epoch this klass has + // survivors (right below), so it is never the LRU eviction victim that + // would otherwise let a fresh entry (and a fresh mint attempt) replace + // it. Resolving here every epoch bounds any given gap to "one epoch with + // no representative", not permanent. + jweak current_rep = _klass_population[slot].representative; + bool stale = false; + if (current_rep != nullptr) { + jobject probe = env->NewLocalRef(current_rep); + stale = (probe == nullptr); + if (probe != nullptr) { + env->DeleteLocalRef(probe); + } + } + if (created || current_rep == nullptr || stale) { + if (stale) { + env->DeleteWeakGlobalRef(current_rep); + _klass_population[slot].representative = nullptr; + } + // Mint a fresh, independent representative jweak rather than reusing + // s.sample_source directly - s.sample_source is the corresponding + // TrackingEntry's own weak ref, and that table slot's jweak gets + // deleted via DeleteWeakGlobalRef (this file's cleanup_table(), the + // "else" branch above) the moment the tracked object dies, which + // would leave _klass_population holding a dangling handle if it + // aliased the same jweak instead. + jobject strong = env->NewLocalRef(s.sample_source); + if (strong != nullptr) { + _klass_population[slot].representative = env->NewWeakGlobalRef(strong); + env->DeleteLocalRef(strong); + } + // else: this epoch's surviving instance for this klass died before we + // could mint a representative for it - the entry is left with + // representative == nullptr for this epoch and retried on the next one + // (see the retry condition's own comment above). + } + } + _klass_count_scratch_size = 0; +} + +bool LivenessTracker::hasQualifyingGrowth(KlassPopulationEntry &entry) const { + RingThirdsStats stats; + if (!ringThirdsStats( + entry.ring_head, entry.ring_fill, KLASS_POPULATION_RING_SIZE, + KLASS_POPULATION_MIN_FILL_FOR_TREND, + [&entry](int i) { return (double)entry.count_ring[i]; }, &stats)) { + return false; + } + + // Cached for selectLeakCandidates()'s ranking (KlassPopulationEntry:: + // cached_slope's own comment, livenessTracker.h) - the ring only changes + // on push, so this is the same value a later re-scan would compute. + entry.cached_slope = stats.recent_mean - stats.earliest_mean; + + double growth_bar = LEAK_GROWTH_REL_MIN * stats.earliest_mean; + if (growth_bar < LEAK_GROWTH_ABS_MIN) { + growth_bar = LEAK_GROWTH_ABS_MIN; + } + if (entry.cached_slope < growth_bar) { + // Mean didn't rise enough - either flat/shrinking, or a rise too small + // to be worth trusting yet. + return false; + } + + // The floor check: an oscillation whose current peak happens to satisfy + // the growth-magnitude test above still returns to its earlier baseline + // every cycle, so its minimum does not rise the way a real leak's does. + double floor_bar = LEAK_FLOOR_REL_MIN * stats.earliest_min; + if (floor_bar < LEAK_FLOOR_ABS_MIN) { + floor_bar = LEAK_FLOOR_ABS_MIN; + } + return (stats.recent_min - stats.earliest_min) >= floor_bar; +} + +void LivenessTracker::recordHeapFloorSample(u64 used) { + // Lock-free, single-writer-at-a-time - see _heap_floor_ring's own comment + // (livenessTracker.h) for why onGC() cannot take _table_lock here. + // + // Standard SPSC publish order: the payload is a plain store, and the index + // that gates which slots are valid is what carries the release. A reader + // that loadAcquire()s the index is then guaranteed to see this payload + // write too, since it precedes the index's storeRelease() in program + // order and a release store cannot be reordered before an earlier store. + // (The previous version had this backwards - storeRelease() on the + // payload with a plain store on the index - which does not establish any + // ordering between "the index says this slot is valid" and "the payload + // for that slot is visible".) + u8 head = load(_heap_floor_ring_head); + store(_heap_floor_ring[head], used); + storeRelease(_heap_floor_ring_head, (u8)((head + 1) % KLASS_POPULATION_RING_SIZE)); + u8 fill = load(_heap_floor_ring_fill); + if (fill < KLASS_POPULATION_RING_SIZE) { + storeRelease(_heap_floor_ring_fill, (u8)(fill + 1)); + } +} + +bool LivenessTracker::heapFloorRising() const { + // Matches recordHeapFloorSample()'s storeRelease() on both index fields - + // loadAcquire() here is what makes the payload writes below visible. + u8 fill = loadAcquire(_heap_floor_ring_fill); + u8 head = loadAcquire(_heap_floor_ring_head); + RingThirdsStats stats; + if (!ringThirdsStats( + head, fill, KLASS_POPULATION_RING_SIZE, + KLASS_POPULATION_MIN_FILL_FOR_TREND, + [this](int i) { return (double)load(_heap_floor_ring[i]); }, + &stats)) { + return false; + } + + double growth_bar = HEAP_FLOOR_GROWTH_REL_MIN * stats.earliest_mean; + if (growth_bar < (double)HEAP_FLOOR_GROWTH_ABS_MIN) { + growth_bar = (double)HEAP_FLOOR_GROWTH_ABS_MIN; + } + if ((stats.recent_mean - stats.earliest_mean) < growth_bar) { + return false; + } + + double floor_bar = HEAP_FLOOR_FLOOR_REL_MIN * stats.earliest_min; + if (floor_bar < (double)HEAP_FLOOR_FLOOR_ABS_MIN) { + floor_bar = (double)HEAP_FLOOR_FLOOR_ABS_MIN; + } + return (stats.recent_min - stats.earliest_min) >= floor_bar; +} + +int LivenessTracker::selectLeakCandidates(KlassCandidate *out, int max) { + int cap = max < MAX_LEAK_CANDIDATES ? max : MAX_LEAK_CANDIDATES; + if (cap <= 0) { + return 0; + } + + // Kept sorted descending by slope magnitude, at most `cap` (<= + // MAX_LEAK_CANDIDATES == 5) entries - not one per klass - so an + // insertion-sort-style insert per candidate (O(cap) per insert, O(N*cap) + // overall for N <= MAX_KLASS_POPULATION_ENTRIES == 256 klasses) is cheaper + // and simpler than collecting every qualifying candidate and calling + // std::sort. + // A single call, shared by every candidate this scan considers - see + // LEAK_TREND_HYSTERESIS_BASE/CORROBORATED's own comment (livenessTracker.h) + // for why an aggregate, non-attributed signal can only raise or lower the + // bar uniformly, never reorder candidates against each other. Lock-free + // (heapFloorRising()'s own comment), so no relation to _table_lock below. + const int required_hysteresis = heapFloorRising() + ? LEAK_TREND_HYSTERESIS_CORROBORATED + : LEAK_TREND_HYSTERESIS_BASE; + + double best_slopes[MAX_LEAK_CANDIDATES]; + int count = 0; + + // Read-only pass over _klass_population - mirrors getLiveTraceIds()'s own + // shared-lock read pattern above, the same table cleanup_table() writes + // under the exclusive lock this shared lock is taken against. + _table_lock.lockShared(); + // Only log when there is actually something to scan - this runs on every + // BFS-thread wake (once per second), so logging an empty scan turns the + // steady, idle state into per-second noise. + if (_klass_population_size > 0) { + TEST_LOG("LivenessTracker::selectLeakCandidates scanning %d klass_population entries", + _klass_population_size); + } + for (int i = 0; i < _klass_population_size; i++) { + const KlassPopulationEntry &entry = _klass_population[i]; + // cached_slope was computed by hasQualifyingGrowth() the last time this + // entry was pushed (recordKlassPopulationSampleLocked()) - the ring only + // changes on push, so re-scanning it here would just recompute the same + // value a moment later. + bool has_trend = entry.ring_fill >= KLASS_POPULATION_MIN_FILL_FOR_TREND; + double slope = entry.cached_slope; + TEST_LOG("LivenessTracker::selectLeakCandidates entry[%d] klass_id=%u ring_fill=%u " + "has_trend=%d slope=%f consecutive_positive=%u required=%d representative=%p", + i, entry.klass_id, entry.ring_fill, has_trend, has_trend ? slope : 0.0, + entry.consecutive_positive, required_hysteresis, (void *)entry.representative); + if (!has_trend || slope <= 0 || entry.consecutive_positive < required_hysteresis) { + // Not enough history yet, flat/shrinking, or hasn't shown a + // qualifying rise (hasQualifyingGrowth()) for enough consecutive + // epochs yet to trust it over sampling/oscillation noise. + continue; + } + if (count == cap && slope <= best_slopes[cap - 1]) { + // Already holding `cap` stronger (or equal) candidates - this one + // doesn't make the cut. + continue; + } + + int pos = count < cap ? count++ : cap - 1; + best_slopes[pos] = slope; + out[pos] = KlassCandidate{entry.klass_id, entry.representative}; + while (pos > 0 && best_slopes[pos - 1] < best_slopes[pos]) { + double tmp_slope = best_slopes[pos - 1]; + best_slopes[pos - 1] = best_slopes[pos]; + best_slopes[pos] = tmp_slope; + KlassCandidate tmp_cand = out[pos - 1]; + out[pos - 1] = out[pos]; + out[pos] = tmp_cand; + pos--; + } + } + _table_lock.unlockShared(); + + return count; +} + +jobject LivenessTracker::resolveCandidateRepresentative(JNIEnv *env, u32 klass_id) { + // Shared lock excludes cleanup_table()'s exclusive lock (the only writer, + // and the only place that can DeleteWeakGlobalRef() an entry's + // representative via foldKlassCountsLocked()'s eviction path above) for + // the whole lookup+resolve, so the value NewLocalRef() runs on here is + // always the table's current one for klass_id, never a snapshot that + // eviction could have invalidated in the meantime - see + // selectLeakCandidates()'s own comment for the race this closes. + _table_lock.lockShared(); + jobject obj = nullptr; + for (int i = 0; i < _klass_population_size; i++) { + if (_klass_population[i].klass_id == klass_id) { + if (_klass_population[i].representative != nullptr) { + obj = env->NewLocalRef(_klass_population[i].representative); + } + break; + } + } + _table_lock.unlockShared(); + return obj; +} + void LivenessTracker::flush(std::set &tracked_thread_ids) { if (!_enabled) { // disabled @@ -112,15 +657,31 @@ void LivenessTracker::flush_table(std::set *tracked_thread_ids) { event._skipped = _table[i].skipped; event._ctx = _table[i].ctx; - jclass clz = env->GetObjectClass(ref); - jstring name_str = (jstring)env->CallObjectMethod(clz, _Class_getName); - env->DeleteLocalRef(clz); - jniExceptionCheck(env); - const char *name = env->GetStringUTFChars(name_str, nullptr); - int class_id = name != nullptr - ? Profiler::instance()->lookupClass(name, strlen(name)) - : 0; - env->ReleaseStringUTFChars(name_str, name); + int class_id = 0; + if (_table[i].cached_klass_id != 0) { + // Already resolved by cleanup_table()'s survivor loop this epoch + // (resolveKlassId(), only when _gc_generations is enabled) - reuse + // it instead of repeating the GetObjectClass+Class.getName()+ + // lookupClass() JNI round-trip for the same object. + class_id = _table[i].cached_klass_id; + } else { + jclass clz = env->GetObjectClass(ref); + jstring name_str = (jstring)env->CallObjectMethod(clz, _Class_getName); + env->DeleteLocalRef(clz); + jniExceptionCheck(env); + // name_str can be null if the call above threw and + // jniExceptionCheck() cleared the pending exception rather than + // propagating it - GetStringUTFChars()/ReleaseStringUTFChars() + // require a non-null jstring (mirrors resolveKlassId()'s own guard). + if (name_str != nullptr) { + const char *name = env->GetStringUTFChars(name_str, nullptr); + if (name != nullptr) { + class_id = Profiler::instance()->lookupClass(name, strlen(name)); + env->ReleaseStringUTFChars(name_str, name); + } + env->DeleteLocalRef(name_str); + } + } // lookupClass() returns -1 when the class map is at capacity; do not // assign it to the u32 event id (it would wrap to 0xFFFFFFFF and @@ -138,13 +699,8 @@ void LivenessTracker::flush_table(std::set *tracked_thread_ids) { _table_lock.unlock(); if (_record_heap_usage) { - bool isLastGc = HeapUsage::isLastGCUsageSupported(); - size_t used = isLastGc ? HeapUsage::get()._used_at_last_gc - : loadAcquire(_used_after_last_gc); - if (used == 0) { - used = HeapUsage::get()._used; - isLastGc = false; - } + bool isLastGc; + size_t used = resolvePostGcHeapUsage(&isLastGc); Profiler::instance()->writeHeapUsage(used, isLastGc); } @@ -220,6 +776,14 @@ void LivenessTracker::stop() { Error LivenessTracker::initialize(Arguments &args) { _enabled = args._gc_generations || args._record_liveness; + // Gates per-klass population tracking (see the _gc_generations member's + // own comment in livenessTracker.h). Updated unconditionally alongside + // _record_heap_usage below, ahead of the _initialized guard, for the same + // reason: each profiler start should observe the flag it was actually + // started with, even though the tracking table itself persists across + // recordings. + _gc_generations = args._gc_generations; + if (!_enabled) { return Error::OK; } @@ -379,6 +943,7 @@ void LivenessTracker::track(JNIEnv *env, AllocEvent &event, jint tid, _table[idx].age = 0; _table[idx].call_trace_id = call_trace_id; _table[idx].ctx = ContextApi::snapshot(); + _table[idx].cached_klass_id = 0; } _table_lock.unlockShared(); @@ -389,8 +954,10 @@ void LivenessTracker::track(JNIEnv *env, AllocEvent &event, jint tid, retried = true; // try cleanup before resizing - there is a good chance it will free some - // space - cleanup_table(true); + // space. allow_resolve=false: this runs synchronously on the + // allocation-sampling callback stack (see cleanup_table()'s own header + // comment for why resolveKlassId() is unsafe here). + cleanup_table(true, false); if (_table_cap < _table_max_cap) { @@ -432,11 +999,47 @@ void LivenessTracker::track(JNIEnv *env, AllocEvent &event, jint tid, } } +void LivenessTracker::maybeForceCleanup(u64 now_ns) { + if (!_enabled || !_gc_generations) { + return; + } + constexpr u64 FORCE_CLEANUP_INTERVAL_NS = 30ULL * 1000 * 1000 * 1000; + u64 last_cleanup_ns = load(_last_cleanup_ns); + if (now_ns - last_cleanup_ns < FORCE_CLEANUP_INTERVAL_NS) { + return; + } + if (load(_gc_epoch) == load(_last_gc_epoch)) { + // Nothing happened since the last sweep (organic, forced, or a prior + // call to this method) - re-walking an unchanged table would just + // re-fold the same survivor counts into this epoch's scratch, skewing + // the slope computed from it. Leave _last_cleanup_ns alone so the next + // wake keeps checking at the same ~1s cadence rather than restarting a + // fresh 30s wait with nothing to show for it. + return; + } + store(_last_cleanup_ns, now_ns); + cleanup_table(true, true); +} + void JNICALL LivenessTracker::GarbageCollectionFinish(jvmtiEnv *jvmti_env) { ProfiledThread::initCurrentThreadSignalSafe(); LivenessTracker::instance()->onGC(); } +size_t LivenessTracker::resolvePostGcHeapUsage(bool *out_is_last_gc) { + bool isLastGc = HeapUsage::isLastGCUsageSupported(); + size_t used = isLastGc ? HeapUsage::get()._used_at_last_gc + : loadAcquire(_used_after_last_gc); + if (used == 0) { + used = HeapUsage::get()._used; + isLastGc = false; + } + if (out_is_last_gc != nullptr) { + *out_is_last_gc = isLastGc; + } + return used; +} + void LivenessTracker::onGC() { if (!_initialized) { return; @@ -448,6 +1051,16 @@ void LivenessTracker::onGC() { if (!HeapUsage::isLastGCUsageSupported()) { store(_used_after_last_gc, HeapUsage::get(false)._used); } + + if (_gc_generations) { + // Feeds heapFloorRising()'s corroboration check (selectLeakCandidates()) + // - gated on _gc_generations, same as the per-klass population table + // itself, since this ring exists purely to support that feature. + size_t used = resolvePostGcHeapUsage(nullptr); + if (used > 0) { + recordHeapFloorSample((u64)used); + } + } } void LivenessTracker::getLiveTraceIds(std::unordered_set& out_buffer) { diff --git a/ddprof-lib/src/main/cpp/livenessTracker.h b/ddprof-lib/src/main/cpp/livenessTracker.h index 622a03c810..97c7c6b414 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.h +++ b/ddprof-lib/src/main/cpp/livenessTracker.h @@ -27,8 +27,76 @@ typedef struct TrackingEntry { jlong time; jlong age; Context ctx; + // Set by cleanup_table()'s survivor loop via resolveKlassId() when + // _gc_generations is enabled (0 otherwise, or if resolution failed - 0 is + // StringDictionary's own "no entry" sentinel, so a real id is never 0). + // flush_table() reuses this instead of re-resolving the same object's + // class via a second GetObjectClass+Class.getName()+lookupClass() JNI + // round-trip - an object's class never changes, so a value resolved here + // stays valid for flush_table()'s later read of the same entry. track() + // resets this to 0 for every newly tracked entry. + u32 cached_klass_id; } TrackingEntry; +// Fixed-capacity, LRU-evicted per-klass population history, keyed by klass +// StringDictionary id (Profiler::classMap(), the same id TrackingEntry/ +// AllocEvent resolves lazily today only at flush time, flush_table() below). +// This is the data doc/architecture/LiveHeapReferenceChains.md's Open +// Question 3 "positive population-slope ranking" proposal needs: a rolling +// window of how many tracked instances of a klass are alive at each GC +// epoch, plus one representative instance to chase a chain for if the trend +// looks leak-shaped (see selectLeakCandidates() below for the ranking, and +// referenceChains.cpp's pollWatchedTargets() for how a ranked candidate gets +// consumed - this struct only stores the raw history). +typedef struct KlassPopulationEntry { + u32 klass_id; // StringDictionary id; 0 means "unused slot" (0 is + // also StringDictionary's own "no entry" sentinel, + // so a real id is never 0 - see resolveKlassId()). + jweak representative; // a currently-live instance of this klass, owned by + // this table (its own weak global ref, deliberately + // NOT aliasing any TrackingEntry::ref - see + // foldKlassCountsLocked()'s comment for why aliasing + // would leave a dangling handle once cleanup_table() + // reaps the original TrackingEntry). + u16 count_ring[30]; // ring buffer of per-epoch live population counts + u8 ring_head; // next slot to write + u8 ring_fill; // samples written so far, caps at 30 + // Number of consecutive epochs (most recent first) for which + // LivenessTracker::hasQualifyingGrowth() found this entry's ring to show a + // leak-shaped rise, updated every time a new sample is pushed + // (recordKlassPopulationSampleLocked()) - reset to 0 the moment a single + // epoch fails the test. selectLeakCandidates() requires this to reach a + // hysteresis threshold before trusting the klass, rather than acting on + // one qualifying epoch alone: a population merely oscillating (no net + // growth) satisfies a single-epoch test on roughly half of all epochs, so + // without this counter it gets reported as a leak candidate almost as + // often as a real leak does. + u8 consecutive_positive; + // Slope (recent third's mean minus earliest third's mean) as of the last + // push, computed and cached by hasQualifyingGrowth() alongside + // consecutive_positive above - selectLeakCandidates() reads this directly + // for ranking instead of re-scanning the ring: the ring only changes on + // push, so a second scan at scan time would just recompute the same + // value. Meaningless (left at its previous value, or 0 for a newly + // created entry) whenever ring_fill < KLASS_POPULATION_MIN_FILL_FOR_TREND + // - callers must check ring_fill first, exactly as before this field + // existed. + double cached_slope; + u64 last_updated_epoch; // _gc_epoch value as of the last write, for LRU + // eviction when the table is full +} KlassPopulationEntry; + +// One leak-candidate result from selectLeakCandidates() below: the klass to +// chase and a currently-live representative instance of it, ready to hand to +// referenceChains.cpp's pollWatchedTargets() (the design doc's Open Question +// 3 bridging step). Deliberately excludes the slope/rank that produced the +// ranking - the caller only needs identity, matching the design doc's own +// "KlassCandidate { u32 klass_id; jweak representative; }" sketch exactly. +typedef struct KlassCandidate { + u32 klass_id; + jweak representative; +} KlassCandidate; + // Aligned to satisfy SpinLock member alignment requirement (64 bytes) // Required because this class contains SpinLock _table_lock member class alignas(alignof(SpinLock)) LivenessTracker { @@ -39,6 +107,75 @@ class alignas(alignof(SpinLock)) LivenessTracker { constexpr static int MAX_TRACKING_TABLE_SIZE = 262144; constexpr static int MIN_SAMPLING_INTERVAL = 524288; // 512kiB + // _klass_population/_klass_count_scratch below are both scanned linearly + // (lookup and LRU-eviction search) - this size keeps every such scan cheap + // enough that a plain linear scan is fine, rather than requiring an index. + constexpr static int MAX_KLASS_POPULATION_ENTRIES = 256; + // Design doc's Open Question 3 proposal: "ring buffer of up to 30 recent + // population counts". + constexpr static int KLASS_POPULATION_RING_SIZE = 30; + // Design doc's Open Question 3 proposal: "Only trust the trend once the + // window has a minimum fill (e.g. ≥10 samples) to avoid noise right after + // a klass starts being tracked." + constexpr static int KLASS_POPULATION_MIN_FILL_FOR_TREND = 10; + // Design doc's Open Question 3 proposal: "seed only the top 3-5 by trend + // magnitude" - this is the upper end of that range. selectLeakCandidates() + // also honors the caller-supplied `max`, so the effective cutoff is + // min(max, MAX_LEAK_CANDIDATES, ); + // "no separate budget constant is needed" per the design doc, this top-N + // cutoff doubles as the per-pass seeding cap. + constexpr static int MAX_LEAK_CANDIDATES = 5; + + // --- Sustained-trend gate (hasQualifyingGrowth() below) --- + // The original single-epoch test ("recent third's mean exceeds the + // earliest third's") has no magnitude floor: a population merely + // oscillating with no net growth satisfies it on roughly half of all + // epochs, so it gets reported as a leak candidate almost as often as a + // real leak does. Two independent, both-required conditions replace it: + // the recent third's mean must exceed the earliest third's by a + // meaningful margin (LEAK_GROWTH_REL_MIN/LEAK_GROWTH_ABS_MIN, whichever is + // larger), AND the recent third's *minimum* must exceed the earliest + // third's minimum by a meaningful margin (LEAK_FLOOR_REL_MIN/ + // LEAK_FLOOR_ABS_MIN) - the floor check is what an oscillation still + // fails even if its current peak happens to look like growth, since an + // oscillation's floor returns to its starting level every cycle while a + // real leak's floor only rises. + constexpr static double LEAK_GROWTH_REL_MIN = 0.15; + constexpr static int LEAK_GROWTH_ABS_MIN = 5; + constexpr static double LEAK_FLOOR_REL_MIN = 0.10; + constexpr static int LEAK_FLOOR_ABS_MIN = 4; + + // Required number of consecutive qualifying epochs + // (KlassPopulationEntry::consecutive_positive) before selectLeakCandidates() + // trusts a klass as a leak candidate. Lower (CORROBORATED) when the + // aggregate post-GC live heap (heapFloorRising() below) is independently + // showing a sustained rise of its own over the same horizon - that is + // whole-heap evidence this klass's growth isn't an isolated artifact + // (redistribution/churn that nets out heap-wide, or per-klass sampling + // noise), so fewer of this klass's own epochs are needed to trust it. + // heapFloorRising() is a single call per selectLeakCandidates() scan, not + // per candidate: the aggregate heap has no per-klass attribution, so it + // cannot single out which klass (if any) is responsible for its rise - + // it can only raise or lower the bar for every candidate in that scan + // uniformly, never reorder them against each other. + constexpr static int LEAK_TREND_HYSTERESIS_BASE = 5; + constexpr static int LEAK_TREND_HYSTERESIS_CORROBORATED = 3; + + // --- Aggregate post-GC heap floor (heapFloorRising() below) --- + // Same "mean-of-thirds growth + floor rise" shape as the per-klass test + // above, applied to a single global ring of post-GC live heap size + // instead of one klass's sampled population - see this class's own ring + // (_heap_floor_ring below). Its own thresholds are deliberately looser + // (fraction-of-heap, not fraction-of-one-klass): this signal is diluted by + // every other klass's allocation activity (a leak far smaller than these + // thresholds is invisible against the rest of the heap), so it is not + // sensitive enough to gate on directly - it is used only as the + // LEAK_TREND_HYSTERESIS_BASE/CORROBORATED selector above. + constexpr static double HEAP_FLOOR_GROWTH_REL_MIN = 0.02; + constexpr static u64 HEAP_FLOOR_GROWTH_ABS_MIN = 1ULL << 20; // 1MiB + constexpr static double HEAP_FLOOR_FLOOR_REL_MIN = 0.01; + constexpr static u64 HEAP_FLOOR_FLOOR_ABS_MIN = 1ULL << 19; // 512KiB + bool _initialized; bool _enabled; Error _stored_error; @@ -59,12 +196,117 @@ class alignas(alignof(SpinLock)) LivenessTracker { volatile u64 _gc_epoch; volatile u64 _last_gc_epoch; + // Timestamp (OS::nanotime()) of the last cleanup_table() sweep that + // actually ran, whether organic (flush_table()'s JFR cadence) or forced + // (track()'s table-overflow branch). Read/written only by + // maybeForceCleanup() below - see that method's own comment for why a + // third, time-based trigger is needed on top of those two. + volatile u64 _last_cleanup_ns; + size_t _used_after_last_gc; + // Ring of post-GC live heap sizes, one sample per GC epoch, feeding + // heapFloorRising() below - same shape as KlassPopulationEntry::count_ring + // but a single global instance rather than one per klass, and lock-free + // rather than _table_lock-guarded: onGC() runs from the JVMTI + // GarbageCollectionFinish callback, which can fire synchronously mid-way + // through a JNI upcall this class itself is making while already holding + // _table_lock (e.g. cleanup_table()'s Class.getName() call, if that + // allocation triggers a GC) - taking the same lock here would risk a + // self-deadlock on a non-reentrant SpinLock. GC completions are never + // concurrent with each other (HotSpot never runs two GCs at once), so + // onGC() is always a single writer at a time, matching the existing + // lock-free _gc_epoch/_used_after_last_gc fields' own assumption - see + // recordHeapFloorSample()/heapFloorRising() (livenessTracker.cpp) for the + // load/store ordering this relies on. + u64 _heap_floor_ring[KLASS_POPULATION_RING_SIZE]; + volatile u8 _heap_floor_ring_head; + volatile u8 _heap_floor_ring_fill; + + // Gates the per-klass population tracking below. Set from + // args._gc_generations in initialize() - deliberately not folded into + // _enabled (which also covers plain _record_liveness): this doesn't + // resolve the design doc's own "still undecided" bullet under Open + // Question 3 by itself, but the plan built on top of this table requires + // liveness tracking *and* _gc_generations, matching the doc's stated + // fallback of "no target-seeding" when generations tracking isn't on + // (arguments.cpp:223-227,244). + bool _gc_generations; + + // Per-klass population history table (see KlassPopulationEntry above). + // Populated only from cleanup_table()'s GC-epoch-advance pass, never from + // track() (the allocation sampling hot path) - see + // accumulateKlassCount()/foldKlassCountsLocked() below. Guarded by + // _table_lock, the same lock cleanup_table() already holds for the + // duration of its epoch-advance pass, rather than adding a second lock. + KlassPopulationEntry _klass_population[MAX_KLASS_POPULATION_ENTRIES]; + int _klass_population_size; + + // Scratch space reused across cleanup_table() calls (a member field, not a + // per-call stack/heap allocation - cleanup_table() runs on a GC-signal + // cadence, not the allocation hot path, but this codebase's + // allocation-free preference still applies wherever avoiding an + // allocation is cheap) to accumulate this epoch's per-klass surviving + // counts before folding them into _klass_population's ring buffers at the + // end of the pass. + typedef struct KlassCountScratch { + u32 klass_id; + u16 count; + jweak sample_source; // the original TrackingEntry::ref of the first + // surviving instance of this klass seen this + // epoch; consulted only by foldKlassCountsLocked() + // when klass_id turns out to need a brand new + // KlassPopulationEntry, to derive a fresh, + // independent representative jweak (see that + // method's comment for why the original handle + // cannot be reused directly). + } KlassCountScratch; + KlassCountScratch _klass_count_scratch[MAX_KLASS_POPULATION_ENTRIES]; + int _klass_count_scratch_size; + + // Profiler::classMap()'s generation as of the last cleanup_table() call + // that checked it, mirroring ReferenceChainTracker::_last_class_map_generation + // (referenceChains.h). Profiler::start() calls _class_map.clearAll() + // (profiler.cpp) whenever `reset || _start_time == 0`, restarting that + // StringDictionary's id namespace at 1 - but TrackingEntry::cached_klass_id + // and _klass_population's klass_id keys are ids resolved from that + // dictionary, and both survive stop()/start() cycles (this class's table is + // designed to persist across recordings). Left unguarded, an id cached + // before a reset would silently collide with whatever unrelated class the + // new generation reassigns that same id to. cleanup_table() compares this + // against Profiler::instance()->classMap()->generation() and, on a + // mismatch, drops every such cached id before resuming. Initialized to 0 + // (StringDictionary's own initial generation), not a sentinel, since a + // cleanup_table() call before any clearAll() has ever run must NOT treat + // that as a mismatch. + u64 _last_class_map_generation; + Error initialize(Arguments &args); Error initialize_table(JNIEnv *jni, int sampling_interval); - void cleanup_table(bool force = false); + // force=true is used by track()'s table-overflow branch to run a cleanup + // synchronously from the allocation-sampling call stack, bypassing the + // GC-epoch-changed check below. The per-klass population tracking below + // (_gc_generations) runs on both paths, once per genuinely new GC epoch + // (see "is_epoch_owner" in livenessTracker.cpp). + // + // allow_resolve gates resolveKlassId() - a real Class.getName() + // Java-bytecode upcall, unlike the plain native JVMTI calls already made + // elsewhere on track()'s callback stack - independently of force: force + // only says "bypass the epoch-unchanged early-exit", it says nothing about + // which call stack this is running on. track()'s hot-path call passes + // force=true, allow_resolve=false (too costly/re-entrancy-prone to resolve + // from the SampledObjectAlloc callback stack - reuses whatever + // cached_klass_id an entry already picked up from an earlier resolving + // sweep, or skips accounting for that entry this epoch if it was never + // resolved). flush_table()/stop() pass the defaults (force=false, + // allow_resolve=true) - the original organic, GC-cadence path. LivenessTracker::maybeForceCleanup() passes force=true, + // allow_resolve=true: it runs on ReferenceChainTracker's own background + // thread (referenceChains.cpp), not the allocation hot path, so the same + // upcalls flush_table() already makes safely are just as safe there - see + // that method's own comment for why a third caller needs both bypassing + // the early-exit *and* resolution. + void cleanup_table(bool force = false, bool allow_resolve = true); void flush_table(std::set *tracked_thread_ids); @@ -73,6 +315,115 @@ class alignas(alignof(SpinLock)) LivenessTracker { jlong getMaxMemory(JNIEnv *env); + // Resolves the best available post-GC heap usage sample, mirroring + // flush_table()'s own resolution order (JDK17+ exact + // CollectedHeap::_used_at_last_gc when supported, otherwise onGC()'s own + // _used_after_last_gc snapshot, falling back to a live usage read if + // neither has produced anything yet, e.g. before the first GC). Shared by + // flush_table()'s JFR event and onGC()'s heap-floor ring sample so both + // read the same value the same way. Returns 0 only if HeapUsage itself has + // nothing to offer. *out_is_last_gc (if non-null) reports which case was + // used, for callers (flush_table()) that need to say so in the JFR event. + size_t resolvePostGcHeapUsage(bool *out_is_last_gc); + + // --- Per-klass population tracking (cleanup_table()'s epoch-advance pass only) --- + + // Resolves the StringDictionary id for `ref`'s class, mirroring + // flush_table()'s existing class-name resolution above (GetObjectClass + + // Class.getName() + Profiler::lookupClass()) - this is the "genuinely new + // cost on an existing pass" the design doc flags, previously paid only at + // JFR-flush time. Returns 0 (StringDictionary's own "no entry" sentinel) + // if the name could not be resolved or interned. + u32 resolveKlassId(JNIEnv *env, jobject ref); + + // Increments klass_id's running sample count in _klass_count_scratch for + // the epoch currently being processed, creating a new scratch slot (with + // `sample_source` remembered for a possible new KlassPopulationEntry) if + // this is the first surviving instance of this klass seen so far this + // epoch. No-op if the scratch table is already full and klass_id is not + // present - the same fixed-capacity/best-effort tradeoff + // _klass_population's own table already accepts, one level up. + void accumulateKlassCount(u32 klass_id, jweak sample_source); + + // Pushes `count` into klass_id's ring buffer, creating the entry (evicting + // the least-recently-updated entry first if the table is already at + // MAX_KLASS_POPULATION_ENTRIES capacity - the same evict-LRU-on-insert- + // when-full shape NativeSocketSampler's fd cache already solves, + // nativeSocketSampler.h:141-142/184's insertFdAddrLocked(), and the same + // "single agent-owned pass, lock already held by caller" shape + // cleanup_table() itself already uses) if klass_id has never been seen. + // A newly-created entry's `representative` is left null - it is the + // caller's job (foldKlassCountsLocked(), which owns the JNIEnv this + // method deliberately does not touch) to fill it in, which keeps this + // method free of any JNI call and therefore directly exercisable by gtest + // without a live JVM. On return, *out_slot is the table slot used for + // klass_id and *out_created is true iff a new entry was created (an + // evicted-and-reused slot counts as "created", since the old klass_id's + // data was fully replaced). Returns the evicted entry's representative + // jweak (nullptr if nothing was evicted, or the evicted entry had none) + // so the caller can DeleteWeakGlobalRef() it. + // Precondition: _table_lock is held (by cleanup_table(), the only + // production caller). + jweak recordKlassPopulationSampleLocked(u32 klass_id, u16 count, u64 epoch, + int *out_slot, bool *out_created); + + // Drains _klass_count_scratch into _klass_population for the epoch that + // just finished, minting a fresh representative jweak (from each entry's + // KlassCountScratch::sample_source) for klasses not already present, and + // retrying the mint for existing entries whose representative is still + // null (a previous epoch's mint attempt can fail if sample_source died in + // the window between cleanup_table()'s survival check and the mint - see + // this method's own retry-condition comment, livenessTracker.cpp) - see + // recordKlassPopulationSampleLocked()'s comment for why that JNI work + // happens here rather than inside it. A fresh weak global ref + // is used instead of aliasing sample_source directly because + // sample_source is the corresponding TrackingEntry's own jweak: that + // entry's slot in _table is reused (and its jweak deleted via + // DeleteWeakGlobalRef) the moment the tracked object dies and + // cleanup_table() reaps it, which would leave _klass_population holding a + // dangling handle if it aliased the same jweak. Resets + // _klass_count_scratch_size to 0 once drained. Called with _table_lock + // held, at the end of cleanup_table()'s epoch-advance pass. + void foldKlassCountsLocked(JNIEnv *env, u64 epoch); + + // --- Slope computation and candidate ranking (selectLeakCandidates() below) --- + + // The sustained-trend gate (this class's own header comment above, + // "Sustained-trend gate") - both-required growth-magnitude and floor-rise + // tests, design doc's explicit "mean of thirds" choice over full + // least-squares regression (cheap, allocation-free, one pass over the + // ring, no sorting or extra storage). A single scan + // (ringThirdsStats(), livenessTracker.cpp) both derives the pass/fail + // result below AND updates entry.cached_slope (recent third's mean minus + // earliest third's mean) for selectLeakCandidates()'s ranking, rather than + // that method re-scanning the same unchanged ring a moment later. Returns + // false (leaving entry.cached_slope untouched) if entry.ring_fill is below + // KLASS_POPULATION_MIN_FILL_FOR_TREND - not enough history yet to trust a + // trend; callers must check ring_fill themselves before trusting + // cached_slope, exactly as they checked this method's own return value + // before cached_slope existed. + // + // Called from recordKlassPopulationSampleLocked() every time a new sample + // is pushed (both the production path, + // foldKlassCountsLocked()->recordKlassPopulationSampleLocked(), and the + // klassPopulationRecordForTest() test seam that calls the same method + // directly), so KlassPopulationEntry::consecutive_positive/cached_slope + // are always kept in sync with the ring they summarize, regardless of + // caller. + bool hasQualifyingGrowth(KlassPopulationEntry &entry) const; + + // Pushes `used` into _heap_floor_ring - see that member's own comment for + // why this is lock-free rather than _table_lock-guarded. Called only from + // onGC() (single-writer-at-a-time, same comment). + void recordHeapFloorSample(u64 used); + + // Reads whether the aggregate post-GC live heap has itself shown a + // sustained rise over _heap_floor_ring's horizon - see + // LEAK_TREND_HYSTERESIS_BASE/CORROBORATED's own comment above for how + // selectLeakCandidates() uses this (a uniform hysteresis-threshold + // selector for the whole scan, never a per-candidate veto or boost). + bool heapFloorRising() const; + public: static LivenessTracker *instance() { static LivenessTracker instance; @@ -87,7 +438,11 @@ class alignas(alignof(SpinLock)) LivenessTracker { _table_size(0), _table_cap(0), _table_max_cap(0), _table(NULL), _subsample_ratio(0.1), _record_heap_usage(false), _Class(NULL), _Class_getName(0), _gc_epoch(0), _last_gc_epoch(0), - _used_after_last_gc(0) {} + _last_cleanup_ns(0), _used_after_last_gc(0), + _heap_floor_ring_head(0), _heap_floor_ring_fill(0), + _gc_generations(false), + _klass_population_size(0), _klass_count_scratch_size(0), + _last_class_map_generation(0) {} Error start(Arguments &args); void stop(); @@ -101,8 +456,170 @@ class alignas(alignof(SpinLock)) LivenessTracker { // threads. Safe to call even if this thread never called track(). static void releaseThreadLocalState(); + // Reads the per-klass population histories (_klass_population) and + // writes up to `max` leak candidates into `out`: klasses whose recent + // population trend is positive (growing), ranked by trend magnitude + // descending, capped at MAX_LEAK_CANDIDATES regardless of `max` (design + // doc's Open Question 3 "top 3-5" cutoff). Returns the number of + // candidates written (0 if _gc_generations was never enabled - + // _klass_population stays empty in that case, since population tracking + // is gated on it, so no separate guard is needed here). Called on demand + // by the BFS-pass poll, not on any timer of its own; does no JNI work, so it is safe + // to call from any thread that can take _table_lock (mirrors + // getLiveTraceIds()'s own shared-lock read pattern, livenessTracker.cpp). + // + // The `representative` jweak copied into KlassCandidate here is a snapshot + // only - callers MUST NOT resolve it directly (e.g. via NewLocalRef()) + // after this method has returned and _table_lock released. This table's + // LRU eviction (recordKlassPopulationSampleLocked(), livenessTracker.cpp) + // can DeleteWeakGlobalRef() that exact handle at any point afterwards + // (from cleanup_table()'s epoch-advance pass, running on a different + // thread), which invalidates the handle - a later NewLocalRef() on it is + // undefined behavior per the JNI spec, not merely "returns null". Use + // resolveCandidateRepresentative() below instead, which re-reads the + // table's current value for klass_id atomically with the resolve. + int selectLeakCandidates(KlassCandidate *out, int max); + + // Re-reads klass_id's current representative from _klass_population and + // resolves it to a fresh JNI local ref, both under the same _table_lock + // critical section - closes the race selectLeakCandidates()'s own comment + // above describes: a KlassCandidate snapshot returned by that method can + // go stale (LRU-evicted and DeleteWeakGlobalRef()'d) at any point before a + // caller gets around to resolving it. Looking the entry up again by + // klass_id here, under lock, guarantees NewLocalRef() only ever runs on a + // representative jweak this table still actually owns at the moment of the + // call: if klass_id has since been evicted (or was never assigned a + // representative), the lookup simply fails to find it and this returns + // nullptr without ever touching the stale handle. Returns nullptr if + // klass_id is no longer present, has no representative yet, or the + // representative's referent has since been collected (NewLocalRef() on a + // jweak returns null in that case, JNI spec). Mirrors the shared-lock read + // pattern selectLeakCandidates()/getLiveTraceIds() already use. + jobject resolveCandidateRepresentative(JNIEnv *env, u32 klass_id); + + // Exposes the _gc_generations gate (see that member's own comment) so a + // caller outside this class - ReferenceChainTracker::pollWatchedTargets() + // (referenceChains.cpp), PROF-15341's LivenessTracker-to-ReferenceChainTracker + // bridging step - can skip + // calling selectLeakCandidates() entirely when the feature isn't in use, + // rather than relying on that method's own "returns 0" fallback to make + // the no-op cheap. Read-only; this accessor never toggles the flag. + bool gcGenerationsEnabled() const { return _gc_generations; } + + // Third trigger for cleanup_table(), alongside track()'s table-overflow + // branch (forced) and flush_table()'s JFR-flush cadence (organic): those + // two both depend on ObjectSampler's allocation-sampling callback firing + // often enough. ObjectSampler::updateConfiguration()'s PID controller + // throttles the JVMTI heap sampling interval toward a fixed target *event + // rate*, not a fixed *byte* rate - under sustained, fast heap growth this + // can push the interval high enough that SampledObjectAlloc (and therefore + // track()) stops firing in practice, starving cleanup_table() of both its + // forced trigger and the per-klass population samples + // selectLeakCandidates()'s slope computation needs. If that happens, the + // history cleanup_table() would otherwise have advanced goes stale and + // ReferenceChainTracker::hasLeakSignal() can never see a positive trend + // again, no matter how much the leaking population actually grows. + // + // Called once per ReferenceChainTracker::threadLoop wake (~1s cadence, see + // referenceChains.cpp) with a live JNIEnv already in hand - a convenient, + // already-existing periodic tick, not a new thread. No-ops unless both: + // (a) at least 30s have passed since the last cleanup_table() sweep + // (organic, forced, or one run by this method), and (b) at least one GC + // has happened since then (gcEpoch() != _last_gc_epoch) - so this never + // does a pointless sweep of an unchanged table. + void maybeForceCleanup(u64 now_ns); + static void JNICALL GarbageCollectionFinish(jvmtiEnv *jvmti_env); + // Test seams - not part of the production API. Mirrors + // NativeSocketSampler's own "for testing only" accessors + // (nativeSocketSampler.h's fdAddrCacheSizeForTest()/ + // fdAddrCacheInsertForTest()) rather than befriending the test binary. + // These only exercise the JNI-free ring/eviction mechanics + // (recordKlassPopulationSampleLocked() takes no JNIEnv), never + // foldKlassCountsLocked()'s representative-minting step, which needs a + // live JVM and is therefore out of gtest's reach. + int klassPopulationSizeForTest() const { return _klass_population_size; } + bool klassPopulationLookupForTest(u32 klass_id, KlassPopulationEntry *out) const { + for (int i = 0; i < _klass_population_size; i++) { + if (_klass_population[i].klass_id == klass_id) { + *out = _klass_population[i]; + return true; + } + } + return false; + } + jweak klassPopulationRecordForTest(u32 klass_id, u16 count, u64 epoch, + int *out_slot, bool *out_created) { + return recordKlassPopulationSampleLocked(klass_id, count, epoch, out_slot, + out_created); + } + // Sets an entry's representative directly - production code only ever + // does this via foldKlassCountsLocked()'s JNI-dependent minting step + // (out of gtest's reach, see the class comment above), so tests use this + // seam instead to set up a fake representative and assert it comes back + // out of recordKlassPopulationSampleLocked() as the evicted jweak when + // that entry is later LRU-evicted. No-op if klass_id is not present. + // Also called from a live-JVM test (not just gtest) while the BFS thread + // (ReferenceChainTracker::threadLoop()) may concurrently be inside + // cleanup_table()'s epoch-advance pass, which holds _table_lock while + // mutating _klass_population/_klass_population_size - so this seam takes + // the same lock rather than writing the field unguarded (mirrors + // klassPopulationResetForTest() immediately below). Deletes any previous + // representative via DeleteWeakGlobalRef() before overwriting, the same + // way foldKlassCountsLocked() handles a stale representative on eviction - + // otherwise repeated calls for the same klass_id leak a JNI weak global + // ref per call. + void klassPopulationSetRepresentativeForTest(JNIEnv *env, u32 klass_id, jweak rep) { + _table_lock.lock(); + for (int i = 0; i < _klass_population_size; i++) { + if (_klass_population[i].klass_id == klass_id) { + jweak prev = _klass_population[i].representative; + _klass_population[i].representative = rep; + _table_lock.unlock(); + if (prev != nullptr) { + env->DeleteWeakGlobalRef(prev); + } + return; + } + } + _table_lock.unlock(); + } + // Unlike the other klassPopulation*ForTest() seams above, this one is + // also called from a live-JVM test (not just gtest) while the BFS thread + // (ReferenceChainTracker::threadLoop()) may concurrently be inside + // cleanup_table()'s epoch-advance pass, which holds _table_lock while + // mutating _klass_population_size/_klass_population - so this seam must + // take the same lock rather than writing the field unguarded. + void klassPopulationResetForTest() { + _table_lock.lock(); + _klass_population_size = 0; + _table_lock.unlock(); + // Also reset the heap-floor ring: it is a sibling piece of the same + // _gc_generations-gated feature, read by every selectLeakCandidates() + // scan (heapFloorRising()), so leaving it populated across tests in the + // same gtest binary would leak one test's heap-usage history into the + // next test's hysteresis threshold. + store(_heap_floor_ring_head, (u8)0); + store(_heap_floor_ring_fill, (u8)0); + } + + // Test seams for the heap-floor ring (mirrors klassPopulation*ForTest()'s + // own seams immediately above) - lock-free, see _heap_floor_ring's own + // comment, so no locking wrapper is needed here either. + void heapFloorRecordForTest(u64 used) { recordHeapFloorSample(used); } + bool heapFloorRisingForTest() const { return heapFloorRising(); } + + // Sets _gc_generations directly, bypassing initialize() (which requires a + // live JVM - VM::hotspot_version()/VM::jni(), see that method's own code - + // out of gtest's reach the same way foldKlassCountsLocked()'s + // representative-minting step is, per this seam block's own comment + // above). Callers outside this class that only need to exercise + // gcGenerationsEnabled()'s gate (e.g. referenceChains_ut.cpp's + // pollWatchedTargets() tests) use this instead of standing up a full + // initialize()/start() call. + void setGcGenerationsForTest(bool v) { _gc_generations = v; } + private: void getLiveTraceIds(std::unordered_set& out_buffer); }; diff --git a/ddprof-lib/src/main/cpp/objectSampler.cpp b/ddprof-lib/src/main/cpp/objectSampler.cpp index 1cc4cabe38..bdae094380 100644 --- a/ddprof-lib/src/main/cpp/objectSampler.cpp +++ b/ddprof-lib/src/main/cpp/objectSampler.cpp @@ -175,11 +175,17 @@ Error ObjectSampler::start(Arguments &args) { return error; } if (_interval > 0) { - if (_record_liveness || _gc_generations) { - error = LivenessTracker::instance()->start(args); - if (error) { - return error; - } + // Always call through, even when this start's own args request neither + // liveness recording nor gc generations: LivenessTracker::start() -> + // initialize() refreshes its own _gc_generations/_enabled from args + // unconditionally (see that method's own comment) and is a no-op beyond + // that when disabled. Gating this call on ObjectSampler's own + // (freshly-set, correct) flags left LivenessTracker's flags stuck at + // whatever the previous recording in this process last set them to, + // since it never got a chance to observe this recording's request at all. + error = LivenessTracker::instance()->start(args); + if (error) { + return error; } jvmtiEnv *jvmti = VM::jvmti(); @@ -205,9 +211,9 @@ void ObjectSampler::stop() { jvmti->SetEventNotificationMode(JVMTI_DISABLE, JVMTI_EVENT_SAMPLED_OBJECT_ALLOC, NULL); - if (_record_liveness || _gc_generations) { - LivenessTracker::instance()->stop(); - } + // See start()'s own comment on why this call is unconditional - + // LivenessTracker::stop() already self-guards on its own _enabled. + LivenessTracker::instance()->stop(); } Error ObjectSampler::updateConfiguration(u64 events, double time_coefficient) { diff --git a/ddprof-lib/src/main/cpp/os_linux.cpp b/ddprof-lib/src/main/cpp/os_linux.cpp index ab59d8f195..04ca738e70 100644 --- a/ddprof-lib/src/main/cpp/os_linux.cpp +++ b/ddprof-lib/src/main/cpp/os_linux.cpp @@ -730,9 +730,12 @@ int OS::createMemoryFile(const char* name) { void OS::copyFile(int src_fd, int dst_fd, off_t offset, size_t size) { // copy_file_range() is probably better, but not supported on all kernels + size_t requested = size; while (size > 0) { ssize_t bytes = sendfile(dst_fd, src_fd, &offset, size); if (bytes <= 0) { + TEST_LOG("OS::copyFile sendfile returned %zd, errno=%d, remaining=%zu of requested=%zu", + bytes, errno, size, requested); break; } size -= (size_t)bytes; diff --git a/ddprof-lib/src/main/cpp/painBudget.h b/ddprof-lib/src/main/cpp/painBudget.h new file mode 100644 index 0000000000..6e1ddb9f5f --- /dev/null +++ b/ddprof-lib/src/main/cpp/painBudget.h @@ -0,0 +1,83 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _PAINBUDGET_H +#define _PAINBUDGET_H + +#include "arch.h" + +/* + * A leaky bucket over *cost* (milliseconds of expensive work already spent), + * not over an event *rate* - unlike PidController/RateLimiter (which target + * a steady events-per-second throughput), this answers "have we spent too + * much recently to justify doing more expensive work right now?" + * + * Typical use: a subsystem that occasionally does one genuinely expensive, + * bounded operation (here: a full-heap BFS pass) wants to avoid doing that + * operation back-to-back if it keeps being expensive, while still allowing + * it immediately again if the last one was cheap. spend() records how much + * an operation cost; canStartNow() drains the balance by however much + * wall-clock time has passed (at _refill_rate) and reports whether the + * debt has cleared. + * + * _refill_rate is the one tunable: the fraction of wall-clock time this + * budget is willing to let its owner spend on the expensive operation, on + * average (e.g. 0.01 = "at most ~1% of wall-clock time, averaged over + * time"). Unlike PidController's gain triples (P/I/D), this single ratio + * has a direct, human-interpretable meaning and needs no derivation beyond + * picking that target fraction. + */ +class PainBudget { +private: + double _balance_ms; // accumulated debt in ms; 0 means "clear to spend" + double _refill_rate; // fraction of wall-clock time allowed, e.g. 0.01 + u64 _last_update_ns; // OS::nanotime() as of the last drain(); 0 = never drained yet + + void drain(u64 now_ns) { + if (_last_update_ns == 0) { + // First call ever - nothing to drain yet, just establish the baseline. + _last_update_ns = now_ns; + return; + } + u64 elapsed_ns = now_ns - _last_update_ns; + double elapsed_ms = (double)elapsed_ns / 1000000.0; + // _refill_rate == 0.0 (the default constructor argument) makes this a + // no-op forever: the balance never drains, so once spend() has pushed it + // above 0 canStartNow() stays false permanently. Callers that want the + // budget to actually refill must pass a positive _refill_rate. + _balance_ms -= elapsed_ms * _refill_rate; + if (_balance_ms < 0) { + _balance_ms = 0; + } + _last_update_ns = now_ns; + } + +public: + explicit PainBudget(double refill_rate = 0.0) + : _balance_ms(0), _refill_rate(refill_rate), _last_update_ns(0) {} + + // Records that an operation just cost `pain_ms` milliseconds of + // wall-clock time. Does not drain first - the cost is added on top of + // whatever debt (already correctly drained as of the last canStartNow() + // call) currently exists. + void spend(u64 pain_ms) { _balance_ms += (double)pain_ms; } + + // True once the debt has drained back to zero at _refill_rate - i.e. it + // is now affordable, on average, to spend more pain. Drains the balance + // as a side effect, so repeated calls correctly reflect elapsed time + // even if spend() is never called again. + bool canStartNow(u64 now_ns) { + drain(now_ns); + return _balance_ms <= 0; + } + + // Test/introspection only - current debt after draining as of now_ns. + double balanceMs(u64 now_ns) { + drain(now_ns); + return _balance_ms; + } +}; + +#endif // _PAINBUDGET_H diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index 60aa659d8a..3d3e582a39 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -31,6 +31,7 @@ #include "objectSampler.h" #include "os.h" #include "perfEvents.h" +#include "referenceChains.h" #include "safeAccess.h" #include "stackFrame.h" #include "stackWalker.h" @@ -843,6 +844,89 @@ void Profiler::writeHeapUsage(long value, bool live) { _locks[lock_index].unlock(); } +void Profiler::writeReferenceChainAbandoned(ReferenceChainAbandonedEvent *event) { + int tid = ProfiledThread::currentTid(); + if (tid < 0) { + return; + } + u32 lock_index = getLockIndex(tid); + if (!_locks[lock_index].tryLock() && + !_locks[lock_index = (lock_index + 1) % CONCURRENCY_LEVEL].tryLock() && + !_locks[lock_index = (lock_index + 2) % CONCURRENCY_LEVEL].tryLock()) { + return; + } + _jfr.recordReferenceChainAbandoned(lock_index, event); + _locks[lock_index].unlock(); +} + +// Unlike writeReferenceChainAbandoned() above (mirroring CPU/wall's signal-handler-safe +// non-blocking pattern out of caution, even though its own call site - Profiler::dump(), +// profiler.cpp - isn't a signal handler either), this call site genuinely cannot be one: +// this is called from Profiler::dump()'s drain loop, on dump()'s own calling thread, once +// per event snapshotted from ReferenceChainTracker::_resolved_chains (up to +// MAX_RESOLVED_CHAINS per dump) - never from pollWatchedTargets() or any other call on +// ReferenceChainTracker's own BFS agent thread, and never from a signal handler. A single +// bare 3-slot tryLock() sweep with no wait - correct for a signal handler, which must never +// block - was found, by running PROF-15341's end-to-end integration test +// (ddprof-test's ReferenceChainTrackingTest.shouldReconstructReferrerChainToGcRoot) for real, +// to drop this event under perfectly ordinary contention: the same _locks[] pool is shared +// with every other sample type (recordJVMTISample() et al.), and any nontrivial allocation +// throughput keeps enough of CONCURRENCY_LEVEL's slots busy that 3 immediate, back-to-back +// attempts routinely all miss. A bounded retry with a short sleep between sweeps costs +// nothing the dump()-thread cannot afford, but the retry budget below is a single deadline +// shared across the *entire* drain batch (see the caller in dump()) rather than per event: +// with up to MAX_RESOLVED_CHAINS events snapshotted, a fresh per-event budget could stall +// the dump/JFR-flush thread for seconds under contention. Once the shared deadline has +// passed this degrades to the same single non-blocking 3-slot sweep as +// writeReferenceChainAbandoned() above for the remainder of the batch. +void Profiler::writeReferenceChain(ReferenceChainEvent *event, u64 deadline_ns) { + int tid = ProfiledThread::currentTid(); + if (tid < 0) { + TEST_LOG("Profiler::writeReferenceChain drop: currentTid() < 0"); + return; + } + u32 lock_index; + bool locked = false; + int sweeps = 0; + u64 start_ns = OS::nanotime(); + for (;;) { + sweeps++; + lock_index = getLockIndex(tid); + if (_locks[lock_index].tryLock() || + _locks[lock_index = (lock_index + 1) % CONCURRENCY_LEVEL].tryLock() || + _locks[lock_index = (lock_index + 2) % CONCURRENCY_LEVEL].tryLock()) { + locked = true; + break; + } + if (OS::nanotime() >= deadline_ns) { + // Shared batch budget exhausted - the sweep just above was already a + // single non-blocking attempt, so stop retrying rather than sleeping + // again. + break; + } + usleep(1000); + } + if (!locked) { + // Unlike the drain-once era, this drop is NOT permanent: the event was + // only copied out of ReferenceChainTracker::_resolved_chains + // (drainPendingChainEvents() snapshots without clearing), so as long as + // the sample stays live the next dump re-emits it and gets another chance + // at the lock. Still counted like every other counted-drop path + // (REFERENCE_CHAIN_WRITE_DROPPED's own comment) rather than dropping it + // silently. + Counters::increment(REFERENCE_CHAIN_WRITE_DROPPED); + TEST_LOG("Profiler::writeReferenceChain drop: lock contention exhausted shared " + "deadline after sweeps=%d waited_us=%llu", + sweeps, (unsigned long long)((OS::nanotime() - start_ns) / 1000)); + return; + } + TEST_LOG("Profiler::writeReferenceChain locked lock_index=%u after sweeps=%d " + "waited_us=%llu", + lock_index, sweeps, (unsigned long long)((OS::nanotime() - start_ns) / 1000)); + _jfr.recordReferenceChain(lock_index, event); + _locks[lock_index].unlock(); +} + bool Profiler::prewarmUnwinder() { #ifdef __linux__ // J9 on aarch64 (and other JVMs) lazily loads libgcc_s.so.1 from its DWARF @@ -1659,6 +1743,27 @@ Error Profiler::start(Arguments &args, bool reset) { // Paired with drainInflight() on the stop side. _cpu_engine->enableEvents(true); + // Independent of the CPU/wall/alloc engine mask above (GC-triggered, not + // sample-triggered) - gated only on its own args._reference_chains flag, + // same pattern as malloc_tracer/NativeSocketSampler being gated on their + // own flags rather than folded into `activated`. Placed after the + // engines are confirmed running (inside this `if (activated)` block) so + // there is nothing to unwind here if it fails - see this method's + // failure path below, which never reaches this point. + if (args._reference_chains) { + error = ReferenceChainTracker::instance()->start(args); + if (error) { + Log::warn("%s", error.message()); + error = Error::OK; // recoverable + } else { + // Only safe once the JVM/JVMTI environment is fully up, which is + // guaranteed at this point in Profiler::start() - see + // ReferenceChainTracker::start()'s own comment (referenceChains.cpp) + // for why this is not called from inside start() itself. + ReferenceChainTracker::instance()->startThread(); + } + } + _state.store(RUNNING, std::memory_order_release); _start_time = time(NULL); __atomic_add_fetch(&_epoch, 1, __ATOMIC_RELAXED); @@ -1703,6 +1808,13 @@ Error Profiler::stop() { _alloc_engine->stop(); if (_event_mask & EM_NATIVEMEM) malloc_tracer.stop(); + // Not part of _event_mask (see the matching start() block above) - gated + // on enabled() instead, which start() set from args._reference_chains for + // this session. + if (ReferenceChainTracker::instance()->enabled()) { + ReferenceChainTracker::instance()->stopThread(); + ReferenceChainTracker::instance()->stop(); + } // Stop the refresher BEFORE socket unpatch: the refresher calls // install_socket_hooks() which re-reads _socket_active before acquiring the // patch lock. If the refresher runs concurrently with unpatch_socket_functions() @@ -1848,6 +1960,55 @@ Error Profiler::dump(const char *path, const int length) { // by the live objects LivenessTracker::instance()->flush(thread_ids); + // ReferenceChainTracker::_resolved_chains (and the search-state fields + // read below) are intentionally left populated across a stop()/start() + // cycle - see _resolved_chains' own comment (referenceChains.h) - but + // that means they can still hold state from a *previous* recording that + // had referencechains enabled, even once the current recording started + // with referencechains=false (in which case ReferenceChainTracker:: + // start() sets _enabled=false and no BFS thread is polling to ever + // refresh or prune them). Gate both emissions on the current session's + // flag so an opted-out recording does not keep re-reporting a dead + // session's abandoned search or stale resolved chains. + if (ReferenceChainTracker::instance()->enabled()) { + // If ReferenceChainTracker's search has ended in ABANDONED (Termination + // section, referenceChains.h), report it via a datadog.ReferenceChainAbandoned + // event. Mirrors the flush() call directly above; unlike that table this + // read does not clear any state, so a dump taken again after this point + // re-reports the same abandoned search rather than losing it. + if (ReferenceChainTracker::instance()->searchState() == + SearchState::ABANDONED) { + ReferenceChainAbandonedEvent rc_event; + if (ReferenceChainTracker::instance()->buildAbandonedEvent(&rc_event)) { + rc_event._start_time = TSC::ticks(); + writeReferenceChainAbandoned(&rc_event); + } + } + + // Re-emit every currently-cached datadog.ReferenceChain pollWatchedTargets() + // (referenceChains.cpp) has resolved - snapshotted here, on this call's + // own thread, rather than written eagerly from the BFS scheduling thread + // that discovered them (see ReferenceChainTracker::_resolved_chains' own + // comment for why the cache re-emits on every dump rather than draining). + std::vector pending_chain_events; + ReferenceChainTracker::instance()->drainPendingChainEvents( + &pending_chain_events); + // One ~50ms retry budget for the *whole* batch, not per event - + // writeReferenceChain()'s own comment for why: up to + // MAX_RESOLVED_CHAINS events can be snapshotted, and a fresh per-event + // budget would let this dump()-thread stall for seconds under ordinary + // _locks[] contention. + const u64 kChainDrainBudgetNs = 50 * 1000000ULL; + u64 chain_drain_deadline_ns = OS::nanotime() + kChainDrainBudgetNs; + long long write_dropped_before = Counters::getCounter(REFERENCE_CHAIN_WRITE_DROPPED); + for (auto &rc_event : pending_chain_events) { + writeReferenceChain(&rc_event, chain_drain_deadline_ns); + } + TEST_LOG("Profiler::dump reference-chain batch=%d write_dropped=%lld", + (int)pending_chain_events.size(), + Counters::getCounter(REFERENCE_CHAIN_WRITE_DROPPED) - write_dropped_before); + } + Libraries::instance()->refresh(); updateJavaThreadNames(); updateNativeThreadNames(); @@ -1865,6 +2026,9 @@ Error Profiler::dump(const char *path, const int length) { err = _jfr.dump(path, length); __atomic_add_fetch(&_epoch, 1, __ATOMIC_SEQ_CST); }); + if (err) { + TEST_LOG("Profiler::dump _jfr.dump failed: %s", err.message()); + } _thread_info.clearAll(thread_ids); _thread_info.reportCounters(); diff --git a/ddprof-lib/src/main/cpp/profiler.h b/ddprof-lib/src/main/cpp/profiler.h index 4010616e9d..a5d93ad948 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -183,7 +183,9 @@ class alignas(alignof(SpinLock)) Profiler { // // rotate() is self-contained: it uses _accepting + RefCountGuard to drain // concurrent JNI readers, and SignalBlocker prevents profiling signals on - // this thread from inserting into old_active between Phase 1 and Phase 2. + // this thread from inserting into old_active between the pre-populate copy + // step and the catch-up copy step of the dictionary's two-step rotation + // (see StringDictionary::rotate(), stringDictionary.h). // No external lock is required for rotation. // // lockAll() wraps jfr_op only — to gate call-trace writers (signal handlers @@ -453,6 +455,20 @@ class alignas(alignof(SpinLock)) Profiler { void writeDatadogProfilerSetting(int tid, int length, const char *name, const char *value, const char *unit); void writeHeapUsage(long value, bool live); + // Mirrors writeHeapUsage()'s shape exactly. Called from dump() whenever + // ReferenceChainTracker's search has ended in SearchState::ABANDONED, + // the same way LivenessTracker::flush() is called from dump(). + void writeReferenceChainAbandoned(ReferenceChainAbandonedEvent *event); + // Unlike writeReferenceChainAbandoned() above, this is NOT a bare 3-slot + // tryLock() sweep - it retries with a bounded, sleeping loop because its + // call site is dump()'s drain loop (profiler.cpp), on dump()'s own calling + // thread, which can tolerate blocking, unlike a signal handler; see this + // method's own comment in profiler.cpp for why that retry exists. + // `deadline_ns` is a single retry budget shared across dump()'s *entire* + // drain batch (not reset per event) - see the caller in dump() and this + // method's own comment in profiler.cpp for why a per-event budget would be + // unbounded across a large batch. + void writeReferenceChain(ReferenceChainEvent *event, u64 deadline_ns); int eventMask() const { return _event_mask; } bool isRemoteSymbolication() const { return _remote_symbolication; } diff --git a/ddprof-lib/src/main/cpp/referenceChains.cpp b/ddprof-lib/src/main/cpp/referenceChains.cpp new file mode 100644 index 0000000000..763926464d --- /dev/null +++ b/ddprof-lib/src/main/cpp/referenceChains.cpp @@ -0,0 +1,2671 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "referenceChains.h" +#include "common.h" +#include "counters.h" +#include "jniHelper.h" +#include "livenessTracker.h" +#include "log.h" +#include "objectSampler.h" +#include "os.h" +#include "profiler.h" +#include "tsc.h" +#include "vmEntry.h" +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// FrontierTable (tag-indexed frontier metadata table) +// --------------------------------------------------------------------------- + +FrontierTable::FrontierTable(int max_cap) + : _table_size(0), _table_cap(0), _table_max_cap(std::max(max_cap, 0)), + _table(nullptr) { + _table_cap = std::min(INITIAL_TABLE_CAPACITY, _table_max_cap); + if (_table_cap > 0) { + _table = (FrontierEntry *)calloc(_table_cap, sizeof(FrontierEntry)); + if (_table == nullptr) { + _table_cap = 0; + } + } + Counters::increment(REFERENCE_CHAIN_FRONTIER_TABLE_BYTES, + (jlong)_table_cap * sizeof(FrontierEntry)); + Counters::increment(REFERENCE_CHAIN_FRONTIER_TABLE_CAPACITY, _table_cap); +} + +FrontierTable::~FrontierTable() { + Counters::increment(REFERENCE_CHAIN_FRONTIER_TABLE_BYTES, + -(jlong)_table_cap * sizeof(FrontierEntry)); + Counters::increment(REFERENCE_CHAIN_FRONTIER_TABLE_CAPACITY, -_table_cap); + free(_table); +} + +void FrontierTable::resetCapacityForTest(int max_cap) { + _table_lock.lock(); + Counters::increment(REFERENCE_CHAIN_FRONTIER_TABLE_BYTES, + -(jlong)_table_cap * sizeof(FrontierEntry)); + Counters::increment(REFERENCE_CHAIN_FRONTIER_TABLE_CAPACITY, -_table_cap); + free(_table); + _table = nullptr; + _table_max_cap = std::max(max_cap, 0); + _table_cap = std::min(INITIAL_TABLE_CAPACITY, _table_max_cap); + if (_table_cap > 0) { + _table = (FrontierEntry *)calloc(_table_cap, sizeof(FrontierEntry)); + if (_table == nullptr) { + _table_cap = 0; + } + } + Counters::increment(REFERENCE_CHAIN_FRONTIER_TABLE_BYTES, + (jlong)_table_cap * sizeof(FrontierEntry)); + Counters::increment(REFERENCE_CHAIN_FRONTIER_TABLE_CAPACITY, _table_cap); + _table_size.store(0, std::memory_order_relaxed); + _table_lock.unlock(); +} + +bool FrontierTable::growLocked(int required_cap) { + if (required_cap <= _table_cap) { + return true; + } + if (_table_cap >= _table_max_cap) { + return false; + } + + int newcap = _table_cap; + while (newcap < required_cap && newcap < _table_max_cap) { + newcap = newcap == 0 ? std::min(INITIAL_TABLE_CAPACITY, _table_max_cap) + : std::min(newcap * 2, _table_max_cap); + } + if (newcap <= _table_cap) { + return false; + } + + FrontierEntry *tmp = + (FrontierEntry *)realloc(_table, sizeof(FrontierEntry) * newcap); + if (tmp == nullptr) { + Log::debug( + "ReferenceChains: frontier table resize to %d entries failed", newcap); + return false; + } + // realloc() does not zero the newly grown region - clear it so lookup() + // never returns garbage state for a slot that hasn't been inserted yet. + memset(tmp + _table_cap, 0, sizeof(FrontierEntry) * (newcap - _table_cap)); + Counters::increment(REFERENCE_CHAIN_FRONTIER_TABLE_BYTES, + (jlong)(newcap - _table_cap) * sizeof(FrontierEntry)); + Counters::increment(REFERENCE_CHAIN_FRONTIER_TABLE_CAPACITY, + newcap - _table_cap); + _table = tmp; + _table_cap = newcap; + return _table_cap >= required_cap; +} + +bool FrontierTable::insert(jlong tag, jlong parent_tag, u32 referrer_klass, + u32 depth, u8 state, u8 root_kind) { + if (tag <= 0 || tag - 1 > (jlong)INT_MAX) { + return false; + } + int idx = (int)(tag - 1); + + // Exclusive lock for the whole write (growLocked() already requires it) - + // a shared lock here would not exclude lookup()'s own shared-mode read of + // the same slot, letting a concurrent reader observe a torn entry. + _table_lock.lock(); + if (idx >= _table_cap && !growLocked(idx + 1)) { + _table_lock.unlock(); + Log::debug("ReferenceChains: frontier table capacity exhausted " + "(cap=%d, max=%d, tag=%lld)", + _table_cap, _table_max_cap, (long long)tag); + return false; + } + _table[idx].parent_tag = parent_tag; + _table[idx].referrer_klass = referrer_klass; + _table[idx].depth = depth; + _table[idx].state = state; + _table[idx].root_kind = root_kind; + _table_lock.unlock(); + + int sz = _table_size.load(std::memory_order_relaxed); + while (sz < idx + 1 && + !_table_size.compare_exchange_weak(sz, idx + 1, + std::memory_order_relaxed)) { + // sz reloaded with the current value by compare_exchange_weak on + // failure; retry until either this thread wins or another thread + // already advanced _table_size past idx + 1. + } + return true; +} + +bool FrontierTable::lookup(jlong tag, FrontierEntry *out) { + if (tag <= 0 || tag - 1 > (jlong)INT_MAX) { + return false; + } + int idx = (int)(tag - 1); + + bool found = false; + _table_lock.lockShared(); + if (idx < _table_size) { + *out = _table[idx]; + found = true; + } + _table_lock.unlockShared(); + return found; +} + +bool FrontierTable::lookupLocked(jlong tag, FrontierEntry *out) const { + if (tag <= 0 || tag - 1 > (jlong)INT_MAX) { + return false; + } + int idx = (int)(tag - 1); + if (idx < _table_size) { + *out = _table[idx]; + return true; + } + return false; +} + +void FrontierTable::clear(jlong tag) { + if (tag <= 0 || tag - 1 > (jlong)INT_MAX) { + return; + } + int idx = (int)(tag - 1); + + // Exclusive lock: this mutates a slot lookup() may be reading concurrently + // under its own shared lock (see insert()'s own comment above). + _table_lock.lock(); + if (idx < _table_size) { + _table[idx].state = FrontierEntryState::ABANDONED; + } + _table_lock.unlock(); +} + +void FrontierTable::markEdge(jlong tag) { + if (tag <= 0 || tag - 1 > (jlong)INT_MAX) { + return; + } + int idx = (int)(tag - 1); + + _table_lock.lock(); + if (idx < _table_size) { + _table[idx].state = FrontierEntryState::EDGE; + } + _table_lock.unlock(); +} + +void FrontierTable::markExpanded(jlong tag) { + if (tag <= 0 || tag - 1 > (jlong)INT_MAX) { + return; + } + int idx = (int)(tag - 1); + + _table_lock.lock(); + if (idx < _table_size) { + _table[idx].state = FrontierEntryState::EXPANDED; + } + _table_lock.unlock(); +} + +void FrontierTable::updateRootKind(jlong tag, u8 root_kind) { + if (tag <= 0 || tag - 1 > (jlong)INT_MAX) { + return; + } + int idx = (int)(tag - 1); + + _table_lock.lock(); + if (idx < _table_size) { + _table[idx].root_kind = root_kind; + } + _table_lock.unlock(); +} + +bool FrontierTable::reconstructChain(jlong target_tag, + std::vector *out_chain, + u8 *out_root_kind) { + FrontierEntry entry{}; + if (!lookup(target_tag, &entry)) { + return false; + } + + std::vector chain; + jlong tag = target_tag; + u8 root_kind = 0; + // Bounded by maxCapacity(): every tag maps to a distinct slot (this table's + // "tags/slots are never reused" invariant, see the class comment above), + // so a well-formed parent_tag chain can visit at most maxCapacity() slots + // before either reaching parent_tag == 0 or repeating a slot. + for (int hops = 0; hops <= maxCapacity() && tag != 0; hops++) { + if (!lookup(tag, &entry)) { + // parent_tag pointed at a tag that was never inserted - should not + // happen for a chain built entirely within one BFS pass, but do not + // fabricate a partial chain silently. + return false; + } + chain.push_back(entry.referrer_klass); + markEdge(tag); + root_kind = entry.root_kind; + tag = entry.parent_tag; + } + if (tag != 0) { + // Ran past the defensive hop bound without reaching a root-attached + // entry (parent_tag == 0) - a corrupted/cyclic chain. Report failure + // rather than returning a truncated, possibly-misleading chain. + return false; + } + + *out_chain = std::move(chain); + if (out_root_kind != nullptr) { + // The loop's last iteration is always the root-attached entry (the one + // whose parent_tag == 0 that just ended the loop), so root_kind here is + // that entry's own FrontierEntry::root_kind. + *out_root_kind = root_kind; + } + return true; +} + +// --------------------------------------------------------------------------- +// ReferenceChainTracker +// --------------------------------------------------------------------------- + +// Marks the calling thread as executing inside the GarbageCollectionStart/ +// Finish JVMTI callback for the duration of the guard's lifetime. Used by the +// tag helpers below as a debug-only self-consistency check that this class +// never issues a Heap-category JVMTI call (SetTag/GetTag/...) from a context +// where the JVMTI spec forbids it (see referenceChains.h). Thread-local +// because the JVMTI spec only guarantees the callback runs on the VM thread +// delivering the event, and this must not leak across threads. +static thread_local bool t_inGCCallback = false; + +namespace { +class GCCallbackGuard { +public: + GCCallbackGuard() { t_inGCCallback = true; } + ~GCCallbackGuard() { t_inGCCallback = false; } +}; +} // namespace + +Error ReferenceChainTracker::start(Arguments &args) { + _enabled = args._reference_chains; + + if (!_enabled) { + Log::info("Reference chain tracking is disabled"); + return Error::OK; + } + + Log::info("Reference chain tracking is enabled (hops=%d, budget=%d, " + "ttl=%ldms, framecap=%d, pausetarget=%ldms, painbudget=%d%%)", + args._reference_chains_hop_cap, args._reference_chains_budget, + args._reference_chains_ttl_ms, args._reference_chains_frontier_cap, + args._reference_chains_pause_target_ms, + args._reference_chains_pain_budget_percent); + + // Like LivenessTracker's table (livenessTracker.cpp:225-232), construct the + // frontier table once and keep it across repeated start()/stop() cycles - + // do not reallocate on a second start() with a possibly different cap, for + // the same reason LivenessTracker keeps its first-initialize() result. + // Recorded unconditionally, even on a start() call that finds _frontier + // already constructed (see _configured_frontier_cap's own comment) - this + // is what resetSearchStateForTest() rebuilds the table at, undoing + // whatever cap an earlier test in this same JVM happened to construct it + // with. + _configured_frontier_cap = args._reference_chains_frontier_cap; + if (_frontier == nullptr) { + _frontier = new FrontierTable(_configured_frontier_cap); + } + + _hop_cap = args._reference_chains_hop_cap; + _budget = args._reference_chains_budget; + // 0 (unset) auto-scales from _budget instead of falling back to it plainly + // - see this field's own comment (referenceChains.h) for why a + // steady-state per-pass budget is the wrong size for the first pass. + _first_pass_budget = args._reference_chains_first_pass_budget > 0 + ? args._reference_chains_first_pass_budget + : std::min(_budget * AUTO_FIRST_PASS_BUDGET_MULTIPLIER, + AUTO_FIRST_PASS_BUDGET_CAP); + _ttl_ms = args._reference_chains_ttl_ms; + + // Pause-time pacing controller: (re)seed the controller's ceiling and the + // adaptive values it drives. _effective_budget/_effective_cadence_ns start + // exactly at their pre-pacing-controller fixed-constant equivalents + // (_budget/PASS_CADENCE_NS) so a tracker that has not yet measured a pass + // behaves identically to before the controller was added - updatePacing() + // only moves them once a real pass duration is + // available. _pause_pid is reconstructed (not just reset()) because its + // target is only known now, from args - same reason RateLimiter::start() + // reconstructs its own _pid rather than mutating it in place. + _pause_target_ms = args._reference_chains_pause_target_ms; + _effective_budget = _budget; + _effective_cadence_ns = PASS_CADENCE_NS; + // Budget-borrowing (referenceChains.h's _borrowed_budget comment): reset + // alongside the rest of the pacing controller's state, so a restarted + // search never inherits headroom earned by a previous one. + _borrowed_budget = 0; + _consecutive_under_target_passes = 0; + _pause_pid = PidController((u64)std::max(_pause_target_ms, 0L), + 10, // proportional gain: reacts to a single + // pass's over/under-ceiling error without + // needing many passes to notice - a + // duration-ms error is typically single/ + // low-double-digit in magnitude (unlike + // the shared triple's event-count scale), + // so a smaller P keeps a one-pass + // overshoot from swinging the budget by + // more than a modest fraction of itself + 1, // integral gain: small and round - + // pidController.cpp's `_integral_value` + // has no built-in clamp, and this + // controller is invoked once per BFS pass + // rather than on the other three usages' + // roughly-periodic one-call-per-second + // cadence, so windup accumulates faster + // per wall-clock second than it does there + 2, // derivative gain: small, matching the + // shared triple's own "the derivational + // gain is rather small" rationale + // (objectSampler.cpp) - a single slow/ + // fast pass should not itself trigger a + // large swing + 1, // sampling_window=1: one compute() call + // *is* one pass, not a fixed real-time + // window like the other three usages + // assume (see _pause_pid's own comment) + 5.0 // cutoff_secs: a round value, halved from + // the shared triple's own "15" since a + // pass-scoped signal is naturally + // noisier per-call than a roughly-1s- + // cadence one + ); + + // Search restart (this class's own header comment): (re)seed _pain_budget + // from the configured refill rate, mirroring _pause_pid's own + // reconstruct-in-start() pattern above. A search's already-accumulated + // _search_pain_ms is deliberately left untouched here - only restartSearch() + // spends it, so a start()/stop() cycle mid-search (if that ever happens) + // does not erase cost the current search has already incurred. + _pain_budget = PainBudget( + std::max(args._reference_chains_pain_budget_percent, 0) / 100.0); + + // Lazy-enable, matching LivenessTracker::start() (livenessTracker.cpp:194-196): + // the GC callbacks are wired unconditionally in vmEntry.cpp, but the events + // themselves are only turned on for this JVMTI env when the flag is on. + jvmtiEnv *jvmti = VM::jvmti(); + jvmti->SetEventNotificationMode( + JVMTI_ENABLE, JVMTI_EVENT_GARBAGE_COLLECTION_START, nullptr); + jvmti->SetEventNotificationMode( + JVMTI_ENABLE, JVMTI_EVENT_GARBAGE_COLLECTION_FINISH, nullptr); + + // Deliberately does NOT create the BFS thread (threadEntry()/threadLoop() + // below) here - threadLoop()'s VM::attachThread() call dereferences + // VM::_vm unconditionally (vmEntry.h:191-195) and crashes if the VM is not + // yet attached, which is exactly the case in this file's own gtest binary + // (referenceChains_ut.cpp calls start() directly with no live JVM). + // startThread() (referenceChains.h) owns spawning the thread instead, and + // is called from Profiler::start() (profiler.cpp) immediately after this + // method returns Error::OK - by that point in the real profiler lifecycle + // the JVM/JVMTI environment is already fully up, so VM::attachThread() is + // safe there. runPass() - the actual BFS engine - does not depend on the + // thread either way and is called directly by this file's own tests. + + return Error::OK; +} + +void ReferenceChainTracker::stop() { + if (!_enabled) { + return; + } + Log::info("Reference chain tracking stopped"); + + // Do not disable GC notifications here - LivenessTracker follows the same + // rule (livenessTracker.cpp:209-210) since the JVMTI env and its tracker + // singletons are expected to survive across multiple start/stop recording + // cycles. The BFS thread itself is stopped separately, by + // Profiler::stop() calling stopThread() (profiler.cpp) - mirroring + // start()'s split between this method and startThread(). +} + +void ReferenceChainTracker::startThread() { + if (!_enabled || _running.load(std::memory_order_acquire)) { + return; + } + // Create the thread (into a local pthread_t) and only publish _thread / + // flip _running=true once pthread_create() has actually succeeded. + // onGCFinish() (GC callback thread) guards its pthread_kill(_thread, ...) + // call on _running alone - GC-finish notifications are already enabled by + // start() before this method runs, so a GC-finish callback firing between + // "_running=true" and pthread_create() actually initializing _thread would + // previously call pthread_kill() on a still value-initialized (0) or + // stale/joined pthread_t, which is undefined behavior. Publishing _thread + // before _running closes that window. + // Reset from any previous stopThread() call - a dynamic-attach profiler + // can go through multiple start()/stop() cycles in one JVM lifetime (this + // class's own start()/stop() header comments), and a stale abort request + // left set from the prior cycle would make heapReferenceCallback() abort + // this new cycle's very first pass instantly. + _abort_pass_requested.store(false, std::memory_order_relaxed); + + // Same reasoning as _abort_pass_requested above, for a different stale-state + // hazard: expandFrontier()'s _cached_object_class[_jni] is keyed on JNIEnv* + // identity to detect a fresh attach, but a new pthread's VM::attachThread() + // (threadLoop(), below) can be handed back a JNIEnv* the JVM already freed + // and is now reusing for this new session - the pointer value alone cannot + // distinguish "still this session" from "coincidentally the same address as + // a prior, already-detached session". A prior session's now-dangling local + // ref would then look "cached and valid" to that identity check and get + // passed straight into NewObjectArray(). stopThread() already joined that + // prior session's thread before this method can run (Profiler::stop()/ + // start() always pair stopThread()+start() sequentially), so it is safe to + // force the cache to re-resolve unconditionally on this new session's first + // expandFrontier() call rather than trust the old JNIEnv* comparison. + _cached_object_class = nullptr; + _cached_object_class_jni = nullptr; + + pthread_t thread; + if (pthread_create(&thread, NULL, threadEntry, this) != 0) { + Log::warn("Unable to create ReferenceChains BFS thread"); + return; + } + _thread = thread; + _running.store(true, std::memory_order_release); +} + +void ReferenceChainTracker::stopThread() { + if (!_running.load(std::memory_order_acquire)) { + return; + } + _running.store(false, std::memory_order_release); + // Ask any in-flight JVMTI FollowReferences walk (heapReferenceCallback()) + // to abort at its next callback invocation - set before pthread_kill() + // below, since that signal alone cannot interrupt a call already inside + // the JVM/JVMTI implementation. + _abort_pass_requested.store(true, std::memory_order_relaxed); + // Same wake-then-join shape as BaseWallClock::stop() (wallClock.cpp:324-333): + // pthread_kill(WAKEUP_SIGNAL) interrupts threadLoop()'s OS::sleep() early + // (WAKEUP_SIGNAL/SIGIO is installed with a no-op handler unconditionally + // in vmEntry.cpp, so this signal never terminates the thread) so it + // re-checks _running and exits promptly rather than waiting out the rest + // of the current sleep interval. + pthread_kill(_thread, WAKEUP_SIGNAL); + int res = pthread_join(_thread, NULL); + if (res != 0) { + Log::warn("Unable to join ReferenceChains BFS thread on stop %d", res); + } +} + +// Not yet started by anything (see start()'s comment above for why) - but +// now implements the real scheduling loop the design doc asks for, matching +// J9WallClock's attach/park/detach lifecycle (j9WallClock.cpp:28-57): each +// wake (adaptive cadence, or earlier via onGCFinish()'s pthread_kill below) +// checks shouldRunPass() and calls runPass() if it says so. The pause-time +// pacing controller sleeps for _effective_cadence_ns rather than the fixed +// PASS_CADENCE_NS, so a +// controller-driven relaxed cadence (updatePacing()) actually shortens how +// long an idle, no-GC-event search waits between passes, not just +// shouldRunPass()'s own comparison. +void ReferenceChainTracker::threadLoop() { + struct Cleanup { + ReferenceChainTracker *tracker; + ~Cleanup() { + // Drop the cached java/lang/Object local ref (and the JNIEnv* it was + // resolved on) before detaching: DetachCurrentThread() invalidates + // every local ref this attach ever created, but _cached_object_class + // and _cached_object_class_jni are tracker-lifetime fields that + // survive into the next start()'s brand-new BFS thread/attach. If the + // JVM happens to hand that next attach the same JNIEnv* address (JNIEnv + // structs are heap-allocated per attach and can be reused once freed), + // the "_cached_object_class_jni != jni" check in expandFrontier() + // would wrongly treat the now-dangling local ref as still valid. + // Clearing both here forces an unconditional FindClass() on the first + // expandFrontier() call of the next attach instead. + tracker->_cached_object_class = nullptr; + tracker->_cached_object_class_jni = nullptr; + VM::detachThread(); + } + } cleanup{this}; + JNIEnv *jni = VM::attachThread("java-profiler ReferenceChains"); + jvmtiEnv *jvmti = VM::jvmti(); + if (jni == nullptr) { + // AttachCurrentThreadAsDaemon() failed - mirror pollWatchedTargets()'s + // own jni==nullptr early return rather than letting a null JNIEnv flow + // into runPass()/resolveLoadedClasses()/expandFrontier()/ + // releaseSearchTags() below: those only guard their DeleteLocalRef() + // calls on `jni != nullptr`, so without this check every + // GetLoadedClasses()/GetObjectsWithTags() local ref returned on this + // (permanently un-attached) thread would leak for the rest of the + // process's lifetime. Nothing this thread does is safe without a live + // JNIEnv, so give up on the whole loop rather than retrying per + // iteration - detachThread() in Cleanup is a safe no-op if attach never + // actually succeeded. + Log::warn("ReferenceChains: VM::attachThread failed; BFS thread exiting"); + return; + } + TEST_LOG("ReferenceChainTracker::threadLoop started, cadence=%lluns", (unsigned long long)_effective_cadence_ns); + + int iteration = 0; + while (_running.load(std::memory_order_acquire)) { + // Fixed ~1s cadence, no early wake on GC (see onGCFinish()'s own + // comment) - stopThread() still interrupts this via its own + // pthread_kill so shutdown stays prompt. + OS::sleep(_effective_cadence_ns); + if (!_running.load(std::memory_order_acquire)) { + break; + } + + // Third trigger for LivenessTracker::cleanup_table() (see + // LivenessTracker::maybeForceCleanup()'s own comment): track()'s + // table-overflow branch and flush_table()'s JFR cadence can both starve + // under ObjectSampler's PID-controlled sampling interval, leaving + // hasLeakSignal() below stuck on a stale population history no matter + // how long a real leak keeps growing. This thread already wakes every + // ~1s with a live JNIEnv, so it doubles as that fallback tick - cheap, + // and a no-op unless 30s have actually elapsed with a GC in between (see + // that method for the exact gate). + u64 wake_now_ns = OS::nanotime(); + LivenessTracker::instance()->maybeForceCleanup(wake_now_ns); + + // No fast-path skip here: shouldRunPass() below already returns false + // cheaply (a couple of atomic loads/comparisons, no JVMTI call) for a + // RUNNING search with no new GC and cadence not yet elapsed. An earlier + // revision additionally gated this on hasLeakSignal() (LivenessTracker's + // population-trend signal, also used by canAffordNewSearch() below to gate + // the first-ever search and every restart), but that signal answers "is + // there a leak candidate right now", which is unrelated to whether an + // already-RUNNING search's own frontier still has pending work - gating a + // RUNNING search's every pass on it would stall that search's own + // convergence for as long as no leak candidate happens to be visible, + // even with GC epochs advancing or cadence elapsed. hasLeakSignal() + // remains the right gate for starting a *new* search, whether that is the + // first one ever or a restart of a *terminal* one (shouldRunPass()'s own + // canAffordNewSearch() call). + u64 now_ns = OS::nanotime(); + bool should_run = shouldRunPass(now_ns); + // Log the loop state only when a pass is actually going to run - the idle + // wakes (should_run == false) are the common steady state and logging them + // every second is pure noise. + if (should_run) { + TEST_LOG("ReferenceChainTracker::threadLoop iteration=%d shouldRunPass=%d searchState=%d " + "passesRun=%d effectiveCadenceNs=%llu effectiveBudget=%d gcFinishEpoch=%llu " + "lastPassGcFinishEpoch=%llu nowMinusLastPassNs=%llu", + ++iteration, should_run, (int)_search_state, _passes_run, + (unsigned long long)_effective_cadence_ns, _effective_budget, + (unsigned long long)gcFinishEpoch(), (unsigned long long)_last_pass_gc_finish_epoch, + (unsigned long long)(now_ns - _last_pass_ns)); + runPass(jvmti, jni, nullptr); + } + // Target-selection bridging step: poll once per scheduling cycle, after + // runPass() - so this poll always sees the most recent pass's tagging (see + // pollWatchedTargets()'s own comment). Unconditional, not gated on + // shouldRunPass()'s decision above: a candidate discovered by an + // earlier pass may still be waiting for its first poll even on a cycle + // where this cycle's own pass was skipped. + pollWatchedTargets(jvmti, jni); + } +} + +void JNICALL ReferenceChainTracker::GarbageCollectionStart(jvmtiEnv *jvmti_env) { + ReferenceChainTracker::instance()->onGCStart(); +} + +void JNICALL ReferenceChainTracker::GarbageCollectionFinish(jvmtiEnv *jvmti_env) { + ReferenceChainTracker::instance()->onGCFinish(); +} + +void ReferenceChainTracker::onGCStart() { + if (!_enabled) { + return; + } + // JVMTI spec: only Memory Management category calls (Allocate/Deallocate) + // are allowed from inside this callback - nothing else may run here. + GCCallbackGuard guard; + atomicIncRelaxed(_gc_start_epoch, (u64)1); +} + +void ReferenceChainTracker::onGCFinish() { + if (!_enabled) { + return; + } + GCCallbackGuard guard; + // Design doc's Triggering section: GC callbacks are only a scheduling + // *signal*, never a pass's execution vehicle (Heap-category JVMTI calls + // are forbidden here - see this file's header comment). Deliberately just + // bookkeeping - no pthread_kill/early wake here. threadLoop() below wakes + // on its own fixed ~1s cadence and reads this epoch then; waking it early + // on every GC gains at most ~1s of latency but, under any GC-heavy + // workload, collapses the loop's cadence to GC frequency instead (each + // early wake is itself a full iteration's worth of shouldRunPass()/ + // pollWatchedTargets() work), which is not worth the latency win. + atomicIncRelaxed(_gc_finish_epoch, (u64)1); +} + +bool ReferenceChainTracker::shouldRunPass(u64 now_ns) { + if (!_search_started) { + // Same gate as a restart (canAffordNewSearch() below) - a brand-new + // tracker must not pay for the first whole-heap walk/tagging pass either + // when there is no leak candidate to justify it. The pain-budget half is + // always a no-op here (nothing has ever been spent yet), so this reduces + // to hasLeakSignal() in practice, but sharing the one gate keeps both + // call sites from drifting apart. + if (!canAffordNewSearch(now_ns)) { + return false; + } + TEST_LOG("ReferenceChainTracker::shouldRunPass -> true (search not started yet)"); + return true; // nothing has run yet - always worth taking the first pass + } + if (_search_state != SearchState::RUNNING) { + // Terminal outcome already reached (runPass()'s Termination section). + if (!_tags_released) { + // releaseSearchTags() failed to confirm every live tag this search + // owned was actually cleared - restartSearch() must never run until + // that is confirmed (see _tags_released's own comment), so return + // true unconditionally here: that drives threadLoop() to call + // runPass() again, whose terminal-state branch retries the release, + // rather than letting canAffordNewSearch()/restartSearch() below run + // ahead of it. + TEST_LOG("ReferenceChainTracker::shouldRunPass -> true (retrying tag " + "release before restart is allowed)"); + return true; + } + // Restart (this class's own header comment) if the pain budget has + // drained and there is still (or again) a leak indication to chase - + // canAffordNewSearch() is always true when LivenessTracker's population + // trends are not in use at all, so this only ever changes behavior for a + // search that already ran once. + if (canAffordNewSearch(now_ns)) { + restartSearch(); + TEST_LOG("ReferenceChainTracker::shouldRunPass -> true (restarting search)"); + return true; + } + // No log here: a terminal search waiting for a restart to become + // warranted is the common idle state, re-evaluated every second, so + // logging it is pure per-second noise (see threadLoop()). + return false; + } + u64 gc_finish_epoch = gcFinishEpoch(); + if (gc_finish_epoch != _last_pass_gc_finish_epoch) { + // Triggering section: "a GC just happened, a pass may be worth running + // soon". + TEST_LOG("ReferenceChainTracker::shouldRunPass -> true (gcFinishEpoch=%llu != " + "lastPassGcFinishEpoch=%llu)", + (unsigned long long)gc_finish_epoch, + (unsigned long long)_last_pass_gc_finish_epoch); + return true; + } + // Pause-time pacing controller: compares against _effective_cadence_ns, not + // the fixed PASS_CADENCE_NS - see that + // field's own comment (referenceChains.h) for how updatePacing() widens or + // relaxes it from the measured pause-time signal. + bool cadence_elapsed = now_ns - _last_pass_ns >= _effective_cadence_ns; + // Only log when the cadence actually elapsed (a pass will run). The + // not-yet-elapsed case is the common idle wake and logging it every second + // is noise. + if (cadence_elapsed) { + TEST_LOG("ReferenceChainTracker::shouldRunPass -> true (now_ns=%llu last_pass_ns=%llu " + "delta=%llu effectiveCadenceNs=%llu)", + (unsigned long long)now_ns, (unsigned long long)_last_pass_ns, + (unsigned long long)(now_ns - _last_pass_ns), + (unsigned long long)_effective_cadence_ns); + } + return cadence_elapsed; +} + +// Search restart gate (this class's own header comment). Deliberately a +// probe (max=1) rather than reusing pollWatchedTargets()'s own +// selectLeakCandidates() call - that one runs after runPass() in +// threadLoop()'s own iteration and needs the *list* to poll each candidate's +// tag; this only needs to know whether at least one exists. +bool ReferenceChainTracker::hasLeakSignal() { + if (!LivenessTracker::instance()->gcGenerationsEnabled()) { + // No population-trend signal to gate on at all - see this method's own + // header comment for why that means "always true" here. + return true; + } + KlassCandidate probe[1]; + return LivenessTracker::instance()->selectLeakCandidates(probe, 1) > 0; +} + +bool ReferenceChainTracker::canAffordNewSearch(u64 now_ns) { + if (!_pain_budget.canStartNow(now_ns)) { + return false; // still cooling down from the last search's own cost + } + return hasLeakSignal(); +} + +// Search restart (this class's own header comment). Called only from +// shouldRunPass() once canAffordNewSearch() has approved it, immediately +// before returning true for this same iteration - runPass() then sees +// _search_started == false and takes the first-pass branch, exactly like a +// brand-new tracker. +void ReferenceChainTracker::restartSearch() { + // Only called once shouldRunPass() has confirmed _tags_released - never + // while a prior search's release might still be pending (see + // _tags_released's own comment): resetting _next_tag to 1 / the frontier + // table below while some object could still hold this search's now- + // ambiguous tag would let the restarted search's fresh tags collide with + // it. + assert(_tags_released && + "restartSearch() must not run before releaseSearchTags() has " + "confirmed every live tag was cleared"); + + // Spend the finishing search's own cost before clearing the accumulator - + // canAffordNewSearch()'s *next* call must see this search's cost, not a + // reset-to-zero balance. + _pain_budget.spend(_search_pain_ms); + _search_pain_ms = 0; + + if (_frontier != nullptr) { + _frontier->resetForRestart(); + } + _next_tag = 1; + // _next_class_tag_magnitude/_class_tags intentionally untouched - see this + // method's own declaration comment (referenceChains.h). + + _search_started = false; + store(_search_state, (u8)SearchState::RUNNING); + store(_abandon_reason, (u8)SearchAbandonReason::NONE); + store(_search_start_ns, (u64)0); + _pending_expand.clear(); + _priority_expand.clear(); + _last_pass_gc_finish_epoch = 0; + store(_last_pass_ns, (u64)0); + store(_passes_run, 0); + // _resolved_chains is intentionally left intact: a chain resolved by the + // finishing search stays cached (and keeps being re-emitted on every dump) + // across the restart, since it describes a sample that is still live. The + // restarted search re-tags that sample under a fresh _search_start_ns, and + // pollWatchedTargets() refreshes the cached entry then (its own comment); + // it prunes the entry if the sample has since been collected. +} + +void ReferenceChainTracker::resetSearchStateForTest(jvmtiEnv *jvmti, + JNIEnv *jni) { + // Every field touched below is otherwise only ever mutated by the BFS + // thread itself (threadLoop()/runPass()/pollWatchedTargets()) - without + // stopping it first, a pass already in flight on that thread can observe + // this reset only partially, or overwrite it right back (e.g. finish a + // pass that was already headed for SearchState::ABANDONED after this + // method has just forced SearchState::RUNNING below), a race found in + // practice, not just in theory. stopThread() (now that it can abort an + // in-flight JVMTI walk promptly - see its own comment) makes this a cheap, + // clean stop/reset/restart rather than an indefinite wait. + stopThread(); + + // Clear every live tag this search still holds before resetting - the + // same ordering restartSearch() itself requires (its own assert), so a + // stale tag from whatever search a previous test left running cannot + // collide with the fresh search's own tags once _next_tag is rewound + // below. + if (jvmti != nullptr && jni != nullptr) { + releaseSearchTags(jvmti, jni); + } + _tags_released = true; + + _pain_budget.spend(_search_pain_ms); + _search_pain_ms = 0; + + if (_frontier != nullptr) { + // Rebuilds the table at this test's own _configured_frontier_cap, + // undoing any smaller framecap= an earlier test left it permanently + // sized at (this class's own header comment on @TestMethodOrder) - + // restartSearch()'s production path only calls the cheaper + // resetForRestart() since it never needs to change the cap mid-JVM. + _frontier->resetCapacityForTest(_configured_frontier_cap); + } + _next_tag = 1; + + _search_started = false; + store(_search_state, (u8)SearchState::RUNNING); + store(_abandon_reason, (u8)SearchAbandonReason::NONE); + store(_search_start_ns, (u64)0); + _pending_expand.clear(); + _priority_expand.clear(); + _last_pass_gc_finish_epoch = 0; + store(_last_pass_ns, (u64)0); + store(_passes_run, 0); + + // Unlike restartSearch(), which deliberately keeps _resolved_chains alive + // across a production restart, a test reset starts from a blank cache so + // one test's resolved chains cannot leak into the next. + _resolved_chains_lock.lock(); + _resolved_chains.clear(); + _resolved_chains_lock.unlock(); + + // Restart the BFS thread against this freshly reset state - startThread() + // itself clears _abort_pass_requested, so the new thread's very first + // pass is not instantly aborted by the flag stopThread() just set above. + startThread(); +} + +long ReferenceChainTracker::pendingExpandPositionForTest(jlong tag) const { + if (tag == 0) { + return -2; + } + // _priority_expand drains first (expandFrontier()'s own comment), so its + // entries are reported as coming before _pending_expand's. + long pos = 0; + for (jlong queued : _priority_expand) { + if (queued == tag) { + return pos; + } + pos++; + } + for (jlong queued : _pending_expand) { + if (queued == tag) { + return pos; + } + pos++; + } + return -1; +} + +size_t ReferenceChainTracker::pendingExpandSizeForTest() const { + return _pending_expand.size() + _priority_expand.size(); +} + +jlong ReferenceChainTracker::tagObject(jvmtiEnv *jvmti, jobject obj) { + assert(!t_inGCCallback && + "SetTag is a JVMTI Heap-category call and must not be made from " + "GarbageCollectionStart/Finish"); + jlong tag = nextTag(); + jvmtiError err = jvmti->SetTag(obj, tag); + if (err != JVMTI_ERROR_NONE) { + return 0; + } + return tag; +} + +jlong ReferenceChainTracker::getTag(jvmtiEnv *jvmti, jobject obj) { + assert(!t_inGCCallback && + "GetTag is a JVMTI Heap-category call and must not be made from " + "GarbageCollectionStart/Finish"); + jlong tag = 0; + jvmtiError err = jvmti->GetTag(obj, &tag); + if (err != JVMTI_ERROR_NONE) { + return 0; + } + return tag; +} + +void ReferenceChainTracker::clearTag(jvmtiEnv *jvmti, jobject obj) { + assert(!t_inGCCallback && + "SetTag is a JVMTI Heap-category call and must not be made from " + "GarbageCollectionStart/Finish"); + jvmti->SetTag(obj, 0); +} + +jlong ReferenceChainTracker::tagAsRootForTest(jvmtiEnv *jvmti, JNIEnv *jni, + jobject obj) { + if (_frontier == nullptr || jvmti == nullptr || jni == nullptr || + obj == nullptr) { + return 0; + } + // Resolves the klass_id the same way LivenessTracker::resolveKlassId() + // does (GetObjectClass + Class.getName() + Profiler::lookupClass()) - + // this is a test-only, off-hot-path call so caching _Class/_Class_getName + // like LivenessTracker does is not worth the extra state. + u32 klass_id = 0; + jclass klass = jni->GetObjectClass(obj); + jclass class_class = jni->FindClass("java/lang/Class"); + if (class_class != nullptr) { + jmethodID get_name = + jni->GetMethodID(class_class, "getName", "()Ljava/lang/String;"); + if (get_name != nullptr) { + jstring name_str = (jstring)jni->CallObjectMethod(klass, get_name); + if (name_str != nullptr) { + const char *name = jni->GetStringUTFChars(name_str, nullptr); + if (name != nullptr) { + int id = Profiler::instance()->lookupClass(name, strlen(name)); + if (id > 0) { + klass_id = (u32)id; + } + jni->ReleaseStringUTFChars(name_str, name); + } + jni->DeleteLocalRef(name_str); + } + } + jni->DeleteLocalRef(class_class); + } + jni->DeleteLocalRef(klass); + + // Tags `obj` and inserts it as a frontier root (parent_tag=0, depth=0), + // exactly the convention runPass()'s heap-root callback path already uses + // (referenceChains.cpp's heapReferenceCallback(), referrer_tag_ptr == + // nullptr branch) - this lets a test drive the real BFS/chain- + // reconstruction logic (runPass()/pollWatchedTargets()/buildChainEvent()) + // against a known, caller-chosen live object, decoupled from whether the + // real root-seeded walk or LivenessTracker's probabilistic sampler happens + // to reach/select it on its own. + jlong tag = tagObject(jvmti, obj); + if (tag == 0) { + return 0; + } + if (!_frontier->insert(tag, 0, klass_id, 0)) { + clearTag(jvmti, obj); + return 0; + } + return tag; +} + +// --------------------------------------------------------------------------- +// Heap-walk engine +// --------------------------------------------------------------------------- + +void ReferenceChainTracker::resolveLoadedClasses(jvmtiEnv *jvmti, + JNIEnv *jni) { + // Profiler::start() resets the class-name StringDictionary + // (_class_map.clearAll(), profiler.cpp) whenever `reset || _start_time == + // 0` - which restarts its id namespace at 1, but does NOT touch any + // class's JVMTI-level class-object tag (JVM-level state, unrelated to our + // dictionary). Detect that reset via the dictionary's own generation + // counter and drop every id this table cached from the now-gone + // generation before the scan below - see _last_class_map_generation's own + // comment (referenceChains.h) for why leaving them in place would keep + // resolving heap references to the wrong (or nonexistent) class name. + u64 current_generation = Profiler::instance()->classMap()->generation(); + bool class_map_reset = current_generation != _last_class_map_generation; + if (class_map_reset) { + _class_tags.clear(); + // Force the scan below to run even if GetLoadedClasses()'s count happens + // to match the last-seen count - -1 can never equal `class_count` + // (always >= 0), unlike 0 which is a legitimate "no classes loaded yet" + // starting value. + _last_resolved_class_count = -1; + _last_class_map_generation = current_generation; + } + + jclass *classes = nullptr; + jint class_count = 0; + if (jvmti->GetLoadedClasses(&class_count, &classes) != JVMTI_ERROR_NONE || + classes == nullptr) { + return; + } + + // Skip the per-class GetTag()/GetClassSignature() scan entirely once the + // loaded-class count has not CHANGED since the last time this ran it: + // every already-tagged class stays tagged forever (tags are never + // cleared once assigned - see _class_tags' own comment), so a resumed + // pass with no newly-loaded classes has nothing left to resolve. Without + // this, every single pass pays a full GetTag() call per loaded class + // (potentially thousands) even though almost all of them are already + // resolved, and that cost is invisible to the pause-time-SLO pacing + // controller (runPass()'s pass_wall_ticks measurement deliberately scopes + // out this call - see that field's own comment). + // + // Deliberately `!=`, not `>`: GetLoadedClasses()'s count is NOT monotonic + // - class unloading (a GC'd custom classloader, JSP/bytecode-macro + // recompilation, etc.) can shrink it. A `>` check would then stay + // permanently skipped once new classes are loaded back up to, but not + // past, a prior historical peak - e.g. 1000 classes loaded then unloaded + // down to 400, then 50 different new classes loaded (total 450, still + // below the 1000 peak) - silently leaving those 50 new classes' tag == 0 + // forever, so any object of theirs discovered by the BFS walk never + // resolves a referrer_klass. `!=` catches both directions; the only + // residual gap is the count-preserving unload-then-reload-same-count case, + // far narrower than the permanent gap `>` left open. + if (class_count != _last_resolved_class_count) { + for (jint i = 0; i < class_count; i++) { + jclass klass = classes[i]; + jlong tag = 0; + // Resolve if not yet tagged (ordinary case: a newly-loaded class), or + // unconditionally on a class-map reset (class_map_reset above) - a + // class already tagged from a prior generation still carries that same + // JVMTI tag (untouched by clearAll()), but the dictionary id it used to + // map to is gone, so its name must be re-resolved into the new + // generation too. + if (jvmti->GetTag(klass, &tag) == JVMTI_ERROR_NONE && + (tag == 0 || class_map_reset)) { + // Resolve its name now, via the same GetClassSignature + + // normalizeClassSignature + Profiler::lookupClass sequence + // ObjectSampler::recordAllocation() already uses + // (objectSampler.cpp:76-90), reused rather than re-derived. + char *class_name = nullptr; + if (jvmti->GetClassSignature(klass, &class_name, nullptr) == + JVMTI_ERROR_NONE && + class_name != nullptr) { + const char *name_slice = nullptr; + size_t name_len = 0; + if (ObjectSampler::normalizeClassSignature(class_name, &name_slice, + &name_len)) { + int id = Profiler::instance()->lookupClass(name_slice, name_len); + if (id != -1) { + // Reuse the existing tag if this class was already tagged by a + // prior generation - only the resolved id needs refreshing, + // not the tag identity heapReferenceCallback() keys off of. + jlong class_tag = tag != 0 ? tag : nextClassTag(); + if (tag != 0 || + jvmti->SetTag(klass, class_tag) == JVMTI_ERROR_NONE) { + _class_tags.insert(class_tag, (u32)id); + } + } + } + jvmti->Deallocate((unsigned char *)class_name); + } + } + // GetLoadedClasses() hands back class_count fresh JNI local refs - + // delete each immediately rather than holding all of them alive at + // once, since class_count can run into the thousands. + if (jni != nullptr) { + jni->DeleteLocalRef(klass); + } + } + _last_resolved_class_count = class_count; + } else if (jni != nullptr) { + // Still owe DeleteLocalRef for every fresh local ref GetLoadedClasses() + // just handed back, even though the scan above was skipped. + for (jint i = 0; i < class_count; i++) { + jni->DeleteLocalRef(classes[i]); + } + } + jvmti->Deallocate((unsigned char *)classes); +} + +namespace { +// Per-runPass() state threaded through heapReferenceCallback() via +// FollowReferences' user_data parameter. Private to this .cpp - the type +// never needs to be visible in referenceChains.h since only runPass() +// constructs one and only heapReferenceCallback() reads it. +struct PassContext { + ReferenceChainTracker *tracker; + FrontierTable *frontier; + int hop_cap; + int budget; + int edges_admitted; + bool truncated; + + // Set only when `truncated` became true because frontier->insert() itself + // reported capacity exhaustion, as opposed to edges_admitted reaching + // budget. runPass() uses this to distinguish "this pass ran out + // of budget, more work remains for a later pass" (search stays RUNNING) + // from "the frontier table itself is full" (design doc's Termination + // section: grounds to ABANDON the whole search, not just this pass). + bool frontier_cap_hit; + + // ARRAY-HOLDER BATCHING: when non-null, expandFrontier() is driving a + // one-hop expansion of a batch of boundary objects passed to a single + // FollowReferences(initial_object=holder_array) call. heapReferenceCallback() + // then descends ONLY into objects whose tag is in this set (the boundary + // objects we deliberately put in the array), and returns "do not descend" + // (0) for everything else - so a freshly-admitted child is tagged but its + // own subtree is left for a later pass, and an already-expanded object from + // a prior pass is never re-traversed. Null on the whole-heap first pass + // (runPass()'s !_search_started branch) and IterateOverReachableObjects + // root enumeration, which keep the unconditional-descend behavior. + std::unordered_set *batch_tags = nullptr; + + // Set only by admitStaticFieldRoots(): the seed holder array for that + // sweep holds loaded-class objects (negative-tagged by + // resolveLoadedClasses(), see the *tag_ptr < 0 branch below), and the + // whole point of the sweep is to walk past that holder->class edge into + // each class's own outgoing references - chiefly STATIC_FIELD - which the + // *tag_ptr < 0 check would otherwise stop cold before FollowReferences + // ever gets to report them. Left false everywhere else (expandFrontier()'s + // batching, root enumeration, the whole-heap first pass), where a + // negative-tagged referee must never be descended into. + bool static_field_seed = false; + + // Amortizes tracker->_pass_deadline_ns's OS::nanotime() check (heapReference + // Callback()/heapRootCallback() run once per visited edge/root - checking + // wall-clock on literally every call would add real overhead on a large + // heap) - checked only every 4096th call, local to this ctx so each of + // runPassManualWalk()'s several sub-calls (root enum, static-field sweep, + // expandFrontier(), rotation) starts its own count. + int deadline_check_counter = 0; + + // True while expandFrontier() is walking a batch drawn from + // _priority_expand (a rotation-selected, already-EXPANDED parent) rather + // than the ordinary _pending_expand backlog - see _priority_expand's own + // comment. Newly admitted children inherit the fast lane so the whole + // re-discovered subtree, not just the immediate child, skips the backlog. + bool admit_priority = false; +}; +} // namespace + +jint JNICALL ReferenceChainTracker::heapReferenceCallback( + jvmtiHeapReferenceKind reference_kind, + const jvmtiHeapReferenceInfo *reference_info, jlong class_tag, + jlong referrer_class_tag, jlong size, jlong *tag_ptr, + jlong *referrer_tag_ptr, jint length, void *user_data) { + PassContext *ctx = (PassContext *)user_data; + + if (ctx->tracker->_abort_pass_requested.load(std::memory_order_relaxed)) { + // stopThread() has set this right before pthread_kill()/pthread_join() - + // see that method's own comment. pthread_kill(WAKEUP_SIGNAL) only + // interrupts threadLoop()'s OS::sleep(); it cannot interrupt an + // in-flight JVMTI FollowReferences call, so without this check + // pthread_join() would block until this pass's walk finishes on its own + // - potentially the whole reachable graph, well past any caller's + // shutdown timeout. Treat it exactly like an ordinary budget exhaustion + // (ctx->truncated = true): this pass ends early and the search stays + // non-terminal - fine, since the tracker is shutting down and simply + // never resumes it. + ctx->truncated = true; + return JVMTI_VISIT_ABORT; + } + + if (ctx->tracker->_pass_deadline_ns != 0 && + (++ctx->deadline_check_counter & 0xFFF) == 0 && + OS::nanotime() >= ctx->tracker->_pass_deadline_ns) { + // This pass has run past its wall-clock share (see _pass_deadline_ns's + // own comment) - treat it exactly like ordinary budget exhaustion so it + // ends early without abandoning the search; a later pass re-enumerates + // whatever roots/edges this one didn't get to. + ctx->truncated = true; + return JVMTI_VISIT_ABORT; + } + + if (*tag_ptr < 0) { + if (ctx->static_field_seed && + reference_kind == JVMTI_HEAP_REFERENCE_ARRAY_ELEMENT && + referrer_tag_ptr != nullptr && *referrer_tag_ptr == 0) { + // admitStaticFieldRoots()'s own holder[i] -> class edge: referrer_tag_ptr + // points at the transient, never-tagged seed array itself (tag 0), not + // at a frontier-admitted parent. Continue the walk into this class's + // own outgoing references - static fields chief among them - instead + // of stopping here; that is the entire purpose of the sweep. The class + // object itself is still never admitted into the frontier (tag_ptr is + // left untouched, so it stays negative). + return JVMTI_VISIT_OBJECTS; + } + // Referee is a class object already tagged negative by + // resolveLoadedClasses() (that pre-pass runs before FollowReferences in + // runPass(), so every loaded class already carries a negative tag by + // this point). Never admit a class object into the frontier as if it + // were an ordinary retained instance, and - outside the + // admitStaticFieldRoots() seed edge handled above - never expand from a + // class's own metadata graph (static fields, superclass, interfaces, + // constant pool, class loader, ...). Out of scope per the design doc's + // non-goals (no field-level/exhaustive paths) and keeps the walk bounded + // to the instance-reachability graph that actually explains "why is + // this object alive". + return 0; + } + if (reference_kind == JVMTI_HEAP_REFERENCE_CLASS || + reference_kind == JVMTI_HEAP_REFERENCE_SYSTEM_CLASS) { + // Definitionally a class by reference_kind (CLASS: "reference from an + // object to its class"; SYSTEM_CLASS: a root reference to a class) even + // if resolveLoadedClasses() failed to resolve/tag this particular one + // (e.g. a transient StringDictionary contention failure) and its tag is + // therefore not yet negative. Same non-goal as above: never expand from + // or admit a class object. + return 0; + } + + if (ctx->truncated) { + // Defensive: FollowReferences should already have stopped delivering + // callbacks after a JVMTI_VISIT_ABORT return below; this just avoids + // doing further work if one more callback arrives anyway. + return JVMTI_VISIT_ABORT; + } + + jlong parent_tag = 0; + u32 depth = 0; + if (referrer_tag_ptr != nullptr) { + jlong rtag = *referrer_tag_ptr; + if (rtag > 0) { + FrontierEntry parent{}; + if (ctx->frontier->lookup(rtag, &parent)) { + parent_tag = rtag; + depth = parent.depth + 1; + } + // lookup() failing for a positive rtag should not happen - a referrer + // must already be one of our tagged frontier objects for its own + // outgoing edges to be traversed at all (FollowReferences only + // explores past an object this callback returned JVMTI_VISIT_OBJECTS + // for) - but fall back to root-like (parent_tag=0/depth=0) rather + // than corrupt the chain if it ever does. + } + // rtag < 0: referrer is a pre-tagged class object (e.g. a static field + // holding this reference) - treated as root-like rather than attributed + // to a parent hop, since class objects are never admitted as frontier + // entries and so have no depth/parent_tag of their own (see the + // *tag_ptr < 0 check above). rtag == 0: referrer not yet tagged, should + // not happen for the same reason noted above. + } + // referrer_tag_ptr == nullptr: a heap-root reference (JNI global, thread + // stack local/JNI local, monitor, thread, system class, ...) - parent_tag + // and depth stay 0. + + if (depth >= (u32)ctx->hop_cap) { + // Hop cap: do not admit this object into the frontier, and do not + // expand further from it - enforced here rather than + // discovering-then-discarding, per the plan. + return 0; + } + + if (*tag_ptr == 0) { + // First time this object is visited in this pass. + u32 referrer_klass = ctx->tracker->classTags()->resolve(class_tag); + // reference_kind describes this admitting edge; only meaningful for a + // root-attached entry (parent_tag == 0) - see FrontierEntry::root_kind's + // own comment for why a non-root entry's edge kind is not recorded. + u8 root_kind = parent_tag == 0 ? (u8)reference_kind : 0; + ReferenceChainTracker::AdmitResult result = ctx->tracker->admitObject( + ctx->frontier, ctx->hop_cap, ctx->budget, &ctx->edges_admitted, + tag_ptr, parent_tag, referrer_klass, depth, root_kind, + ctx->admit_priority); + switch (result) { + case ReferenceChainTracker::AdmitResult::BUDGET_EXHAUSTED: + ctx->truncated = true; + return JVMTI_VISIT_ABORT; + case ReferenceChainTracker::AdmitResult::FRONTIER_CAP_HIT: + // Frontier-size cap hit (FrontierTable::insert() returned false + // without partially writing) - stop admitting new entries and report + // the truncation (design doc: "stop admitting new entries ... report + // it"), rather than silently dropping this object and continuing. + // Distinct from ordinary budget exhaustion above - runPass() abandons + // the whole search for this, not just this pass. + ctx->truncated = true; + ctx->frontier_cap_hit = true; + return JVMTI_VISIT_ABORT; + default: + // ADMITTED, or HOP_CAP/ALREADY_ADMITTED (neither reachable here: the + // hop-cap check above already returned before this branch, and + // *tag_ptr == 0 rules out ALREADY_ADMITTED) - nothing further to do. + break; + } + } + + if (ctx->batch_tags != nullptr) { + // ARRAY-HOLDER BATCHING one-hop descent control (see PassContext:: + // batch_tags). Descend only into this pass's boundary objects so the + // single FollowReferences(holder_array) call expands exactly one hop: + // a boundary object yields its direct children (which get tagged above), + // but those children are not themselves descended into, and any + // already-expanded object from a prior pass is skipped rather than + // re-traversed. + jlong my_tag = *tag_ptr; + if (my_tag > 0 && ctx->batch_tags->count(my_tag) != 0) { + return JVMTI_VISIT_OBJECTS; + } + return 0; + } + + return JVMTI_VISIT_OBJECTS; +} + +ReferenceChainTracker::AdmitResult ReferenceChainTracker::admitObject( + FrontierTable *frontier, int hop_cap, int budget, int *edges_admitted, + jlong *tag_ptr, jlong parent_tag, u32 referrer_klass, u32 depth, + u8 root_kind, bool priority) { + if (*tag_ptr != 0) { + return AdmitResult::ALREADY_ADMITTED; + } + if (depth >= (u32)hop_cap) { + return AdmitResult::HOP_CAP; + } + if (*edges_admitted >= budget) { + return AdmitResult::BUDGET_EXHAUSTED; + } + jlong tag = nextTag(); + if (!frontier->insert(tag, parent_tag, referrer_klass, depth, + FrontierEntryState::FRONTIER, root_kind)) { + return AdmitResult::FRONTIER_CAP_HIT; + } + *tag_ptr = tag; + (*edges_admitted)++; + // Queue for expandFrontier()/markAllFrontierExpanded() - see + // _pending_expand's/_priority_expand's own declaration comments for why + // this replaces a scan over the admitted range, and for why a + // rotation-discovered child (priority=true) skips the ordinary backlog. + if (priority) { + _priority_expand.push_back(tag); + } else { + _pending_expand.push_back(tag); + } + return AdmitResult::ADMITTED; +} + +bool ReferenceChainTracker::maybeUpgradeRootAttachedRootKind( + FrontierTable *frontier, jlong tag, u8 new_root_kind) { + FrontierEntry entry{}; + if (!frontier->lookup(tag, &entry)) { + return false; + } + if (entry.parent_tag != 0) { + // Not root-attached - per this phase's option (a) resolution of the + // parent_tag==0/root_kind invariant conflict (referenceChains.h's + // FrontierEntry::root_kind comment), only a root-context update may ever + // write a non-zero root_kind, and only onto an entry that is already + // root-attached. An object that happens to also be a genuine GC root but + // was first discovered as a non-root child (e.g. via frontier + // expansion) keeps its original, non-root attribution - a known, + // documented limitation rather than an attempt to retroactively flip + // parent_tag to 0, which reconstructChain()'s parent-link walk does not + // support. + return false; + } + if (rootKindDurability(new_root_kind) <= rootKindDurability(entry.root_kind)) { + return false; + } + frontier->updateRootKind(tag, new_root_kind); + return true; +} + +std::vector +ReferenceChainTracker::collectStaleRootKindEntriesForRotation( + int max_count) { + std::vector selected; + int table_size = _frontier->size(); + if (max_count <= 0 || table_size <= 0) { + return selected; + } + if (_root_kind_rotation_cursor <= 0 || + _root_kind_rotation_cursor > table_size) { + _root_kind_rotation_cursor = 1; + } + + // Held for the whole sweep below (potentially wrapping all the way around + // table_size) rather than once per tag via lookup() - the same rationale + // as collectStaleExpandedEntriesForRotation()'s own lockShared() use: a + // per-tag SpinLock acquisition would double this scan's cost under a large + // frontier table. + jlong start_tag = _root_kind_rotation_cursor; + jlong tag = start_tag; + _frontier->withSharedLock([&](const FrontierTable *frontier) { + do { + FrontierEntry entry{}; + if (frontier->lookupLocked(tag, &entry) && + entry.state == FrontierEntryState::EXPANDED && + entry.parent_tag == 0 && isTransientRootKind(entry.root_kind) && + !isQueuedForRotation(tag)) { + selected.push_back(tag); + _priority_expand.push_back(tag); + if ((int)selected.size() >= max_count) { + tag = tag % table_size + 1; + break; + } + } + tag = tag % table_size + 1; + } while (tag != start_tag); + }); + + _root_kind_rotation_cursor = tag; + return selected; +} + +std::vector +ReferenceChainTracker::collectStaleExpandedEntriesForRotation( + int max_count) { + std::vector selected; + int table_size = _frontier->size(); + if (max_count <= 0 || table_size <= 0) { + return selected; + } + // Always sweep from the lowest tag, instead of resuming from where the + // last call left off: low tags are the earliest-admitted entries, which + // tend to be long-lived infrastructure objects (caches, maps) closest to + // a GC root, while a round-robin cursor gives every entry equal turn and + // takes O(table_size / max_count) passes to cycle back to any one of + // them - far too slow once the table holds tens of thousands of entries. + // + // This scan's own EXPANDED criterion is a strict superset of + // collectStaleRootKindEntriesForRotation()'s (which additionally requires + // parent_tag == 0 and a transient root_kind), and that function always + // runs first within the same pass and pushes its picks onto + // _priority_expand before this one runs - so without a check here, a tag + // it already selected would be pushed a second time, and + // expandFrontier() re-expands each deque entry as its own independent + // unit of work. isQueuedForRotation() also covers any entries still + // sitting there from a prior pass's truncated batch (expandFrontier() + // leaves those at the front of the queue for a later retry rather than + // popping them). + // Held for the whole scan below instead of once per tag via lookup() - a + // per-tag SpinLock acquisition/release would double the cost of this + // O(table_size) sweep under a large frontier table (the exact scenario - + // tens of thousands of entries - this rotation mechanism targets). + jlong tag = 1; + _frontier->withSharedLock([&](const FrontierTable *frontier) { + while (tag <= table_size && (int)selected.size() < max_count) { + FrontierEntry entry{}; + if (frontier->lookupLocked(tag, &entry) && + entry.state == FrontierEntryState::EXPANDED && + !isQueuedForRotation(tag)) { + selected.push_back(tag); + _priority_expand.push_back(tag); + } + tag++; + } + }); + return selected; +} + +// --------------------------------------------------------------------------- +// Manual walk driver - IterateOverReachableObjects root/stack-ref enumeration +// plus expandFrontier()'s batched array-holder FollowReferences hop expansion. +// The only path driven by runPass() below. +// --------------------------------------------------------------------------- + +namespace { +// jvmtiHeapRootKind (IterateOverReachableObjects's root/stack-ref callbacks, +// ordinals 1-7) and jvmtiHeapReferenceKind (FrontierEntry::root_kind's own +// type, FollowReferences' callback, ordinals 8/21-27) are different, disjoint +// enums per the real jvmti.h - storing a raw jvmtiHeapRootKind value into +// root_kind unmodified would make flightRecorder.cpp's rootKindName() report +// "unknown" for every root-callback-attributed chain. Every jvmtiHeapRootKind +// value maps onto its jvmtiHeapReferenceKind namesake; there is no root-kind +// equivalent of STATIC_FIELD (that value only ever arises from +// heapReferenceCallback()'s own referrer-is-a-tagged-class case), so it is +// never produced here. +u8 translateHeapRootKind(jvmtiHeapRootKind root_kind) { + switch (root_kind) { + case JVMTI_HEAP_ROOT_JNI_GLOBAL: + return (u8)JVMTI_HEAP_REFERENCE_JNI_GLOBAL; + case JVMTI_HEAP_ROOT_SYSTEM_CLASS: + return (u8)JVMTI_HEAP_REFERENCE_SYSTEM_CLASS; + case JVMTI_HEAP_ROOT_MONITOR: + return (u8)JVMTI_HEAP_REFERENCE_MONITOR; + case JVMTI_HEAP_ROOT_STACK_LOCAL: + return (u8)JVMTI_HEAP_REFERENCE_STACK_LOCAL; + case JVMTI_HEAP_ROOT_JNI_LOCAL: + return (u8)JVMTI_HEAP_REFERENCE_JNI_LOCAL; + case JVMTI_HEAP_ROOT_THREAD: + return (u8)JVMTI_HEAP_REFERENCE_THREAD; + case JVMTI_HEAP_ROOT_OTHER: + default: + return (u8)JVMTI_HEAP_REFERENCE_OTHER; + } +} + +} // namespace + +jvmtiIterationControl JNICALL ReferenceChainTracker::heapRootCallback( + jvmtiHeapRootKind root_kind, jlong class_tag, jlong size, jlong *tag_ptr, + void *user_data) { + PassContext *ctx = (PassContext *)user_data; + if (ctx->tracker->_abort_pass_requested.load(std::memory_order_relaxed)) { + ctx->truncated = true; + return JVMTI_ITERATION_ABORT; + } + if (ctx->truncated) { + return JVMTI_ITERATION_ABORT; + } + + u32 referrer_klass = ctx->tracker->classTags()->resolve(class_tag); + u8 translated_root_kind = translateHeapRootKind(root_kind); + AdmitResult result = ctx->tracker->admitObject( + ctx->frontier, ctx->hop_cap, ctx->budget, &ctx->edges_admitted, tag_ptr, + /*parent_tag=*/0, referrer_klass, /*depth=*/0, translated_root_kind); + switch (result) { + case AdmitResult::BUDGET_EXHAUSTED: + ctx->truncated = true; + return JVMTI_ITERATION_ABORT; + case AdmitResult::FRONTIER_CAP_HIT: + ctx->truncated = true; + ctx->frontier_cap_hit = true; + return JVMTI_ITERATION_ABORT; + case AdmitResult::ALREADY_ADMITTED: + // Rediscovery via a second heap root - either later in this same pass's + // root enumeration, or in a later pass re-enumerating roots entirely + // (design doc's durability tie-break / "opportunistic upgrade", "Fix for + // root-attribution staleness" point 1 and Phase 5 item 1): apply the + // same durability ranking admitObject() would have used on first + // discovery, upgrading root_kind if this root is more durable than + // whatever is currently recorded. Restricted to root-attached entries + // only (parent_tag == 0) - see maybeUpgradeRootAttachedRootKind()'s own + // comment for why. + ctx->tracker->maybeUpgradeRootAttachedRootKind(ctx->frontier, *tag_ptr, + translated_root_kind); + break; + default: + break; + } + return JVMTI_ITERATION_CONTINUE; +} + +jvmtiIterationControl JNICALL ReferenceChainTracker::stackRefCallback( + jvmtiHeapRootKind root_kind, jlong class_tag, jlong size, jlong *tag_ptr, + jlong thread_tag, jint depth, jmethodID method, jint slot, + void *user_data) { + // Stack-local/JNI-local roots carry thread/frame/slot detail JVMTI reports + // via this callback's richer shape, but FrontierEntry has nowhere to + // record it (depth/method/slot are not part of the record) - admission is + // otherwise identical to heapRootCallback() above, so this just forwards. + return heapRootCallback(root_kind, class_tag, size, tag_ptr, user_data); +} + +void ReferenceChainTracker::runPassManualWalk(jvmtiEnv *jvmti, JNIEnv *jni, + bool run_root_enum, + int root_enum_budget, + int expand_budget, + int *edges_admitted, + bool *truncated, + bool *frontier_cap_hit) { + assert(!t_inGCCallback && + "IterateOverReachableObjects/FollowReferences are JVMTI " + "Heap-category calls and must not be made from " + "GarbageCollectionStart/Finish"); + + // Shared wall-clock ceiling for this whole call's static-field sweep, + // expandFrontier(), and rotation sub-calls below (see _pass_deadline_ns's + // own comment) - deliberately NOT applied to root/stack-ref enumeration + // itself, which is instead cadence-gated by run_root_enum/ + // ROOT_ENUM_MIN_INTERVAL_NS. + _pass_deadline_ns = _pause_target_ms > 0 + ? OS::nanotime() + (u64)_pause_target_ms * 1000000ULL + : 0; + + *edges_admitted = 0; + *truncated = false; + *frontier_cap_hit = false; + + // Reserve a slice for rotation up front (see ROTATION_RESERVED_BUDGET's + // own comment) so it still gets to run this pass even when ordinary work + // below spends everything else and truncates. Also capped at half of + // expand_budget: without that cap, a pacing-throttled pass (expand_budget + // down near MIN_EFFECTIVE_BUDGET) would hand rotation its full fixed + // reservation and leave ordinary expansion with 0 - exactly the priority + // inversion this reservation exists to avoid, just for the other side. + // Capping at half means each side degrades proportionally as pacing + // throttles down, instead of either one hitting a hard 0. + int rotation_reserved_budget = + std::min(expand_budget / 2, ROTATION_RESERVED_BUDGET); + int budget = expand_budget - rotation_reserved_budget; + + // Root/stack-ref enumeration alone (unlike a root-seeded FollowReferences + // call on the fallback path) never discovers a root's own transitive + // children - IterateOverReachableObjects's root/stack-ref callbacks are + // given no oop, only a tag_ptr (see heapRootCallback()'s own comment) - so + // even when it runs this pass, the expandFrontier() call below is still + // needed to make any further progress. Gated behind run_root_enum (see + // ROOT_ENUM_MIN_INTERVAL_NS's own comment) since the call's fixed + // root-walk-and-dispatch cost is paid in full every time it runs, + // regardless of budget. + if (run_root_enum) { + PassContext ctx; + ctx.tracker = this; + ctx.frontier = _frontier; + ctx.hop_cap = _hop_cap; + ctx.budget = root_enum_budget; + ctx.edges_admitted = 0; + ctx.truncated = false; + ctx.frontier_cap_hit = false; + + jvmtiError root_err = jvmti->IterateOverReachableObjects( + heapRootCallback, stackRefCallback, /*object_ref_callback=*/nullptr, + &ctx); + + // expand_budget is spent independently of root_enum_budget below (see + // ROOT_ENUM_MIN_INTERVAL_NS's own comment) - ctx.edges_admitted is + // written straight into *edges_admitted so the static-field/expand/ + // rotation budget math below is never shrunk by whatever root + // enumeration admitted. + *edges_admitted = ctx.edges_admitted; + _last_root_enum_ns = OS::nanotime(); + + if (root_err != JVMTI_ERROR_NONE) { + *truncated = true; + *frontier_cap_hit = false; + _root_enum_truncated_last_time = false; + return; + } + if (ctx.truncated) { + *truncated = true; + *frontier_cap_hit = ctx.frontier_cap_hit; + // Only a budget-exhausted truncation (not a frontier-cap-hit, which + // abandons the search outright) is grounds to retry root enumeration + // on the very next pass - see _root_enum_truncated_last_time's own + // comment. + _root_enum_truncated_last_time = !ctx.frontier_cap_hit; + return; + } + _root_enum_truncated_last_time = false; + } + + // Static-field roots (SomeClass.staticField -> obj) are not reachable via + // IterateOverReachableObjects' root/stack-ref callbacks above - see + // admitStaticFieldRoots()'s own comment - so this pass would otherwise + // never discover an object retained only that way. Best-effort: failures + // here do not truncate the pass, they just mean this sweep found nothing + // new this time around. + // + // Only run the sweep when the loaded-class set has actually changed since + // the last time it completed (same guard shape resolveLoadedClasses() uses + // for its own GetLoadedClasses()-driven scan, and reusing the count that + // call already refreshed via resolveLoadedClasses() earlier this same + // runPass() - see _last_static_field_class_count's own comment). Without + // this, admitStaticFieldRoots() would re-run its own GetLoadedClasses() + // call and a FollowReferences over every loaded class - a stop-the-world + // HeapWalkOperation - on every pass, forever, at the per-second pass + // cadence, even once every loaded class's static fields have already been + // swept and no new class has appeared to introduce new ones. + int expand_phase_edges_admitted = 0; + if (_last_resolved_class_count != _last_static_field_class_count) { + int static_field_edges_admitted = 0; + bool static_field_truncated = false; + bool static_field_frontier_cap_hit = false; + int static_field_budget = std::max(budget - expand_phase_edges_admitted, 0); + admitStaticFieldRoots(jvmti, jni, _hop_cap, static_field_budget, + &static_field_edges_admitted, &static_field_truncated, + &static_field_frontier_cap_hit); + expand_phase_edges_admitted += static_field_edges_admitted; + *edges_admitted += static_field_edges_admitted; + if (static_field_truncated) { + *truncated = true; + *frontier_cap_hit = static_field_frontier_cap_hit; + if (static_field_frontier_cap_hit) { + // Frontier-size cap hit while admitting static-field roots is the + // same "grounds to ABANDON the whole search" outcome + // BUDGET_EXHAUSTED/FRONTIER_CAP_HIT handling above gives root + // enumeration - do not spend any more of this pass's budget on the + // ordinary expansion below. + return; + } + } else { + // Sweep completed (possibly discovering nothing, if every static field + // it saw was already ALREADY_ADMITTED) - remember the class count it + // covered so a later pass with no new classes can skip re-running it. + // Left unset on a truncated sweep (above) so the next pass retries + // instead of wrongly treating a still-incomplete sweep as done. + _last_static_field_class_count = _last_resolved_class_count; + } + } + + int expand_edges_admitted = 0; + bool expand_truncated = false; + bool expand_frontier_cap_hit = false; + int remaining_budget = std::max(budget - expand_phase_edges_admitted, 0); + expandFrontier(jvmti, jni, _hop_cap, remaining_budget, + &expand_edges_admitted, &expand_truncated, + &expand_frontier_cap_hit); + expand_phase_edges_admitted += expand_edges_admitted; + *edges_admitted += expand_edges_admitted; + *truncated = *truncated || expand_truncated; + *frontier_cap_hit = expand_frontier_cap_hit; + + // Note: unlike a hard truncation during root/stack-ref enumeration or the + // static-field sweep above (which return early - the pass never even + // reached ordinary expansion), a truncated ordinary expansion does NOT + // skip rotation below: rotation runs on its own reserved slice of budget + // (see ROTATION_RESERVED_BUDGET's own comment) precisely because ordinary + // expansion truncates on nearly every pass under a sustained fast-growing + // backlog, and that is exactly the situation - a mutable field reassigned + // out from under an already-EXPANDED entry - rotation exists to correct. + + // Bounded rotating re-expansion (design doc's closing section, Phase 5 + // item 3): re-walk a bounded, rotating subset of already-EXPANDED, + // transiently-root-attributed entries so a durable root discovered + // elsewhere on a later pass (via maybeUpgradeRootAttachedRootKind() above) + // gets a chance to be observed even for an entry whose own fields were + // already fully expanded once. Runs after the ordinary expansion above so + // it only ever spends whatever budget that left unused, plus its own + // reserved slice. + std::vector rotation_tags = + collectStaleRootKindEntriesForRotation(ROOT_KIND_ROTATION_BUDGET); + // Also re-walk a bounded, rotating subset of EXPANDED entries regardless + // of root attribution: a mutable field reassigned since an + // object's one-time expansion - e.g. HashMap.table on resize - is + // otherwise never observed again, silently orphaning everything only + // reachable through the field's current value. See + // collectStaleExpandedEntriesForRotation()'s own comment. + std::vector stale_expanded_tags = + collectStaleExpandedEntriesForRotation(STALE_EXPANDED_ROTATION_BUDGET); + if (rotation_tags.empty() && stale_expanded_tags.empty()) { + return; + } + // rotation_reserved_budget + max(budget - expand_phase_edges_admitted, 0) is + // exactly expand_budget - expand_phase_edges_admitted: budget already IS + // expand_budget - rotation_reserved_budget (above), and expand_phase_edges_ + // admitted can never exceed budget (the static-field sweep and ordinary + // expandFrontier() calls above are both capped to budget-derived slices), + // so the max() is never actually needed to avoid going negative. Folding + // rotation_reserved_budget back into expand_budget here - rather than + // subtracting it out and then adding it back - says directly what this + // value is: whatever of the whole pass's budget the phases above didn't + // spend. + int rotation_budget = expand_budget - expand_phase_edges_admitted; + int rotation_edges_admitted = 0; + bool rotation_truncated = false; + bool rotation_frontier_cap_hit = false; + expandFrontier(jvmti, jni, _hop_cap, rotation_budget, + &rotation_edges_admitted, &rotation_truncated, + &rotation_frontier_cap_hit); + *edges_admitted += rotation_edges_admitted; + // OR, not overwrite: the ordinary expand phase above may have already set + // these to true (real truncation/cap-hit left in _pending_expand), and a + // rotation batch that happens to finish cleanly must not erase that - + // has_pending_frontier (runPass()) and the FRONTIER_CAP abandon check both + // read these as "did any of this pass's sub-phases truncate/cap-hit", not + // just the last one that ran. + *truncated = *truncated || rotation_truncated; + *frontier_cap_hit = *frontier_cap_hit || rotation_frontier_cap_hit; +} + +// --------------------------------------------------------------------------- +// Incremental resumption across passes. +// --------------------------------------------------------------------------- + +void ReferenceChainTracker::markAllFrontierExpanded() { + while (!_priority_expand.empty()) { + _frontier->markExpanded(_priority_expand.front()); + _priority_expand.pop_front(); + } + while (!_pending_expand.empty()) { + _frontier->markExpanded(_pending_expand.front()); + _pending_expand.pop_front(); + } +} + +void ReferenceChainTracker::expandFrontier(jvmtiEnv *jvmti, JNIEnv *jni, + int hop_cap, int budget, + int *edges_admitted, + bool *truncated, + bool *frontier_cap_hit) { + assert(!t_inGCCallback && + "GetObjectsWithTags/FollowReferences are JVMTI Heap-category calls " + "and must not be made from GarbageCollectionStart/Finish"); + + PassContext ctx; + ctx.tracker = this; + ctx.frontier = _frontier; + ctx.hop_cap = hop_cap; + ctx.budget = budget; + ctx.edges_admitted = 0; + ctx.truncated = false; + ctx.frontier_cap_hit = false; + + // ARRAY-HOLDER BATCHING: expand a whole batch of boundary objects with ONE + // FollowReferences(initial_object=holder_array) call per BFS level, instead + // of one FollowReferences PER frontier entry. batch_tags gates + // heapReferenceCallback() to a single hop (see its own comment). This is + // the unconditional default expansion path (runPass()'s only non-fallback + // walk), not a prototype relative to anything else still in the codebase. + std::unordered_set batch_tags; + ctx.batch_tags = &batch_tags; + + jvmtiHeapCallbacks callbacks; + memset(&callbacks, 0, sizeof(callbacks)); + callbacks.heap_reference_callback = heapReferenceCallback; + + // java/lang/Object element type for the transient frontier-holder array. + // Cached across calls on this same (attached) JNIEnv rather than re-resolved + // via a fresh FindClass() every call - expandFrontier() runs roughly once + // per BFS-thread wake for the tracker's lifetime, and the class never + // changes, so a per-pass class-loader lookup is unnecessary churn. Without + // a JNIEnv (some test seams) the array-holder path cannot run; a JNIEnv + // change (fresh attach) invalidates the cache since the previous call's + // local ref is only guaranteed valid for that attach's lifetime. + if (jni != nullptr) { + if (_cached_object_class_jni != jni) { + _cached_object_class = jni->FindClass("java/lang/Object"); + if (jniExceptionCheck(jni)) { + _cached_object_class = nullptr; + } + _cached_object_class_jni = jni; + } + } else { + _cached_object_class = nullptr; + _cached_object_class_jni = nullptr; + } + jclass object_class = _cached_object_class; + + bool progress = true; + while (!ctx.truncated && progress && object_class != nullptr) { + progress = false; + + // Drain _priority_expand ahead of the ordinary backlog (see its own + // declaration comment) - a rotation-selected parent's re-discovered + // children must not queue behind however much of _pending_expand is + // still outstanding, or the re-discovery never visibly progresses. + bool from_priority = !_priority_expand.empty(); + std::deque &source = + from_priority ? _priority_expand : _pending_expand; + ctx.admit_priority = from_priority; + if (source.empty()) { + break; // nothing pending + } + + // Same batch-sizing rationale as before: at most `budget` pending tags + // per level so GetObjectsWithTags()'s cost stays proportional to what we + // will actually expand this iteration, not to the whole backlog. + // Additionally capped at the configured steady per-pass budget + // (_budget), independent of how large `budget` itself is: the first + // pass's caller-supplied budget is _first_pass_budget, which can be + // (and by design, for a whole-JVM root enumeration, routinely is) far + // larger than the actual pending backlog - e.g. 200000 vs. 81672 roots + // enumerated. Without this second cap, batch_size collapses to the + // entire backlog in one shot, building one huge holder array and + // spending this pass's whole expansion budget on a single + // FollowReferences call that can fail outright (JNI local-capacity/OOM) + // with zero progress, stalling every root at the front of + // _pending_expand for however many later, budget-capped passes it takes + // to drain that same backlog before any real expansion happens. Capping + // at _budget keeps every batch - first pass or not - the same size this + // array-holder mechanism is already proven to handle on every other + // pass. + size_t batch_size = std::min( + source.size(), + (size_t)std::max(std::min(budget, _budget), 1)); + std::vector candidate_tags(source.begin(), + source.begin() + batch_size); + + // Resolve this batch's live boundary objects. GetObjectsWithTags iterates + // the whole tag map, but does so under a no-safepoint mutex on this + // (Java) thread - it is NOT a stop-the-world VM operation, unlike the + // FollowReferences below (jvmtiTagMap.cpp: get_objects_with_tags takes + // Mutex::_no_safepoint_check_flag and calls entry_iterate directly, + // whereas follow_references does VMThread::execute()). + jint resolved_count = 0; + jobject *resolved_objects = nullptr; + jlong *resolved_tags = nullptr; + jvmtiError resolve_err = jvmti->GetObjectsWithTags( + (jint)candidate_tags.size(), candidate_tags.data(), &resolved_count, + &resolved_objects, &resolved_tags); + if (resolve_err != JVMTI_ERROR_NONE) { + ctx.truncated = true; + break; + } + + std::unordered_map live; + for (jint i = 0; i < resolved_count; i++) { + live[resolved_tags[i]] = resolved_objects[i]; + } + + // Build the frontier-holder array from the live boundary objects and + // record their tags so heapReferenceCallback() descends into exactly + // these (one hop). + batch_tags.clear(); + jobjectArray holder = nullptr; + if (resolved_count > 0) { + jint capacity_err = jni->EnsureLocalCapacity(resolved_count + 16); + if (capacity_err < 0 || jniExceptionCheck(jni)) { + // Could not guarantee local-ref headroom for this batch - treat like + // any other batch-level failure below (JVMTI error / OOM building the + // holder array): retry this batch on a later pass rather than + // proceeding into NewObjectArray with no capacity guarantee. + ctx.truncated = true; + } else { + holder = jni->NewObjectArray(resolved_count, object_class, nullptr); + if (jniExceptionCheck(jni)) { + // OutOfMemoryError building the holder array (or any other + // exception NewObjectArray raised) left `holder` null; make sure + // the pending exception does not survive into the next JNI call + // below or the next expandFrontier() invocation on this same + // long-lived BFS-thread JNIEnv (JNI spec: undefined behavior with + // a pending exception across ordinary JNI calls). + holder = nullptr; + } + if (holder != nullptr) { + for (jint i = 0; i < resolved_count; i++) { + jni->SetObjectArrayElement(holder, i, resolved_objects[i]); + if (jniExceptionCheck(jni)) { + // e.g. an array-store-class failure. Abort building this + // batch's holder rather than handing a partially-populated + // array (with a just-cleared pending exception) to + // FollowReferences. + ctx.truncated = true; + break; + } + batch_tags.insert(resolved_tags[i]); + } + } + if (holder == nullptr) { + // NewObjectArray failed (OOM/local-ref exhaustion) - the + // FollowReferences call below (which would have discovered this + // batch's children) never runs. Falling through to the + // mark-EXPANDED-and-dequeue path further down would silently and + // permanently drop these still-undiscovered children, so this must + // be treated exactly like a failed FollowReferences/JVMTI call: + // retry the batch on a later pass instead. + ctx.truncated = true; + } else if (!ctx.truncated) { + // A single FollowReferences over the holder array expands this whole + // BFS level in one stop-the-world HeapWalkOperation (instead of one + // per frontier entry). initial_object=holder means the traversal + // starts from the array only (never enumerates roots / the whole + // heap); heapReferenceCallback() returns "descend" for the array's + // elements (the boundary objects, in batch_tags) and "no descend" for + // their children, so exactly one hop past the boundary is explored. + jvmtiError follow_err = + jvmti->FollowReferences(0, nullptr, holder, &callbacks, &ctx); + if (follow_err != JVMTI_ERROR_NONE) { + ctx.truncated = true; + } + } + } + } + + if (!ctx.truncated) { + // The whole batch had all its direct children admitted this level: + // dead entries are pruned, live ones are marked EXPANDED, and all are + // popped off the front. New children were appended to the back by + // admitObject() and become the next level's batch. + for (jlong tag : candidate_tags) { + if (live.find(tag) == live.end()) { + _frontier->clear(tag); + } else { + _frontier->markExpanded(tag); + } + source.pop_front(); + } + progress = true; + } + // else truncated (budget/frontier-cap/JVMTI error): leave the batch at + // the front of the source queue for a later pass to retry. Re-walking is + // idempotent - already-admitted children are ALREADY_ADMITTED (not + // re-counted, not descended), so a retry only admits the remaining + // children. The while condition (!ctx.truncated) ends the loop here. + + if (holder != nullptr) { + jni->DeleteLocalRef(holder); + } + if (jni != nullptr) { + for (jint i = 0; i < resolved_count; i++) { + jni->DeleteLocalRef(resolved_objects[i]); + } + } + if (resolved_objects != nullptr) { + jvmti->Deallocate((unsigned char *)resolved_objects); + } + if (resolved_tags != nullptr) { + jvmti->Deallocate((unsigned char *)resolved_tags); + } + } + + // object_class is NOT deleted here - it is now cached in + // _cached_object_class and reused across calls on this same JNIEnv (see + // above), not a per-call local ref. + + if (!ctx.truncated && jni != nullptr && object_class == nullptr && + (!_pending_expand.empty() || !_priority_expand.empty())) { + // FindClass("java/lang/Object") failed for this (attached) JNIEnv, so + // the batching loop above never ran even though pending frontier work + // remains. Report truncated rather than leaving *truncated false: the + // caller (runPassManualWalk()/runPass()) treats false as "no pending + // frontier work", which would falsely mark the search + // SearchState::COMPLETED instead of retrying - directly contradicting + // this subsystem's documented "no silent truncation" requirement (see + // SearchAbandonReason's header comment). + ctx.truncated = true; + } + + *edges_admitted = ctx.edges_admitted; + *truncated = ctx.truncated; + *frontier_cap_hit = ctx.frontier_cap_hit; +} + +void ReferenceChainTracker::admitStaticFieldRoots(jvmtiEnv *jvmti, JNIEnv *jni, + int hop_cap, int budget, + int *edges_admitted, + bool *truncated, + bool *frontier_cap_hit) { + assert(!t_inGCCallback && + "GetLoadedClasses/FollowReferences are JVMTI Heap-category calls " + "and must not be made from GarbageCollectionStart/Finish"); + *edges_admitted = 0; + *truncated = false; + *frontier_cap_hit = false; + + if (jni == nullptr) { + // No JNIEnv to build the holder array on (some test seams) - see + // expandFrontier()'s own identical guard. Best-effort sweep: nothing + // discovered this call, not this pass's own truncation. + return; + } + + jint class_count = 0; + jclass *classes = nullptr; + jvmtiError classes_err = jvmti->GetLoadedClasses(&class_count, &classes); + if (classes_err != JVMTI_ERROR_NONE) { + return; + } + if (class_count <= 0) { + if (classes != nullptr) { + jvmti->Deallocate((unsigned char *)classes); + } + return; + } + + // Same java/lang/Object element-type cache expandFrontier() uses for its + // own frontier-holder array - shared across both call sites on this same + // attached JNIEnv rather than a second FindClass() per pass. + if (_cached_object_class_jni != jni) { + _cached_object_class = jni->FindClass("java/lang/Object"); + if (jniExceptionCheck(jni)) { + _cached_object_class = nullptr; + } + _cached_object_class_jni = jni; + } + jclass object_class = _cached_object_class; + + if (object_class == nullptr || + jni->EnsureLocalCapacity(class_count + 16) < 0 || + jniExceptionCheck(jni)) { + for (jint i = 0; i < class_count; i++) { + jni->DeleteLocalRef(classes[i]); + } + jvmti->Deallocate((unsigned char *)classes); + return; + } + + jobjectArray holder = jni->NewObjectArray(class_count, object_class, nullptr); + if (jniExceptionCheck(jni)) { + // OutOfMemoryError (or any other exception) building the holder - + // clear it rather than let it survive into the DeleteLocalRef() calls + // below (JNI spec: undefined behavior with a pending exception across + // ordinary JNI calls), same as expandFrontier()'s identical case. + holder = nullptr; + } + if (holder != nullptr) { + for (jint i = 0; i < class_count; i++) { + jni->SetObjectArrayElement(holder, i, classes[i]); + if (jniExceptionCheck(jni)) { + holder = nullptr; + break; + } + } + } + + for (jint i = 0; i < class_count; i++) { + jni->DeleteLocalRef(classes[i]); + } + jvmti->Deallocate((unsigned char *)classes); + + if (holder == nullptr) { + // OOM/local-ref exhaustion/array-store failure - skip this pass's sweep + // rather than treating it like the manual walk's own truncation (see + // this method's own header comment). + return; + } + + PassContext ctx; + ctx.tracker = this; + ctx.frontier = _frontier; + ctx.hop_cap = hop_cap; + ctx.budget = budget; + ctx.edges_admitted = 0; + ctx.truncated = false; + ctx.frontier_cap_hit = false; + // Empty (not null) batch_tags forces heapReferenceCallback() to stop at + // exactly one hop past each class - see this method's own header comment + // for why a deeper descent here would reintroduce the whole-graph + // FollowReferences cost the array-holder batching design otherwise avoids. + std::unordered_set empty_batch_tags; + ctx.batch_tags = &empty_batch_tags; + // Lets heapReferenceCallback() walk past the holder->class seed edge (see + // PassContext::static_field_seed's own comment) so this sweep actually + // reaches each class's static fields instead of stopping at the + // negative-tagged class object itself. + ctx.static_field_seed = true; + + jvmtiHeapCallbacks callbacks; + memset(&callbacks, 0, sizeof(callbacks)); + callbacks.heap_reference_callback = heapReferenceCallback; + jvmtiError follow_err = + jvmti->FollowReferences(0, nullptr, holder, &callbacks, &ctx); + jni->DeleteLocalRef(holder); + if (follow_err != JVMTI_ERROR_NONE) { + return; + } + + *edges_admitted = ctx.edges_admitted; + *truncated = ctx.truncated; + *frontier_cap_hit = ctx.frontier_cap_hit; +} + +bool ReferenceChainTracker::releaseSearchTags(jvmtiEnv *jvmti, JNIEnv *jni) { + assert(!t_inGCCallback && + "GetObjectsWithTags is a JVMTI Heap-category call and must not be " + "made from GarbageCollectionStart/Finish"); + if (jvmti == nullptr || _frontier == nullptr) { + return true; // nothing to release + } + + jlong scan_limit = _frontier->size(); + std::vector live_tags; + for (jlong tag = 1; tag <= scan_limit; tag++) { + FrontierEntry entry{}; + if (_frontier->lookup(tag, &entry) && + entry.state != FrontierEntryState::ABANDONED) { + live_tags.push_back(tag); + } + } + if (live_tags.empty()) { + return true; + } + + jint resolved_count = 0; + jobject *resolved_objects = nullptr; + jlong *resolved_tags = nullptr; + if (jvmti->GetObjectsWithTags((jint)live_tags.size(), live_tags.data(), + &resolved_count, &resolved_objects, + &resolved_tags) != JVMTI_ERROR_NONE) { + // GetObjectsWithTags() itself failed (e.g. JVMTI_ERROR_OUT_OF_MEMORY): + // we do NOT know which, if any, of live_tags are still live objects, so + // do not mark any of them ABANDONED here - doing so while their JVMTI + // tag might still be set would let a restarted search's nextTag() + // sequence eventually reissue the same numeric tag to a brand-new + // object, corrupting FrontierTable's tag-uniqueness invariant (see this + // method's own header comment). Report failure so the caller retries + // this same batch later instead of proceeding to restart. + Counters::increment(REFERENCE_CHAIN_TAG_RELEASE_FAILED); + Log::warn("ReferenceChains: GetObjectsWithTags failed while releasing " + "%zu search tag(s); will retry before allowing a search " + "restart", + live_tags.size()); + return false; + } + + for (jint i = 0; i < resolved_count; i++) { + // clearTag() rather than a raw SetTag() call - reuses the + // same helper (and its GC-callback self-consistency assert) tagObject/ + // getTag already go through. + clearTag(jvmti, resolved_objects[i]); + if (jni != nullptr) { + jni->DeleteLocalRef(resolved_objects[i]); + } + } + if (resolved_objects != nullptr) { + jvmti->Deallocate((unsigned char *)resolved_objects); + } + if (resolved_tags != nullptr) { + jvmti->Deallocate((unsigned char *)resolved_tags); + } + // Tags that failed to resolve above are already dead (JVMTI forgot them + // with their object) - nothing to release, just mark the record ABANDONED + // below like every other entry this search owned. Only reached once + // GetObjectsWithTags() itself succeeded, so every live_tags entry has now + // either been resolved-and-cleared or confirmed dead. + for (jlong tag : live_tags) { + _frontier->clear(tag); + } + return true; +} + +bool ReferenceChainTracker::runPass(jvmtiEnv *jvmti, JNIEnv *jni, + bool *out_truncated) { + if (!_enabled || jvmti == nullptr || _frontier == nullptr) { + TEST_LOG("ReferenceChainTracker::runPass early-exit: enabled=%d jvmti=%p frontier=%p", + _enabled, (void *)jvmti, (void *)_frontier); + return false; + } + + if (_search_state != SearchState::RUNNING) { + // The search already reached a terminal outcome - nothing left for + // another pass to do until shouldRunPass() decides to restartSearch() + // (this class's header comment), which flips _search_started back to + // false before this method is called again. If a prior terminal-state + // transition's releaseSearchTags() call failed, retry it here rather + // than leaving _tags_released false forever - shouldRunPass() refuses + // to restart the search until this succeeds (see _tags_released's own + // comment), so this is the only remaining call site that can make + // progress on the retry. + if (!_tags_released) { + _tags_released = releaseSearchTags(jvmti, jni); + } + TEST_LOG("ReferenceChainTracker::runPass no-op: searchState=%d already terminal " + "tagsReleased=%d", + (int)_search_state, _tags_released); + if (out_truncated != nullptr) { + *out_truncated = false; + } + return true; + } + + resolveLoadedClasses(jvmti, jni); + + TEST_LOG("ReferenceChainTracker::runPass starting JVMTI walk: " + "search_started=%d frontierSize=%zu", + _search_started, _frontier != nullptr ? _frontier->size() : (size_t)0); + + int edges_admitted = 0; + bool truncated = false; + bool frontier_cap_hit = false; + jvmtiError err; + // Wall-clock duration of the actual safepoint-triggering JVMTI call below + // (FollowReferences or, inside expandFrontier(), FollowReferences preceded + // by GetObjectsWithTags) - the measured signal updatePacing() below feeds + // into _pause_pid. Deliberately scoped to just that call, not this whole + // method, so resolveLoadedClasses()'s own JNI/JVMTI cost and this method's + // own bookkeeping are not mistaken for safepoint time. Measured via + // TSC::ticks() rather than OS::nanotime(), matching this codebase's other + // interval-timing call sites (LivenessTracker::track(), pollWatchedTargets() + // below); TSC::ticks() itself falls back to OS::nanotime() when the TSC is + // unavailable/disabled, so this is a strict upgrade with no behavior change + // on hosts without a usable timestamp counter. + u64 pass_wall_ticks = 0; + + // Every pass is driven by the manual walk (runPassManualWalk() - + // IterateOverReachableObjects for roots, then a batched array-holder + // FollowReferences per BFS level in expandFrontier()), on every collector + // including ZGC. The walk issues only JVMTI heap calls, which run inside + // the VM_HeapWalkOperation safepoint and honor ZGC's load barriers, so + // concurrent relocation cannot corrupt it - it reads no raw oop. Batching + // one hop per level keeps each FollowReferences bounded, avoiding the + // multi-hundred-ms-to-second STW pauses a whole-graph FollowReferences + // would impose. + bool manual_first_pass = !_search_started; + if (manual_first_pass) { + _search_started = true; + store(_search_start_ns, OS::nanotime()); + } + + // Root/stack-ref enumeration alone never discovers a root's transitive + // children (runPassManualWalk()'s own comment) - there is no "first pass + // walks the whole graph inline" shortcut here, so every pass (first or + // resumed) takes the same expand-frontier shape. Root/stack-ref + // enumeration itself, though, does NOT run on every pass: its fixed + // native dispatch cost is paid in full regardless of budget (see + // ROOT_ENUM_MIN_INTERVAL_NS's own comment), so it is cadence-gated to the + // first pass, a still-truncated retry from last time, or once + // ROOT_ENUM_MIN_INTERVAL_NS has elapsed since it last ran - not every + // pass, unlike expandFrontier()'s cheap incremental work below. + u64 now_ns = OS::nanotime(); + bool run_root_enum = manual_first_pass || _root_enum_truncated_last_time || + (now_ns - _last_root_enum_ns >= ROOT_ENUM_MIN_INTERVAL_NS); + + u64 call_start_ticks = TSC::ticks(); + runPassManualWalk(jvmti, jni, run_root_enum, _first_pass_budget, + _effective_budget, &edges_admitted, &truncated, + &frontier_cap_hit); + pass_wall_ticks = TSC::ticks() - call_start_ticks; + err = JVMTI_ERROR_NONE; + + store(_passes_run, load(_passes_run) + 1); + _last_pass_gc_finish_epoch = gcFinishEpoch(); + store(_last_pass_ns, OS::nanotime()); + if (!run_root_enum) { + // A pass that ran root/stack-ref enumeration spends _first_pass_budget, + // not _effective_budget - its duration is not a signal about the + // per-pass cost updatePacing() is trying to regulate (expandFrontier()'s + // cheap, per-node expansion calls), so feeding it in here would + // throttle _effective_budget down for every one of those unrelated + // later passes based on a single, deliberately oversized outlier. + updatePacing(pass_wall_ticks); + } else { + // Excluded from the budget/cadence controller above, but not from the + // borrow ceiling's revocation check (see maybeRevokeBorrowForRootEnumPass()'s + // own comment) - a root-enum pass's wall-clock cost is real pause time + // and must still be able to revoke a borrowed-budget grant the pacing + // controller would otherwise keep believing is safe. + maybeRevokeBorrowForRootEnumPass(pass_wall_ticks); + } + // Search restart (this class's own header comment): accumulate this + // pass's own cost toward the running total restartSearch() will spend into + // _pain_budget once the search reaches a terminal state - same + // TSC::ticks_to_millis() conversion updatePacing() already uses for its + // own pass-duration signal. + _search_pain_ms += TSC::ticks_to_millis(pass_wall_ticks); + + // Design doc's Termination section, decided in priority order: + // 1. Frontier-size cap hit -> abandon immediately, regardless of TTL. + // 2. No pending frontier entries left (this pass wasn't truncated) -> + // the reachable graph was fully explored within the hop cap; natural + // completion (the hop cap alone is a normal boundary, not + // truncation - see heapReferenceCallback()'s own comment). + // 3. TTL exceeded while work is still pending -> abandon. + // 4. Otherwise stay RUNNING - more pending work, no cap hit yet. + // Write the abandon reason (and every other detail field + // buildAbandonedEvent() reads: _passes_run/_last_pass_ns/_search_start_ns + // above, _frontier's size, ...) BEFORE the _search_state transition below, + // and publish that transition with a release store - dump()'s reader side + // (buildAbandonedEvent()/searchState()) pairs it with an acquire load, so + // observing the new _search_state also guarantees every detail field + // written before this release store is visible too, even on a weakly + // ordered CPU (e.g. arm64) where relaxed stores to two different atomics + // carry no such guarantee. + bool has_pending_frontier = truncated; + if (frontier_cap_hit) { + store(_abandon_reason, (u8)SearchAbandonReason::FRONTIER_CAP); + storeRelease(_search_state, (u8)SearchState::ABANDONED); + } else if (!has_pending_frontier) { + storeRelease(_search_state, (u8)SearchState::COMPLETED); + } else if (_ttl_ms > 0 && + _last_pass_ns - _search_start_ns >= (u64)_ttl_ms * 1000000ULL) { + store(_abandon_reason, (u8)SearchAbandonReason::TTL); + storeRelease(_search_state, (u8)SearchState::ABANDONED); + } + + if (load(_search_state) != SearchState::RUNNING) { + _tags_released = releaseSearchTags(jvmti, jni); + } + + if (out_truncated != nullptr) { + *out_truncated = truncated; + } + + TEST_LOG("ReferenceChainTracker::runPass done: err=%d edges_admitted=%d truncated=%d " + "frontier_cap_hit=%d searchState=%d abandonReason=%d frontierSize=%d " + "effectiveBudget=%d effectiveCadenceNs=%llu", + (int)err, edges_admitted, truncated, frontier_cap_hit, (int)load(_search_state), + (int)_abandon_reason, _frontier->size(), _effective_budget, + (unsigned long long)_effective_cadence_ns); + + return err == JVMTI_ERROR_NONE; +} + +// --------------------------------------------------------------------------- +// Pause-time-SLO feedback loop (see this method's declaration in +// referenceChains.h for the full mechanism). +// --------------------------------------------------------------------------- + +void ReferenceChainTracker::updatePacing(u64 pass_wall_ticks) { + // Truncating to whole milliseconds matches every other PidController usage + // in this codebase (ObjectSampler/MallocTracer/NativeSocketSampler all feed + // it integer counts, pidController.h's `compute(u64 input, ...)`) - sub-ms + // precision is not meaningful against a millisecond-scale target anyway. + // TSC::ticks_to_millis() already falls back to a nanotime-based conversion + // when the TSC is unavailable/disabled (tsc.h), matching runPass()'s own + // TSC::ticks() fallback for pass_wall_ticks itself. + u64 pass_ms = TSC::ticks_to_millis(pass_wall_ticks); + // time_delta_coefficient is deliberately 1.0, not a real-elapsed-time + // ratio - unlike ObjectSampler's usage (objectSampler.cpp), which + // rescales an event count accumulated over a variable-length real-time + // window against a fixed-real-time target, _pause_pid was constructed + // with sampling_window=1 (its own constructor comment above, in start()): + // one compute() call *is* one pass, and pass_ms already IS the per-call + // quantity being compared against the per-call ceiling _target encodes. + // Rescaling pass_ms by how much real wall-clock time elapsed since the + // previous call would compare it against a target calibrated for a + // different unit (per-second, not per-pass), double-counting the same + // irregular-cadence effect this coefficient exists to correct for in the + // per-second case. (Re-litigated after review: an earlier pass flagged + // this as a bug and a fix using TSC-measured elapsed time was drafted, + // but re-checking against this constructor's own documented design + // confirmed 1.0 is correct here - see this comment instead of changing + // it again.) + double signal = _pause_pid.compute(pass_ms, 1.0); + + // Budget-borrowing (referenceChains.h's _borrowed_budget comment): only a + // sustained run of comfortably-under-target passes earns extra headroom + // above _budget, and any pass that is not comfortably under target revokes + // it immediately - _budget itself must stay the ceiling the instant this + // search stops proving it has pause-time room to spare. + bool comfortably_under_target = + _pause_target_ms > 0 && + (double)pass_ms <= (double)_pause_target_ms * BORROW_UNDER_TARGET_FRACTION; + if (comfortably_under_target) { + if (_consecutive_under_target_passes < BORROW_WARMUP_PASSES) { + _consecutive_under_target_passes++; + } + if (_consecutive_under_target_passes >= BORROW_WARMUP_PASSES) { + int64_t max_borrow = (int64_t)_budget * (BORROW_CEILING_MULTIPLIER - 1); + int64_t grown = _borrowed_budget + + (int64_t)std::llround((double)_budget * BORROW_GROWTH_FRACTION); + _borrowed_budget = std::min(grown, max_borrow); + } + } else { + _consecutive_under_target_passes = 0; + _borrowed_budget = 0; + } + + int64_t ceiling = (int64_t)_budget + _borrowed_budget; + int64_t floor = ceiling > 0 ? std::min((int64_t)MIN_EFFECTIVE_BUDGET, ceiling) + : 0; + int64_t desired = (int64_t)_effective_budget + (int64_t)std::lround(signal); + int64_t clamped = std::max(floor, std::min(ceiling, desired)); + // Whatever part of `desired` the clamp above could not absorb - positive + // when there was more headroom than the ceiling allows, negative when the + // pass is still over the pause-time target even at the floor. Drives + // _effective_cadence_ns below, per this method's own comment on folding + // Open Question 5 into the same controller output. + int64_t overflow = desired - clamped; + _effective_budget = (int)clamped; + + if (overflow < 0) { + // Still over the pause-time ceiling even at the minimum budget - widen + // the fallback interval instead of shrinking the budget further. + u64 step = (u64)(-overflow) * CADENCE_NS_PER_EDGE_OVERFLOW; + _effective_cadence_ns = + std::min(_effective_cadence_ns + step, MAX_EFFECTIVE_CADENCE_NS); + } else if (overflow > 0) { + // Comfortably under the ceiling even at the maximum (config) budget - + // relax the fallback interval. The GC-finish-epoch trigger already fires + // independently of cadence (shouldRunPass() above), so this only + // shortens how long an idle, no-GC-event search waits between passes. + u64 step = (u64)overflow * CADENCE_NS_PER_EDGE_OVERFLOW; + _effective_cadence_ns = + step >= _effective_cadence_ns + ? MIN_EFFECTIVE_CADENCE_NS + : std::max(_effective_cadence_ns - step, MIN_EFFECTIVE_CADENCE_NS); + } + // overflow == 0: the budget clamp alone fully absorbed this pass's + // correction - leave the cadence at its current value. +} + +// A root/stack-ref enumeration pass never reaches updatePacing() above (see +// runPass()'s own comment on why its wall-clock cost is excluded from the +// per-pass PID/effective-budget signal), but it still spends real +// pause-time-SLO time. _borrowed_budget's own comment requires the grant be +// revoked the instant ANY pass is not comfortably under target, so this +// mirrors updatePacing()'s comfortably_under_target check for that one +// purpose only - it never grows _consecutive_under_target_passes/ +// _borrowed_budget, since the warmup streak is calibrated against +// expandFrontier()'s per-node cost, not this call's unrelated fixed +// dispatch cost. +void ReferenceChainTracker::maybeRevokeBorrowForRootEnumPass( + u64 pass_wall_ticks) { + if (_pause_target_ms <= 0) { + return; + } + u64 pass_ms = TSC::ticks_to_millis(pass_wall_ticks); + bool comfortably_under_target = + (double)pass_ms <= (double)_pause_target_ms * BORROW_UNDER_TARGET_FRACTION; + if (!comfortably_under_target) { + _consecutive_under_target_passes = 0; + _borrowed_budget = 0; + // The ceiling updatePacing() would compute right now collapses to + // _budget alone (no _borrowed_budget term above) - re-clamp + // _effective_budget immediately instead of leaving the borrow-inflated + // value in place until the next ordinary pass's updatePacing() call. + _effective_budget = std::min(_effective_budget, (int)_budget); + } +} + +// --------------------------------------------------------------------------- +// Target-selection bridging step - LivenessTracker's leak-candidate ranking feeds +// this tracker's already-running BFS search (design doc's Open Question 3, +// corrected mechanism - see this method's own comment below and the plan +// doc's "Correction to the design doc's Open Question 3 mechanism"). +// --------------------------------------------------------------------------- + +void ReferenceChainTracker::pollWatchedTargets(jvmtiEnv *jvmti, JNIEnv *jni) { + if (!_enabled || jvmti == nullptr || jni == nullptr || + !LivenessTracker::instance()->gcGenerationsEnabled()) { + // Explicit guard, even though selectLeakCandidates() below already + // returns 0 candidates whenever its own _gc_generations gate + // (livenessTracker.h) is off - keeps this method's cost at the four + // checks above, not even a shared-lock-guarded table scan, when the + // feature isn't in use (design doc's Open Question 3 "still undecided" + // fallback: referencechains=... alone gets no target-seeding). + return; + } + + // Stamp every entry this poll refreshes with the current search + // generation. _search_start_ns changes each time restartSearch() begins a + // new search (runPass() sets it on the restarted search's first pass); a + // cached chain whose source_search_ns predates the current one was + // reconstructed from a FrontierTable the restart has since reset, so it is + // refreshed below the moment the restarted search re-tags its sample - + // trusting the stale source_tag would risk matching a tag the reset has + // reassigned to an unrelated object. + const u64 current_search_ns = load(_search_start_ns); + + // klass_ids resolved (and therefore already pruned-if-dead) by the + // candidate loop below, so the prune pass afterwards skips re-resolving + // them - it only needs to cover cached klasses that are no longer flagged. + std::unordered_set handled; + + // Sized generously above LivenessTracker::selectLeakCandidates()'s own + // private MAX_LEAK_CANDIDATES cap (design doc: top 3-5) - that method + // clamps internally to whichever of `max`/its own cap/the qualifying- + // candidate count is smallest, so this local bound only needs to be + // "large enough", not exactly synchronized to a constant this class has + // no visibility into (MAX_LEAK_CANDIDATES is private to LivenessTracker). + constexpr int kMaxWatchedCandidates = 8; + KlassCandidate candidates[kMaxWatchedCandidates]; + int candidate_count = LivenessTracker::instance()->selectLeakCandidates( + candidates, kMaxWatchedCandidates); + // Only log when there are candidates to act on - this poll runs on every + // BFS-thread wake (once per second), so logging a zero count is per-second + // noise for the common idle case. + if (candidate_count > 0) { + TEST_LOG("ReferenceChainTracker::pollWatchedTargets candidate_count=%d", candidate_count); + } + + for (int i = 0; i < candidate_count; i++) { + TEST_LOG("ReferenceChainTracker::pollWatchedTargets candidate[%d] klass_id=%u", i, + candidates[i].klass_id); + // Deliberately does NOT resolve candidates[i].representative directly: + // that field is a snapshot taken under selectLeakCandidates()'s own + // shared-lock scan, which can go stale (LRU-evicted and + // DeleteWeakGlobalRef()'d by LivenessTracker's cleanup_table(), running + // concurrently on a different thread) at any point between that call and + // this one - see selectLeakCandidates()'s comment (livenessTracker.h) for + // why resolving it here would be undefined behavior, not just a null + // result. resolveCandidateRepresentative() re-reads the table's current + // value for this klass_id and resolves it atomically under the same + // lock, so it is always safe to call from here. + const u32 klass_id = candidates[i].klass_id; + handled.insert(klass_id); + jobject obj = LivenessTracker::instance()->resolveCandidateRepresentative( + jni, klass_id); + if (obj == nullptr) { + TEST_LOG("ReferenceChainTracker::pollWatchedTargets candidate[%d] klass_id=%u " + "representative could not be resolved (died/evicted)", + i, klass_id); + // The sample this klass's cached chain describes is gone - stop + // re-emitting it (this poll's own prune contract). + _resolved_chains_lock.lock(); + _resolved_chains.erase(klass_id); + _resolved_chains_lock.unlock(); + continue; // candidate died, or was evicted, since LivenessTracker flagged it + } + + // Corrected mechanism (the plan doc's own correction to the design doc's + // original proposal): a READ, never a SetTag + // seed. runPass()'s whole-graph walk is the only thing that ever + // assigns a tag; if it already has (tag > 0), heapReferenceCallback() + // already recorded a correct parent_tag/depth chain for this object the + // moment it was first visited - pre-tagging it here instead would make + // that callback's `*tag_ptr == 0` branch (the only branch that records + // parent_tag/depth, referenceChains.h) skip it entirely the next time a + // pass reached it. + jlong tag = getTag(jvmti, obj); + + // Reconstruct only when this klass has no current chain cached: either + // nothing cached yet, or what is cached was built from a different tag or + // an earlier search generation (see current_search_ns above). A klass + // that keeps getting flagged, unchanged, across many polls is left alone + // - its cached chain is already being re-emitted on every dump. + bool need_refresh = false; + if (tag > 0) { + _resolved_chains_lock.lock(); + auto it = _resolved_chains.find(klass_id); + need_refresh = (it == _resolved_chains.end() || + it->second.source_tag != tag || + it->second.source_search_ns != current_search_ns); + _resolved_chains_lock.unlock(); + } + TEST_LOG("ReferenceChainTracker::pollWatchedTargets candidate[%d] klass_id=%u tag=%lld " + "needRefresh=%d", + i, klass_id, (long long)tag, need_refresh); + if (need_refresh) { + ReferenceChainEvent event; + bool built = buildChainEvent(tag, &event); + TEST_LOG("ReferenceChainTracker::pollWatchedTargets buildChainEvent(tag=%lld) -> %d", + (long long)tag, built); + if (built) { + // Provisional stamp; drainPendingChainEvents() re-stamps each copy at + // dump time so the event lands in that chunk's window. + event._start_time = TSC::ticks(); + cacheResolvedChain(klass_id, std::move(event), tag, current_search_ns); + } + } + // tag == 0: not yet discovered by any pass - retry on the next poll, + // once a pass has had a chance to reach it (this method's own comment). + + jni->DeleteLocalRef(obj); + } + + // Prune cached chains for samples that are gone but were not visited by the + // candidate loop above (a klass that stopped being flagged but may still be + // alive). Snapshot the keys under lock, resolve each without holding it + // (resolveCandidateRepresentative() takes LivenessTracker's own lock and + // calls JNI), then erase the ones whose representative no longer resolves. + std::vector cached_keys; + _resolved_chains_lock.lock(); + cached_keys.reserve(_resolved_chains.size()); + for (const auto &kv : _resolved_chains) { + cached_keys.push_back(kv.first); + } + _resolved_chains_lock.unlock(); + + std::vector dead_keys; + for (u32 k : cached_keys) { + if (handled.find(k) != handled.end()) { + continue; // candidate loop already resolved (and pruned if dead) this one + } + jobject o = LivenessTracker::instance()->resolveCandidateRepresentative(jni, k); + if (o == nullptr) { + dead_keys.push_back(k); + } else { + jni->DeleteLocalRef(o); + } + } + if (!dead_keys.empty()) { + _resolved_chains_lock.lock(); + for (u32 k : dead_keys) { + _resolved_chains.erase(k); + } + _resolved_chains_lock.unlock(); + TEST_LOG("ReferenceChainTracker::pollWatchedTargets pruned=%d stale cached chains", + (int)dead_keys.size()); + } +} + +// Inserts or refreshes klass_id's resolved chain - see _resolved_chains' +// comment (referenceChains.h) for why a resolved chain is cached and +// re-emitted rather than emitted once. A refresh (klass_id already present) +// always succeeds; only a brand-new klass_id arriving with the cache already +// full is dropped (counted, not silent), rather than evicting some other +// still-live sample's chain. Split out of pollWatchedTargets() so +// ResolvedChainCacheTest (referenceChains_ut.cpp) can drive the overflow path +// directly, without standing up hundreds of real LivenessTracker candidates. +void ReferenceChainTracker::cacheResolvedChain(u32 klass_id, + ReferenceChainEvent &&event, + jlong source_tag, + u64 source_search_ns) { + _resolved_chains_lock.lock(); + auto it = _resolved_chains.find(klass_id); + if (it == _resolved_chains.end() && + (int)_resolved_chains.size() >= MAX_RESOLVED_CHAINS) { + _resolved_chains_lock.unlock(); + Counters::increment(REFERENCE_CHAIN_EVENTS_DROPPED); + TEST_LOG("ReferenceChainTracker::cacheResolvedChain dropped new klass_id=%u, " + "cache full (at MAX_RESOLVED_CHAINS=%d)", + klass_id, MAX_RESOLVED_CHAINS); + return; + } + CachedChain &slot = _resolved_chains[klass_id]; + slot.event = std::move(event); + slot.source_tag = source_tag; + slot.source_search_ns = source_search_ns; + TEST_LOG("ReferenceChainTracker::cacheResolvedChain klass_id=%u source_tag=%lld " + "cache_size=%d", + klass_id, (long long)source_tag, (int)_resolved_chains.size()); + _resolved_chains_lock.unlock(); +} + +void ReferenceChainTracker::drainPendingChainEvents( + std::vector *out) { + if (out == nullptr) { + return; + } + // Snapshot-and-keep, not a drain: every cached chain is copied out (and + // re-stamped so it lands in the dumping chunk's window) while the cache + // itself is left intact, so the same live sample's chain re-emits into + // every chunk it survives into (see _resolved_chains' comment). `now` is + // read once, before the lock, so every event in one dump shares a stamp. + u64 now = TSC::ticks(); + _resolved_chains_lock.lock(); + for (const auto &kv : _resolved_chains) { + out->push_back(kv.second.event); + out->back()._start_time = now; + } + _resolved_chains_lock.unlock(); + TEST_LOG("ReferenceChainTracker::drainPendingChainEvents re-emitted=%d", + (int)out->size()); +} diff --git a/ddprof-lib/src/main/cpp/referenceChains.h b/ddprof-lib/src/main/cpp/referenceChains.h new file mode 100644 index 0000000000..8b42848cbd --- /dev/null +++ b/ddprof-lib/src/main/cpp/referenceChains.h @@ -0,0 +1,1783 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _REFERENCECHAINS_H +#define _REFERENCECHAINS_H + +#include "arch.h" +#include "arguments.h" +#include "common.h" +#include "event.h" +#include "painBudget.h" +#include "pidController.h" +#include "spinLock.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// PROF-15341: incremental resumption across passes (see +// ReferenceChainTracker::runPass() below), building on an earlier +// proof-of-concept that established two things end-to-end: +// 1. A cheap "a GC just happened" signal reaches this subsystem via the +// GarbageCollectionStart/Finish JVMTI callbacks (vmEntry.cpp), mirroring +// LivenessTracker::onGC() (livenessTracker.cpp:415-426) - just bumping an +// atomic epoch counter, nothing else. +// 2. JVMTI object tags round-trip a live object across a GC (SetTag/GetTag), +// via the minimal tagObject()/getTag()/clearTag() helpers below. +// The tag-indexed FrontierTable was added next, followed by the actual heap +// walk (runPass() calling jvmtiEnv::FollowReferences from the heap roots, +// heapReferenceCallback() populating FrontierTable subject to the hop +// cap/budget/frontier cap) - but that walk originally ran as a single, +// non-resumable pass with no cross-pass persistence, no GC-epoch-driven +// scheduling, and no tag release. This revision makes the search resumable +// and terminating: +// - runPass() now distinguishes a search's first pass (seed +// FollowReferences from the heap roots, exactly as the original +// single-pass walk did) from a resumed pass (expandFrontier() below: +// resolve each not-yet-expanded frontier entry via GetObjectsWithTags - +// dead ones are pruned for free - then call FollowReferences with that +// object as initial_object to discover its own outgoing edges, +// continuing until the per-pass budget or the frontier cap is hit). +// - The Termination section's cutoffs are enforced across passes: the hop +// cap already carried over via FrontierEntry::depth; this adds a +// wall-clock TTL cutoff (_ttl_ms, from first pass) and treats the +// frontier-size cap as immediate search abandonment rather than a +// per-pass truncation. +// - releaseSearchTags() clears (SetTag(obj, 0)) every live tag this search +// still owns once it completes or is abandoned, without discarding the +// FrontierTable's own records - reconstructChain() keeps working from +// memory after the search ends, only the underlying JVMTI tag map entry +// is released (design doc's Open Question 4 concern about leftover-tag +// overhead). +// - shouldRunPass()/threadLoop() implement the Triggering section's pass- +// scheduling signal (GC-finish epoch advanced, or a fixed cadence +// elapsed) - see threadLoop()'s own comment for why the thread this runs +// on is still not spawned by start(). +// +// PROF-15341 (doc/architecture/LiveHeapReferenceChains-RemainingWorkPlan.md): +// pollWatchedTargets() below is the LivenessTracker-to-ReferenceChainTracker +// target-selection bridge, closing the gap left by buildChainEvent() having +// no caller. It polls LivenessTracker::selectLeakCandidates() +// (livenessTracker.h's Open Question 3 population-slope ranking) and, for +// each candidate already tagged by an ordinary runPass() walk, reconstructs +// and emits its chain via Profiler::writeReferenceChain(). This is a READ of +// getTag(), never a SetTag seed - see pollWatchedTargets()'s own comment for +// the plan doc's "Correction to the design doc's Open Question 3 mechanism" +// this implements instead of the design doc's original seeding proposal. +// +// `can_tag_objects` and `can_generate_garbage_collection_events` are already +// requested unconditionally in vmEntry.cpp, so this bridging step only adds +// callback wiring and lazy event enablement, not capability requests. +// +// PROF-15341 (doc/architecture/LiveHeapReferenceChains-RemainingWorkPlan.md): +// the pause-time pacing controller replaces the fixed _budget/PASS_CADENCE_NS +// constants' role as the literal per-pass values with a measured +// pause-time-SLO feedback loop (design doc's Open Questions 2/5, "Proposed +// mechanism" paragraphs). runPass() now times its own FollowReferences/ +// GetObjectsWithTags call (already the thread blocked inside the safepoint +// those trigger, see the Triggering section) and feeds that duration to +// updatePacing() below, which scales _effective_budget/_effective_cadence_ns +// - the values runPass()/shouldRunPass()/threadLoop() now actually use - +// via this tracker's own PidController instance (_pause_pid). _budget/ +// PASS_CADENCE_NS survive as this controller's ceiling/baseline +// respectively, not as the literal per-pass values anymore. See +// updatePacing()'s own comment for the full mechanism, including why its +// gains are not copied from ObjectSampler/MallocTracer/NativeSocketSampler's +// shared triple. +// +// PROF-15341 (doc/architecture/LiveHeapReferenceChains-RemainingWorkPlan.md): +// search restart. Earlier revisions of this class only ever ran a single +// search for the tracker's entire lifetime (runPass()'s own comment used to +// read "starting a *new* search once one ends is not implemented"). That is +// a real gap: LivenessTracker::selectLeakCandidates() only trusts a klass's +// population trend once it has accumulated +// LivenessTracker::KLASS_POPULATION_MIN_FILL_FOR_TREND GC epochs of history +// (livenessTracker.cpp), which takes real wall-clock time - but a +// large-enough-budget search can finish walking the whole reachable graph, +// and permanently stop, before that time has passed. Any object allocated +// after the search already completed is then structurally undiscoverable +// forever, not just unlucky. +// +// The fix: a (re)started search's first pass is now gated on +// LivenessTracker already reporting at least one leak candidate, rather than +// starting unconditionally - by the time a candidate is flagged, the +// underlying object has necessarily survived several epochs already, so a +// fresh root-seeded walk started right then is very likely to still find it +// reachable. restartSearch() (referenceChains.cpp) resets the per-search +// state (frontier table, tag counter, emitted-target set) once a prior +// search reaches COMPLETED/ABANDONED, so shouldRunPass() can treat the next +// candidate-driven trigger exactly like a first-ever search. +// +// Restarting is still an expensive full-heap walk, so canAffordNewSearch() +// also gates it on _pain_budget (PainBudget, painBudget.h) - a leaky bucket +// over the wall-clock cost of past searches, not a fixed cooldown, so a +// search that finished cheaply can restart again soon while an expensive one +// has to wait proportionally longer. shouldRunPass() reuses this same +// canAffordNewSearch() check for the very first search too, though the pain +// budget half of it is always a no-op there (nothing has been spent yet). +// With LivenessTracker::gcGenerationsEnabled() off there is no candidate +// signal to gate on at all, so both the first search and every restart start +// unconditionally, exactly as before this revision - gating without a +// leak-detection mechanism running would have no signal to justify one. +// +// JVMTI spec restriction: GarbageCollectionStart/Finish run while the VM is +// at a safepoint, and only the Memory Management category (Allocate/ +// Deallocate) is allowed from inside them - Heap category calls (SetTag, +// GetTag, GetObjectsWithTags, FollowReferences, IterateThroughHeap) are not. +// onGCStart()/onGCFinish() below must therefore never call anything but the +// atomic counter bump. GCCallbackGuard (referenceChains.cpp) marks this +// thread as "inside the GC callback" for the duration of that bump; the tag +// helpers assert() (debug builds only) that they are never entered while the +// guard is active, as a self-consistency check - it does not catch every way +// this restriction could be violated, only calls routed through this class. +// +// Per-tag frontier metadata state (design doc: Frontier/EdgeStore records). +// FRONTIER->EXPANDED is driven by ReferenceChainTracker::expandFrontier()/ +// markAllFrontierExpanded() once an entry's own outgoing edges have +// been visited; FRONTIER/EXPANDED->ABANDONED is driven by expandFrontier()'s +// resolve-or-drop path (dead objects) and releaseSearchTags() (search +// completion/abandonment). +namespace FrontierEntryState { +constexpr u8 FRONTIER = 0; // discovered, not yet expanded by FollowReferences +constexpr u8 EXPANDED = 1; // expanded; children (if any) are in the table +constexpr u8 EDGE = 2; // on a path toward a target sample (EdgeStore) +constexpr u8 ABANDONED = 3; // tag released; entry kept only to avoid reuse +} // namespace FrontierEntryState + +// Search-level outcome (design doc's Termination section), distinct from a +// single pass's per-call truncation (ReferenceChainTracker::runPass()'s +// `out_truncated`, unchanged from the original single-pass heap-walk engine): +// a pass can be truncated - budget +// or frontier cap exhausted for *that call* - without the search itself +// being ABANDONED, because there may be nothing left to do (RUNNING is still +// correct) or plenty left for the next pass to pick up. See runPass()'s own +// comment for exactly which conditions move _search_state out of RUNNING. +namespace SearchState { +constexpr u8 RUNNING = 0; // at least one more pass may still make progress +constexpr u8 COMPLETED = 1; // reachable graph fully explored within caps +constexpr u8 ABANDONED = 2; // TTL or frontier-size cap forced an incomplete stop +} // namespace SearchState + +// Records which cutoff actually moved a search from RUNNING to ABANDONED +// (runPass()'s Termination-section decision, referenceChains.cpp) - recorded +// so abandonReason() (and the T_REFERENCE_CHAIN_ABANDONED JFR event built +// from it, see buildAbandonedEvent()) can report *why*, per the design doc's +// "no silent truncation" requirement, rather than just *that* it happened. +// Values match Recording::recordReferenceChainAbandoned()'s kReasons table +// (flightRecorder.cpp) index-for-index. +namespace SearchAbandonReason { +constexpr u8 NONE = 0; // not (yet) abandoned +constexpr u8 FRONTIER_CAP = 1; // frontier-size cap hit +constexpr u8 TTL = 2; // wall-clock TTL exceeded with work still pending +} // namespace SearchAbandonReason + +// Frontier/EdgeStore record (design doc: "Data structures" / +// "Frontier metadata storage"). Deliberately does not hold a live +// jclass/jobject: retaining either would defeat the point of using +// non-retaining JVMTI tags for frontier identity. `referrer_klass` is a +// StringDictionary id (Profiler::classMap(), profiler.h:260 - the same +// interning table LivenessTracker uses via Profiler::lookupClass(), +// livenessTracker.cpp:120-122) resolved from a class name string; the +// heap-walk engine populates it from GetClassSignature, and FrontierEntry +// only needs the field. +typedef struct FrontierEntry { + jlong parent_tag; // links back to the record that discovered this one + u32 referrer_klass; // StringDictionary id, 0 = unresolved/none + u32 depth; // hop count from the frontier's seed, for the hop cap + u8 state; // one of FrontierEntryState's constants + // jvmtiHeapReferenceKind of the edge that admitted this entry, but only + // meaningful when parent_tag == 0 (this entry is root-attached) - 0 (no + // JVMTI_HEAP_REFERENCE_* value is 0) for every other entry, since a + // non-root entry's own referrer edge kind is not what + // reconstructChain()'s callers want to report (they want to label the + // chain's root, not every hop). Set by heapReferenceCallback() + // (referenceChains.cpp) at insert() time. + u8 root_kind; +} FrontierEntry; + +// Durability ranking for FrontierEntry::root_kind (design doc's "Fix for +// root-attribution staleness" point 1 / this plan's Phase 5 item 1): higher +// is more durable. Used to decide whether a newly-observed root reference to +// an already-admitted, root-attached entry should replace its recorded +// root_kind rather than keeping whichever root happened to be enumerated +// first. Only the three tiers the design doc actually names are ranked with +// confidence ("static/class/CLD > JNI global > JNI local/stack local/ +// monitor"); JVMTI_HEAP_REFERENCE_THREAD and _OTHER have no documented tier +// and are conservatively bucketed with the least-durable tier rather than +// assumed durable. +inline int rootKindDurability(u8 root_kind) { + switch (root_kind) { + case JVMTI_HEAP_REFERENCE_STATIC_FIELD: + case JVMTI_HEAP_REFERENCE_SYSTEM_CLASS: + return 3; + case JVMTI_HEAP_REFERENCE_JNI_GLOBAL: + return 2; + case JVMTI_HEAP_REFERENCE_MONITOR: + case JVMTI_HEAP_REFERENCE_STACK_LOCAL: + case JVMTI_HEAP_REFERENCE_JNI_LOCAL: + case JVMTI_HEAP_REFERENCE_THREAD: + case JVMTI_HEAP_REFERENCE_OTHER: + return 1; + default: + return 0; // root_kind's own "not set"/non-root-attached value + } +} + +// True for the two root kinds the design doc calls "first observed via" +// rather than "rooted by" evidence (design doc point 2): a stack-local or +// JNI-local reference is only alive for as long as its owning frame/handle +// scope is on some thread's stack, so an entry admitted through one is +// always a candidate both for a durability upgrade (rootKindDurability() +// above) and for the softer output label (flightRecorder.cpp's +// rootKindName()) and for Phase 5's bounded rotating re-expansion +// (ReferenceChainTracker::collectStaleRootKindEntriesForRotation()). +inline bool isTransientRootKind(u8 root_kind) { + return root_kind == JVMTI_HEAP_REFERENCE_STACK_LOCAL || + root_kind == JVMTI_HEAP_REFERENCE_JNI_LOCAL; +} + +// Tag-indexed slot table storing FrontierEntry metadata, modeled on +// LivenessTracker's TrackingEntry table (livenessTracker.h:21-30): CAS-safe +// doubling resize under a signal-safe SpinLock (spinLock.h), reusing its +// shared/exclusive split so reads (lookup) never race a resize. +// +// Structural difference from LivenessTracker's table: the slot index is the +// JVMTI tag value itself (tag - 1), not an externally-assigned array +// position. This works because ReferenceChainTracker::nextTag() hands out +// tags sequentially starting at 1 and never reuses one, so each +// tag maps to exactly one slot for the table's lifetime. +// +// Capacity is an explicit constructor parameter (wired from +// Arguments::_reference_chains_frontier_cap), not derived from heap +// size the way LivenessTracker sizes its table (livenessTracker.cpp:152-176) +// - the design doc explicitly flags that sizing formula as non-transferable +// to a BFS frontier (Open Question 2: frontier width is driven by per-hop +// fan-out, not an allocation sampling rate). Only the doubling-resize +// *mechanics* are reused from LivenessTracker, not its sizing heuristic. +// +// Concurrency: unlike LivenessTracker::track() (called from the allocation +// sampling hot path, which must never block), FrontierTable::insert()/ +// clear()/markEdge()/markExpanded() are only ever called from the single +// agent-owned BFS thread (design doc's Algorithm; the heap-walk engine), so +// they use the blocking exclusive lock() rather than LivenessTracker's +// non-blocking tryLockShared() bailout - exclusive, not shared, so a writer +// actually excludes a concurrent lookup() reader instead of merely +// serializing against other writers. lookup() may still be called +// concurrently from a reader walking parent_tag links (e.g. chain +// reconstruction), hence the shared lock there: shared mode only ever +// contends with other shared-mode readers, never with a writer's exclusive +// lock. +class alignas(alignof(SpinLock)) FrontierTable { +private: + // Provisional default pending empirical tuning (see + // doc/architecture/LiveHeapReferenceChains-ImplementationPlan.md) - not + // benchmark-derived. Reuses LivenessTracker's doubling-resize *mechanics* + // (growLocked() below), but this starting size is a conservative guess, + // not scaled from LivenessTracker's own initial size (which that class + // derives from max_heap/sampling_interval, a formula the design doc + // explicitly flags as non-transferable to a BFS frontier - see + // arguments.h's DEFAULT_REFERENCE_CHAINS_FRONTIER_CAP comment). Small + // enough to avoid over-allocating for a search that never grows a wide + // frontier, large enough to avoid the first several growLocked() calls + // for an ordinary one; a future frontier-table peak-occupancy + // measurement pass is the intended way to replace this guess. + static constexpr int INITIAL_TABLE_CAPACITY = 1024; + + // mutable: capacity()/maxCapacity() below are const accessors that still + // need to take this lock to read _table_cap/_table_max_cap safely. + mutable SpinLock _table_lock; + // 1 + highest index ever inserted (informational upper bound for + // lookup(); never shrinks, since tags/slots are never reused). atomic + // (not volatile) because insert() updates it via a CAS loop concurrently + // with plain reads from size()/resetForRestart() - a volatile int mixed + // with __sync_bool_compare_and_swap has no synchronizes-with edge under + // the C++ memory model, so those plain reads and the CAS are a genuine + // data race (caught by TSAN), even though relaxed/informational + // semantics are all that's needed here. + std::atomic _table_size; + int _table_cap; + int _table_max_cap; + FrontierEntry *_table; + + // Grows _table (doubling) until it holds at least `required_cap` slots or + // _table_max_cap is reached. Must be called with _table_lock held + // exclusively. Returns false (capacity exhausted) without partially + // resizing if `required_cap` exceeds _table_max_cap. + bool growLocked(int required_cap); + +public: + // `max_cap` <= 0 disables the table (capacity() stays 0, every insert() + // reports exhaustion) - callers are expected to guard on the config flag + // before constructing one, but this makes a misconfigured cap fail safe + // rather than crash. + explicit FrontierTable(int max_cap); + ~FrontierTable(); + + FrontierTable(const FrontierTable &) = delete; + FrontierTable &operator=(const FrontierTable &) = delete; + + // Writes (parent_tag, referrer_klass, depth, state) into the slot for + // `tag` (index = tag - 1), growing the table if needed. Returns false + // without writing anything if `tag` is not positive, or the table is + // already at max_cap and still too small for this tag - the design doc's + // frontier-size-cap requirement is "stop admitting new entries and report + // it", so this reports failure to the caller rather than crashing or + // silently dropping the write. + // `root_kind` is the jvmtiHeapReferenceKind of the admitting edge - only + // meaningful when `parent_tag == 0` (see FrontierEntry::root_kind's own + // comment); callers that are not admitting a root-attached entry can + // leave it at the default 0. + bool insert(jlong tag, jlong parent_tag, u32 referrer_klass, u32 depth, + u8 state = FrontierEntryState::FRONTIER, u8 root_kind = 0); + + // Reads the slot for `tag` into *out. Returns false (leaving *out + // untouched) if `tag` is not positive or has never been inserted. + bool lookup(jlong tag, FrontierEntry *out); + + // Runs `fn(this)` with the shared lock held for the whole call, for a + // caller that needs to look up many tags back to back (e.g. the rotation + // collectors' O(size()) sweeps in referenceChains.cpp) under ONE lock + // acquisition, instead of paying SpinLock's lock/unlock cost on every + // single lookup() call. RAII (SharedLockGuard, spinLock.h) releases the + // lock on every exit path from `fn`, including an early return - unlike a + // manual lockShared()/unlockShared() pair, a `fn` that returns early can't + // leak the lock. `fn` should only call lookupLocked() on this table, never + // another FrontierTable method that tries to take the lock again. + template void withSharedLock(Fn &&fn) const { + SharedLockGuard guard(&_table_lock); + fn(this); + } + + // Same as lookup() above, but assumes the caller already holds the shared + // lock via withSharedLock() below. + bool lookupLocked(jlong tag, FrontierEntry *out) const; + + // Marks the slot for `tag` as ABANDONED in place. This is only the + // metadata-table side of tag release (design doc's Termination section); + // the caller is still responsible for SetTag(obj, 0) via + // ReferenceChainTracker::clearTag() - clear() here does not touch JVMTI. + // No-op if `tag` was never inserted. + void clear(jlong tag); + + // Marks the slot for `tag` as EDGE in place (design doc: "on a path + // toward a target sample (EdgeStore)"). No-op if `tag` was never + // inserted. Used by reconstructChain() below to mark every hop it walks. + void markEdge(jlong tag); + + // Marks the slot for `tag` as EXPANDED in place (design doc: "expanded; + // children (if any) are in the table") - the resumed-pass counterpart to + // markEdge(): ReferenceChainTracker::expandFrontier() calls this + // once an entry's own outgoing edges have been fully visited by a + // FollowReferences(initial_object=) call, so a later + // pass's scan for pending work (which only considers FRONTIER-state + // entries) skips it. No-op if `tag` was never inserted. + void markExpanded(jlong tag); + + // Overwrites the slot for `tag`'s root_kind in place, touching no other + // field - the durability-upgrade counterpart to insert()'s one-time + // root_kind write (design doc's "opportunistic upgrade during root + // re-enumeration", Phase 5 item 1). No-op if `tag` was never inserted. + // + // Callers MUST only invoke this when the update itself originates from a + // root discovery (a root callback rediscovering an already-tagged object + // as a heap root), never from an ordinary edge admission/re-expansion - + // and only on an entry that is already root-attached (parent_tag == 0). + // FrontierEntry::root_kind is documented as meaningful only when + // parent_tag == 0; this mutator does not itself touch parent_tag, so + // calling it from a non-root discovery context (e.g. an + // edge-driven re-expansion rediscovering an edge to an already-tracked, + // non-root-attached object) would silently leave a non-zero root_kind on + // an entry nothing else treats as root-attached. See + // ReferenceChainTracker::maybeUpgradeRootAttachedRootKind() (the sole + // caller) for how this is enforced. + void updateRootKind(jlong tag, u8 root_kind); + + // Walks parent_tag links starting at `target_tag` back to a root-attached + // entry (parent_tag == 0), appending each visited entry's referrer_klass + // to *out_chain in leaf-to-root order, and marking each visited entry + // EDGE via markEdge() - this table's degenerate EdgeStore (design doc: + // "a chain can be walked back from a target sample to a root by + // following parent_tag across EdgeStore records"). Returns false (leaving + // *out_chain untouched) if target_tag was never inserted. Bounds the walk + // at maxCapacity() hops as a defensive guard against a corrupted/cyclic + // parent_tag chain - nextTag() only ever hands out a strictly larger value + // than any tag already assigned (true across resumed passes too, not just + // within one), so a child's parent_tag always points at an + // already-existing, strictly smaller tag and a cycle should be + // unreachable in practice; this is not a correctness dependency. + // + // `out_root_kind` (if non-null) receives the root-attached entry's own + // FrontierEntry::root_kind - the jvmtiHeapReferenceKind of whichever edge + // first admitted this chain into the frontier, letting a caller label the + // chain with why it is reachable at all (JNI global, thread stack, static + // field, ...) instead of just how (the referrer_klass hops in *out_chain). + bool reconstructChain(jlong target_tag, std::vector *out_chain, + u8 *out_root_kind = nullptr); + + // Search restart (ReferenceChainTracker::restartSearch(), this class's own + // header comment): marks every slot unoccupied again without releasing + // _table's allocation - a new search's nextTag() sequence restarts at 1, + // reusing these same slot indices, so lookup()/insert() must not read back + // the previous search's now-irrelevant entries for them. Safe to call + // only once releaseSearchTags() has already cleared every live JVMTI tag + // this search owned (restartSearch()'s own caller ordering) - this method + // has no way to release tags itself, it only forgets the metadata table's + // record of them. + void resetForRestart() { + _table_lock.lock(); + _table_size.store(0, std::memory_order_relaxed); + _table_lock.unlock(); + } + + // Debug-only test seam (ReferenceChainTracker::resetSearchStateForTest()). + // Unlike resetForRestart(), which only forgets this table's occupancy, + // this discards the table's whole allocation and rebuilds it at + // `max_cap` - the only way to undo the "sized once, on the first start() + // in this JVM" capacity choice (this class's own constructor comment) + // that a differently-configured test running earlier in the same, + // no-forkEvery JVM (ProfilerTestPlugin.kt) would otherwise leave every + // later test permanently stuck with. Defined in referenceChains.cpp + // alongside the constructor it mirrors. + void resetCapacityForTest(int max_cap); + + // _table_cap/_table_max_cap are plain ints, not atomics like _table_size, + // and resetCapacityForTest() (debug-only test seam, see its own comment) + // rewrites both under _table_lock after freeing/reallocating _table. Every + // other reader of these fields (growLocked() and its callers) already + // holds _table_lock; these two accessors take it too so a concurrent + // resetCapacityForTest() during shared-JVM test overlap can't race an + // unsynchronized read here. + int capacity() const { + _table_lock.lock(); + int cap = _table_cap; + _table_lock.unlock(); + return cap; + } + int maxCapacity() const { + _table_lock.lock(); + int max_cap = _table_max_cap; + _table_lock.unlock(); + return max_cap; + } + + // Current upper bound on assigned slots (mirrors _table_size's own + // comment: "1 + highest index ever inserted"). expandFrontier() + // uses this to know how far a resumed pass's scan for FRONTIER-state + // entries needs to go. Relaxed/informational like _table_size itself: a + // concurrent insert() racing this read only makes the caller's scan + // window one tag short for this call, which self-corrects on the next + // call once _table_size has caught up. + int size() const { return _table_size.load(std::memory_order_relaxed); } +}; + +// Tag-indexed table mapping a *class* tag (see +// ReferenceChainTracker::nextClassTag() - always negative, a namespace +// disjoint from the positive FrontierTable object tags above so a raw tag +// value alone always tells the heap-walk callback which table it belongs +// to) to the StringDictionary id of that class's resolved name +// (Profiler::classMap(), the same interning table LivenessTracker uses via +// Profiler::lookupClass(), livenessTracker.cpp:120-122 - see Open Item 2 in +// the implementation plan). +// +// Populated once per loaded class by +// ReferenceChainTracker::resolveLoadedClasses() - a GetLoadedClasses() + +// GetClassSignature() pass run *before* FollowReferences starts, specifically +// so heapReferenceCallback() (referenceChains.cpp) never needs a class-name +// lookup of its own: GetClassSignature is a JNI/Class-category call, and the +// JVMTI spec forbids Heap-callback functions like heapReferenceCallback from +// calling anything but "callback safe" functions (see the header comment +// above) - resolving names inline inside the callback is not an option. +// +// Concurrency: like FrontierTable, only ever touched by the single +// agent-owned BFS thread (design doc's Algorithm "Thread" bullet), so no locking is +// needed - unlike FrontierTable there is also no cross-thread reader to +// guard against (chain reconstruction only needs FrontierTable). +class ClassTagTable { +private: + std::unordered_map _table; + +public: + void insert(jlong class_tag, u32 dict_id) { _table[class_tag] = dict_id; } + + // Returns the StringDictionary id for `class_tag`, or 0 if it was never + // inserted (0 is StringDictionary's own "no entry" sentinel too, so this + // composes with FrontierEntry::referrer_klass's documented 0 = + // unresolved/none convention without a separate "found" out-parameter). + u32 resolve(jlong class_tag) const { + auto it = _table.find(class_tag); + return it != _table.end() ? it->second : 0; + } + + size_t size() const { return _table.size(); } + + // Drops every cached class_tag -> dict_id mapping - used when the + // underlying StringDictionary itself was reset (see + // ReferenceChainTracker::_last_class_map_generation's comment) and every + // id here now points at a namespace that no longer exists. + void clear() { _table.clear(); } +}; + +// Singleton shape mirrors LivenessTracker (livenessTracker.h). +class ReferenceChainTracker { + // Test-only accessor (referenceChains_ut.cpp), mirroring vmEntry.h's + // VMTestAccessor pattern: since instance() is a process-wide singleton, + // the search-lifecycle fields (_search_state/_search_started/etc.) + // would otherwise leak across separate TEST_F cases in the same gtest + // binary. The accessor resets them back to their just-constructed values + // between tests; it does not change any production behavior. + friend class ReferenceChainsTestAccessor; + +private: + bool _enabled; + + // Frontier metadata table. Constructed lazily on the first + // start() with the flag enabled, sized from + // args._reference_chains_frontier_cap; like LivenessTracker's table + // (livenessTracker.cpp:209-210) it survives stop() so it persists across + // multiple start/stop recording cycles. + FrontierTable *_frontier; + + // args._reference_chains_frontier_cap as of the most recent start() call - + // recorded unconditionally (even once _frontier already exists and start() + // itself skips reconstructing it), so resetSearchStateForTest() has + // something to rebuild the table at other than whatever cap the first + // start() in this JVM happened to use (see _frontier's own comment). + int _configured_frontier_cap; + + // Class-tag -> StringDictionary id table. Populated by + // resolveLoadedClasses(), read by heapReferenceCallback(). Survives + // stop()/start() cycles for the same reason _frontier does - a class, + // once resolved, does not need re-resolving just because the profiler + // recording was restarted - UNLESS the underlying dictionary itself was + // reset (see _last_class_map_generation below), in which case every id + // cached here is for an id namespace that no longer exists. + ClassTagTable _class_tags; + + // Profiler::classMap()'s generation as of the last resolveLoadedClasses() + // call. Profiler::start() calls _class_map.clearAll() (profiler.cpp) + // whenever `reset || _start_time == 0`, which restarts that + // StringDictionary's id namespace at 1 - but a class's JVMTI-level + // class-object tag (GetTag(klass, ...)) is JVM-level state, untouched by + // that reset, so resolveLoadedClasses()'s "already tagged -> already + // resolved, skip it" check (tag == 0) would otherwise keep _class_tags + // pointing at ids from a dictionary generation that clearAll() already + // wiped. resolveLoadedClasses() compares this against + // Profiler::instance()->classMap()->generation() and, on a mismatch, + // re-resolves every loaded class's name (reusing its existing tag rather + // than assigning a new one) instead of only the untagged ones - see that + // method's own comment. Initialized to 0 (StringDictionary's own initial + // generation), not a sentinel, since a resolveLoadedClasses() call before + // any clearAll() has ever run must NOT treat that as a mismatch. + u64 _last_class_map_generation; + + // GetLoadedClasses() count as of the last resolveLoadedClasses() call that + // actually ran its per-class GetTag()/GetClassSignature() scan - lets that + // method skip the scan entirely on a resumed pass where the loaded-class + // count has not CHANGED (see resolveLoadedClasses()'s own comment for why + // this must be an equality check, not just a "grew" check: the count is + // not monotonic once class unloading is in play). Survives stop()/start() + // cycles for the same reason _class_tags does. Written and read only from + // the single BFS thread, like _last_pass_gc_finish_epoch. Forced to -1 + // (a value class_count, always >= 0, can never equal) by a + // _last_class_map_generation mismatch, so the scan is never skipped on the + // very call that must re-resolve every already-tagged class. + int _last_resolved_class_count; + + // GetLoadedClasses() count as of the last runPassManualWalk() call whose + // admitStaticFieldRoots() sweep actually ran (i.e. was not skipped by the + // guard below) AND completed without being truncated. Distinct from + // _last_resolved_class_count even though both are populated from the same + // GetLoadedClasses() count: resolveLoadedClasses() runs once per runPass() + // unconditionally (it is cheap to skip its own per-class scan once + // unchanged), whereas admitStaticFieldRoots() re-walks EVERY loaded class + // via FollowReferences - a stop-the-world HeapWalkOperation - so + // runPassManualWalk() only calls it at all when this differs from + // resolveLoadedClasses()'s freshly-observed _last_resolved_class_count, + // i.e. only when the loaded-class set has actually changed since the last + // completed sweep. Left unset (mismatched) on a truncated sweep so the + // next pass retries rather than silently treating a still-incomplete sweep + // as done. Initialized to -1 (a value class_count, always >= 0, can never + // equal) so the very first pass always runs the sweep once. + int _last_static_field_class_count; + + // "GC just happened" signals. Bumped only from onGCStart()/onGCFinish(); + // gcFinishEpoch() is now read by shouldRunPass() as one of the + // two pass-scheduling triggers (design doc's Triggering section). + volatile u64 _gc_start_epoch; + volatile u64 _gc_finish_epoch; + + // Monotonically increasing tag source for frontier objects. 0 is reserved + // (JVMTI convention: an untagged object reads back tag 0, and + // SetTag(obj, 0) clears a tag), so this starts at 1. Always hands out + // positive values - see nextClassTag() below for why classes use a + // disjoint (negative) range instead of sharing this counter. + volatile jlong _next_tag; + + // Monotonically increasing *magnitude* source for class tags; nextClassTag() + // negates it before handing it out. Kept separate from _next_tag (rather + // than tagging classes from the same positive sequence) so + // heapReferenceCallback() can tell "is this tag a class I pre-tagged, or a + // frontier object?" from the tag's sign alone, with no extra table lookup - + // load-bearing for the "never expand from a class's own metadata graph" + // rule documented on heapReferenceCallback() below. + volatile jlong _next_class_tag_magnitude; + + // Per-pass tunables, copied from Arguments in start() (design doc: Open + // Question 2 defaults, from the config-flag scaffolding). A future + // measurement pass will decide whether/how these can change between passes + // of the same search; for now this only needs one fixed value per + // start()/stop() cycle, exactly like LivenessTracker's _subsample_ratio + // (livenessTracker.h:52). + int _hop_cap; + int _budget; + + // Edge budget for just the search's one-shot, root-seeded first pass + // (runPass()'s !_search_started branch) - copied from + // Arguments::_reference_chains_first_pass_budget in start(), auto-scaled + // from _budget (AUTO_FIRST_PASS_BUDGET_MULTIPLIER, capped at + // AUTO_FIRST_PASS_BUDGET_CAP) when unset (0), rather than falling back to + // plain _budget: a steady-state per-pass budget sized for cheap incremental + // expansion truncates a cold root-seeded walk of a real JVM's object graph + // long before it reaches anything interesting (see + // ddprof-stresstest's ReferenceChainLeakDemo, whose whole class comment is + // about exactly this trap). Only the first pass's own edge budget is this + // large - runPassManualWalk()'s IterateOverReachableObjects root/stack-ref + // enumeration itself reruns on every pass, first or resumed (see its own + // comment); already-admitted roots short-circuit cheaply via + // admitObject()'s ALREADY_ADMITTED check, so a root this pass doesn't + // reach before the budget runs out is still picked up by a later pass, not + // permanently lost. Unlike _budget/_effective_budget, this is spent at + // most once per search, not once per pass, so a much larger ceiling is + // affordable without the per-pass pacing controller (updatePacing()) ever + // seeing it - runPass() deliberately excludes the first pass's own + // duration from that signal (see runPass()'s own comment) so a large + // first-pass cost cannot throttle down every cheap expansion pass that + // follows. + int _first_pass_budget; + + // Wall-clock TTL, copied from Arguments in start() (design doc's + // Termination section: "a hard cap on passes-per-search or wall-clock TTL + // from first observation"). This implements the TTL half of that + // "or" - the config-flag scaffolding only added a TTL sub-option (no + // separate pass-count cap), and Open Question 2 leaves the choice between + // the two open pending a future measurement pass. <= 0 disables the TTL + // cutoff (a search can only still end via the frontier-size cap or natural + // completion). + long _ttl_ms; + + // Pause-time pacing controller: pause-time-SLO ceiling copied from + // Arguments in start() (Arguments::_reference_chains_pause_target_ms) - + // the "single target ceiling" the plan asks for in place of guessing + // _budget/PASS_CADENCE_NS directly. Used only to (re)construct _pause_pid + // in start(); updatePacing() itself never reads it again, since it lives + // inside _pause_pid's own _target once constructed. + long _pause_target_ms; + + // Pause-time pacing controller: the actual per-pass budget runPass() passes + // to FollowReferences/expandFrontier(), replacing _budget's old role as a + // literal per-pass value - _budget above becomes this controller's ceiling + // instead (never exceeded, see updatePacing()), while this field is what + // updatePacing() actually raises/lowers pass to pass. Starts at _budget in + // start(), so a tracker that has not measured a pass yet behaves exactly as + // before the pacing controller was added. + int _effective_budget; + + // Pause-time pacing controller: the actual fallback cadence + // shouldRunPass()/threadLoop() compare against, replacing the fixed + // PASS_CADENCE_NS constant below in that role once updatePacing() starts + // adjusting it - see PASS_CADENCE_NS's own comment for why that constant + // survives as this field's starting value rather than being deleted + // outright. + u64 _effective_cadence_ns; + + // Budget-borrowing: extra headroom updatePacing() has temporarily granted + // above _budget's own ceiling, earned by a sustained run of comfortably- + // under-target passes (see BORROW_WARMUP_PASSES's own comment). This is + // the one exception to "_budget is never exceeded" (_effective_budget's + // own comment) - it exists because a fast-growing frontier (a real + // leaking-cache workload, not just a synthetic one) can otherwise starve + // under a steady-state budget sized for ordinary incremental expansion, + // never converging within this search's TTL even though pause time has + // visible headroom to spare. Revoked immediately (reset to 0, see + // updatePacing()) the moment a pass is no longer comfortably under target, + // so a search that starts abusing its pause-time budget loses the + // borrowed headroom before the very next pass - _budget itself remains the + // hard ceiling in that case, same as before this field existed. + int64_t _borrowed_budget; + + // Budget-borrowing: number of consecutive passes (since the last reset) + // that came in comfortably under _pause_target_ms (see + // BORROW_UNDER_TARGET_FRACTION). Reset to 0 the moment a pass does not + // qualify - see _borrowed_budget's own comment on why this must be a + // consecutive-run counter, not a cumulative one: a single expensive pass + // means the frontier is not, in fact, converging with room to spare, and + // borrowing more budget for the next pass on the strength of an unrelated + // earlier streak would defeat the point of gating growth on *sustained* + // headroom at all. + int _consecutive_under_target_passes; + + // Pause-time pacing controller: this tracker's own PidController instance - + // see updatePacing() + // below for the full mechanism, and PASS_CADENCE_NS's neighboring + // constants for why its gains are not copied from ObjectSampler/ + // MallocTracer/NativeSocketSampler's shared triple. Placeholder-constructed + // here (target=1, unit gains); start() reconstructs it once + // _pause_target_ms is known, mirroring RateLimiter's own + // placeholder-then-reconstruct pattern (rateLimiter.h's + // `_pid{1, 1.0, 1.0, 1.0, 1, 1.0}` member default, replaced in + // RateLimiter::start()). + PidController _pause_pid; + + // Search lifecycle state. _search_started distinguishes a search's first + // pass (seed FollowReferences from the heap roots) from a resumed pass + // (expand the persisted frontier, see expandFrontier()) - runPass() below. + // _search_state starts RUNNING and only ever moves forward (RUNNING -> + // COMPLETED or RUNNING -> ABANDONED, never back) - see runPass()'s comment + // for the exact conditions. Both fields are written only by runPass(), + // called from the single agent-owned BFS thread, but are read cross-thread + // by searchState()/buildAbandonedEvent() (called from Profiler::dump(), + // e.g. profiler.cpp's JFR-flush path) - so, like _gc_start_epoch/ + // _gc_finish_epoch above, they are volatile and accessed via load()/ + // store() rather than a plain load/store the compiler could reorder or + // cache across threads. + bool _search_started; + volatile u8 _search_state; + + // True once releaseSearchTags() has confirmed every live tag this search + // owned was actually cleared (or there were none) - see that method's own + // comment for why a GetObjectsWithTags() failure must NOT be treated as + // "released". Starts true (nothing to release for a not-yet-run search); + // set false the moment a search reaches a terminal state and is only ever + // reset back to true once releaseSearchTags() itself confirms success - + // possibly across several retried runPass() calls first, see runPass()'s + // terminal-state branch. shouldRunPass() refuses to restartSearch() while + // this is false, so _next_tag/the frontier table are never reset out from + // under a search whose tags might still be live. Written and read only + // from the single BFS thread (runPass()/shouldRunPass()), like + // _search_started above, so no volatile/load()/store() is needed. + bool _tags_released; + + // Set (once) at the same point runPass() moves _search_state to ABANDONED - + // see SearchAbandonReason's own comment for why this exists and + // buildAbandonedEvent()/abandonReason() below for how it is read. Same + // cross-thread read pattern as _search_state above. + volatile u8 _abandon_reason; + + // Wall-clock timestamp (OS::nanotime()) of the search's first pass - + // baseline for the TTL cutoff above. Set once, in runPass(), the first + // time _search_started flips true; read cross-thread by + // buildAbandonedEvent() (elapsed-time calculation), so volatile/load()- + // accessed like _search_state above. + volatile u64 _search_start_ns; + + // Tags currently in FrontierEntryState::FRONTIER (admitted but not yet + // expanded), in admission order. Pushed by heapReferenceCallback() at the + // moment it admits a tag (both for the first pass's root-seeded walk and + // for expandFrontier()'s own per-node FollowReferences calls, since both + // share that one callback), popped by expandFrontier()/ + // markAllFrontierExpanded() as entries are expanded. This replaces a + // former O(range) scan over every tag between a cursor and the frontier's + // current size just to filter down to the FRONTIER-state subset - a scan + // whose cost was proportional to everything admitted since the cursor + // last advanced, not to what was actually pending, so a pass immediately + // following a large one-shot admission (e.g. a restart's first pass) paid + // for the whole batch just to discover a handful of genuinely pending + // entries. Only ever touched from the single BFS thread that runs + // heapReferenceCallback()/expandFrontier(), so no locking is needed. + std::deque _pending_expand; + + // Fast-lane counterpart to _pending_expand above: entries admitted while + // re-walking a rotation-selected (already-EXPANDED) parent go here + // instead, and expandFrontier() drains this queue ahead of the ordinary + // one. Without this, a mutable field re-observed via rotation (e.g. + // HashMap.table after a resize) admits a fresh child that then has to + // travel through however much of the ordinary backlog is still ahead of + // it - under a fast-growing leak that backlog can be tens of thousands of + // entries deep, so the re-admitted chain would never visibly progress + // within any reasonable search window. Same single-BFS-thread-only + // access as _pending_expand, no locking needed. + std::deque _priority_expand; + + // java/lang/Object jclass cache for expandFrontier()'s frontier-holder + // array element type (referenceChains.cpp) - resolved via FindClass() once + // per attached JNIEnv and reused across every subsequent expandFrontier() + // call on that same attach, instead of re-resolving it on every BFS pass. + // _cached_object_class_jni records which JNIEnv the cached local ref + // belongs to, so a fresh attach (a new JNIEnv*) invalidates the cache + // rather than reusing a local ref from a different (and possibly already + // detached) JNI attach. Only ever touched from the single BFS thread that + // calls expandFrontier(), so no locking is needed - same as + // _pending_expand above. + jclass _cached_object_class = nullptr; + JNIEnv *_cached_object_class_jni = nullptr; + + // Rotation cursor for collectStaleRootKindEntriesForRotation() (Phase 5 + // item 3): 1-based tag to resume scanning from on the next call, so + // consecutive calls sweep forward through the table instead of always + // re-examining the same low-tag entries first. Wraps back to 1 once it + // reaches _frontier->size(). Persisted across passes (not per-search-reset + // by ReferenceChainsTestAccessor::reset(), same as _next_tag is not reset + // by restartSearch() logic elsewhere) since a stale cursor value only ever + // costs one wasted scan step before self-correcting, never a correctness + // problem. + jlong _root_kind_rotation_cursor; + + // Per-pass cap on how many transient-root_kind entries + // collectStaleRootKindEntriesForRotation() selects - round, provisional + // like this subsystem's other unbenchmarked constants (see e.g. + // MIN_EFFECTIVE_BUDGET's own comment): small enough that a pass dominated + // by rotation work never meaningfully competes with genuinely new + // discoveries for the same pass's budget, large enough that a search with + // a modest number of transient roots converges to durable attribution + // within a handful of passes rather than needing hundreds. + static constexpr int ROOT_KIND_ROTATION_BUDGET = 16; + + // Per-pass cap on how many EXPANDED entries + // collectStaleExpandedEntriesForRotation() re-queues for expansion. Larger + // than ROOT_KIND_ROTATION_BUDGET above: a re-queued entry's own freshly + // admitted children still have to travel through the ordinary FIFO + // backlog before they themselves get expanded, so under a + // fast-growing/deep leak this needs enough budget for that propagation to + // make visible progress within a search's polling window, not just for + // the re-queue step itself. + static constexpr int STALE_EXPANDED_ROTATION_BUDGET = 256; + + // Upper bound on the slice of each pass's edge budget carved out for + // rotation before ordinary root-enum/static-field/expand work gets to + // spend any of it. Under a sustained, fast-growing backlog (e.g. an + // unbounded cache leak) ordinary expansion truncates on almost every pass, + // and without a reservation rotation would then be handed a budget of 0 + // every single time - never actually running despite the earlier + // truncated-pass check now letting it through. Sized to cover both + // rotation calls' full per-pass caps (ROOT_KIND_ROTATION_BUDGET + + // STALE_EXPANDED_ROTATION_BUDGET) whenever expand_budget is large enough + // to afford that; runPassManualWalk() additionally caps the actual + // reservation at half of expand_budget (see its own comment) so the + // reverse failure mode - rotation swallowing an already-throttled pass's + // entire budget and starving ordinary expansion of everything - cannot + // happen either. + static constexpr int ROTATION_RESERVED_BUDGET = + ROOT_KIND_ROTATION_BUDGET + STALE_EXPANDED_ROTATION_BUDGET; + + // Snapshot of gcFinishEpoch() as of the end of the last pass. Written only + // by runPass(), read only by shouldRunPass() - both always called from the + // same thread (the BFS thread once wired up, or directly by a caller/test + // standing in for it), so no locking is needed. + u64 _last_pass_gc_finish_epoch; + + // OS::nanotime() as of the end of the last pass. Written by runPass() on + // the BFS thread; also read cross-thread by buildAbandonedEvent()'s + // elapsed-time calculation, so volatile/load()-accessed like + // _search_state above. + volatile u64 _last_pass_ns; + + // Total passes run this search. Written by runPass() on the BFS thread; + // read cross-thread by passesRun()/buildAbandonedEvent(), so volatile/ + // load()-accessed like _search_state above. + volatile int _passes_run; + + // Resolved reference chains, keyed by the leak-candidate klass_id + // pollWatchedTargets() reconstructed each one for. An entry lives here for + // as long as LivenessTracker can still resolve a representative for its + // klass_id: pollWatchedTargets() prunes it the moment the representative + // stops resolving (collected, or LRU-evicted from the population table). + // Every Profiler::dump() re-emits the whole cache (drainPendingChainEvents() + // snapshots without clearing), so a datadog.ReferenceChain lands in every + // JFR chunk the sample survives into - mirroring how LivenessTracker + // re-emits its live-object samples on each flush, rather than the emit-once + // model where a chain reached only the single transient dump that drained + // it. klass_id is the identity that survives a search restart (which resets + // FrontierTable tags); CachedChain remembers the tag and search generation + // the chain was reconstructed from, so a poll after a restart re-tags the + // object rebuilds the chain instead of trusting a tag value the reset has + // since reassigned to a different object. + struct CachedChain { + ReferenceChainEvent event; + jlong source_tag; + u64 source_search_ns; + }; + // Bounded like the queue it replaces: distinct leak klasses are already + // capped by LivenessTracker's own MAX_KLASS_POPULATION_ENTRIES (256), and + // pruning drops dead ones, so the eviction path below should never be + // reached under an ordinary dump cadence - but a new klass arriving while + // the cache is full drops (counted via REFERENCE_CHAIN_EVENTS_DROPPED) + // rather than growing without bound. + static constexpr int MAX_RESOLVED_CHAINS = 256; + // Keyed by klass_id, not by object/sample identity: at most one + // representative CachedChain is kept per leak-candidate klass. A newly + // resolved chain for a klass_id already present overwrites the existing + // entry rather than being added alongside it, so multiple live samples of + // the same klass collapse onto whichever one was most recently resolved. + std::unordered_map _resolved_chains; + SpinLock _resolved_chains_lock; + + // Search restart (this class's own header comment): leaky bucket over the + // wall-clock cost of past searches, gating how soon a *restarted* search + // may take its first pass - see PainBudget's own comment (painBudget.h) + // and canAffordNewSearch() below. _search_pain_ms accumulates the current + // search's own cost (each pass's pass_wall_ticks, converted to ms) as it + // runs; restartSearch() spends the total into _pain_budget and zeroes this + // back out for the next search. Constructed with the configured refill + // rate in start(), mirroring _pause_pid's own placeholder-then-reconstruct + // pattern. + PainBudget _pain_budget; + u64 _search_pain_ms; + + // The cache above is mutated on this tracker's own BFS scheduling thread + // (pollWatchedTargets()) and read on whatever thread calls Profiler::dump() + // (drainPendingChainEvents()); _resolved_chains_lock (declared with the + // cache) is the only synchronization between them. The write itself is + // still deferred to the dump() thread - Profiler::writeReferenceChain() + // (profiler.cpp) can block up to ~50ms per event under _locks[] contention, + // which must never delay the next scheduled BFS pass - exactly mirroring + // how buildAbandonedEvent()'s output is deferred to that same call site + // rather than written eagerly. + + // Fallback cadence for shouldRunPass()'s cadence trigger (design doc's + // Triggering section / Open Question 5). Provisional default pending + // empirical tuning (see + // doc/architecture/LiveHeapReferenceChains-ImplementationPlan.md) - not + // benchmark-derived: a round one-second value chosen only so an idle + // search still makes some progress between GC-triggered wakeups without + // polling so tightly that an idle tracker burns CPU. The pause-time pacing + // controller folds Open Question 5's cadence decision into updatePacing() + // below rather than solving it separately (design doc's explicit "one + // shared mechanism" framing): this constant now only serves as + // _effective_cadence_ns's starting value (start()) and as the unit + // MAX_EFFECTIVE_CADENCE_NS below scales from - shouldRunPass()/threadLoop() + // themselves compare against _effective_cadence_ns, not this constant + // directly, once a pass has run. + static constexpr u64 PASS_CADENCE_NS = 1000000000ULL; // 1s + + // Auto-scaled default for _first_pass_budget when + // Arguments::_reference_chains_first_pass_budget is unset (0) - see + // _first_pass_budget's own comment for why plain _budget is the wrong + // fallback. 50x is a round, provisional guess (same status as every other + // _reference_chains* constant here), picked to comfortably clear a cold + // JVM's root set without needing firstpassbudget spelled out explicitly for + // every reasonably-sized heap; the cap keeps a pathologically large + // _budget (e.g. an operator-supplied 100000) from ballooning the first + // pass's own one-shot cost unbounded. + static constexpr int AUTO_FIRST_PASS_BUDGET_MULTIPLIER = 50; + static constexpr int AUTO_FIRST_PASS_BUDGET_CAP = 200000; + + // Minimum wall-clock gap between root/stack-ref enumeration attempts + // (runPassManualWalk()'s IterateOverReachableObjects call) after the + // search's own first pass. That call re-walks every live GC root and + // stack/JNI-local on every attempt regardless of budget (see its own + // comment) - a fixed tax independent of how much of it is new. Retrying it + // on every cheap steady-state tick (PASS_CADENCE_NS once relaxed) pays that + // tax far more often than it buys new admissions, which two live + // experiments confirmed nets LESS total progress than one large, + // infrequent attempt (each retry given _first_pass_budget-sized headroom - + // see _last_root_enum_ns's own comment). Seconds, not milliseconds: large + // enough that most ticks take the cheap expandFrontier()-only path, small + // enough that a ~20s search window still gets several independent attempts + // at whatever root JVMTI's enumeration order didn't reach the first time. + // Round, provisional guess, like this subsystem's other unbenchmarked + // constants. + static constexpr u64 ROOT_ENUM_MIN_INTERVAL_NS = 2000000000ULL; // 2s + + // Pause-time pacing controller: bounds and conversion constants for + // updatePacing()'s budget/cadence adjustment - see that method's own + // comment for the full mechanism. Every value here is a round, provisional + // guess like every other _reference_chains* constant in this codebase + // (arguments.h's own DEFAULT_REFERENCE_CHAINS_* header comment sets the + // pattern) - a future benchmark plan is the intended path to replacing + // all of them with measured values, not a design decision made here. + // + // Floor updatePacing() will never shrink _effective_budget below (clamped + // further down to _budget itself when the configured budget is smaller + // than this floor - see updatePacing()). Not 0: a floor of 0 would let a + // single pathological pass shrink the search to "admit nothing, ever", + // stalling all progress instead of just slowing it. + static constexpr int MIN_EFFECTIVE_BUDGET = 50; + + // Bounds for _effective_cadence_ns. The lower bound is not 0: threadLoop() + // sleeps for exactly this many nanoseconds each loop iteration (below), so + // a true 0 would busy-loop the BFS thread. The upper bound reuses + // PASS_CADENCE_NS (this field's own pre-pacing-controller baseline) as the unit for + // a round, provisional multiplier, so a search that is persistently over + // the pause-time ceiling still makes some progress rather than backing + // off indefinitely. + static constexpr u64 MIN_EFFECTIVE_CADENCE_NS = 10000000ULL; // 10ms + static constexpr u64 MAX_EFFECTIVE_CADENCE_NS = PASS_CADENCE_NS * 4; // 4s + + // Conversion factor from "edges of budget signal updatePacing()'s clamp + // could not absorb" to a cadence adjustment in nanoseconds - the two are + // different units (edge count vs. wall-clock time) with no natural + // exchange rate, so this is a round, provisional choice: large enough + // that a sustained, deeply-saturated overflow visibly moves the cadence + // within a handful of passes, small enough that a single borderline pass + // does not swing the whole cadence range at once. + static constexpr u64 CADENCE_NS_PER_EDGE_OVERFLOW = 1000000ULL; // 1ms/edge + + // Budget-borrowing (see _borrowed_budget's own comment): how many + // consecutive comfortably-under-target passes (BORROW_UNDER_TARGET_FRACTION) + // must be observed before updatePacing() starts growing _borrowed_budget at + // all. Round, provisional like this subsystem's other unbenchmarked + // constants - large enough that a brief lull (e.g. one quiet pass right + // after a GC) cannot itself unlock extra headroom, small enough that a + // workload with a genuinely fast-growing frontier converges within a few + // seconds of passes rather than needing to wait out most of the search's + // own TTL just to start borrowing. + static constexpr int BORROW_WARMUP_PASSES = 5; + + // Budget-borrowing: a pass counts toward BORROW_WARMUP_PASSES/keeps + // _borrowed_budget only when pass_ms is at most this fraction of + // _pause_target_ms - deliberately stricter than merely "under the + // ceiling" (which the ordinary _effective_budget clamp already + // guarantees), so growth is gated on *comfortable* headroom, not on + // shaving the pass in just under the wire. + static constexpr double BORROW_UNDER_TARGET_FRACTION = 0.5; + + // Budget-borrowing: hard cap on how far updatePacing() may grow + // (_budget + _borrowed_budget) above _budget alone - _borrowed_budget + // itself is clamped so the resulting ceiling never exceeds + // _budget * BORROW_CEILING_MULTIPLIER. _budget remains a real ceiling in + // the sense that it still bounds how much headroom borrowing can ever + // reach; this only relaxes "never exceeded" into "never exceeded by more + // than a bounded, revocable multiple", which is the whole point of the + // extension (see _borrowed_budget's own comment). + static constexpr int BORROW_CEILING_MULTIPLIER = 4; + + // Budget-borrowing: fraction of _budget by which _borrowed_budget grows on + // each pass once BORROW_WARMUP_PASSES has been reached - a fraction of the + // configured budget rather than of the current borrowed amount, so growth + // stays linear (predictable, boundable within a known number of passes) + // rather than compounding. + static constexpr double BORROW_GROWTH_FRACTION = 0.25; + + // Agent-owned BFS thread (design doc's Triggering section: "an agent-owned, + // already-attached thread ... calling FollowReferences/IterateThroughHeap + // directly; the safepoint is a side effect of that call, not something the + // profiler builds or schedules"). threadLoop() mirrors J9WallClock's + // pthread lifecycle (j9WallClock.cpp:28-57) rather than BaseWallClock's, + // since J9WallClock's is the simpler of the two shapes actually used for a + // single dedicated thread in this codebase. threadLoop() implements the + // actual scheduling loop (shouldRunPass() below). + // + // start()/stop() themselves still do NOT create/join this thread - + // threadLoop()'s VM::attachThread() call crashes on a null VM::_vm if the + // VM is not yet attached, and referenceChains_ut.cpp calls start() + // directly with no live JVM, so spawning unconditionally from start() + // would crash that gtest binary. startThread()/stopThread() (public API + // above) own the thread's lifecycle instead, and are called from + // Profiler::start()/stop() (profiler.cpp) - the only place in this + // codebase that also calls ReferenceChainTracker::start()/stop() itself, + // and only once the JVM/JVMTI environment is already up. onGCFinish() + // below wakes this thread via pthread_kill(WAKEUP_SIGNAL) whenever it is + // running (i.e. once startThread() has been called) - inert otherwise. + pthread_t _thread; + // std::atomic rather than plain volatile bool - volatile alone gives + // no C++ memory-model acquire/release guarantees (it only prevents the + // compiler from eliding/reordering that one variable's own accesses), so a + // weakly-ordered CPU (e.g. arm64) could let the BFS thread's stopThread()- + // side write (see stopThread()'s own comment) become visible to + // threadLoop() later than intended, missing the shutdown request on one + // wakeup and sleeping/looping an extra cycle before pthread_join() unblocks + // it. Written with memory_order_release from startThread()/stopThread(), + // read with memory_order_acquire from threadLoop() - the same cross-thread + // shape _abort_pass_requested above already uses atomic for. + std::atomic _running; + + // Cooperative-cancellation flag for an in-flight JVMTI FollowReferences + // walk: stopThread() sets this before pthread_kill()/pthread_join() + // (that signal alone cannot interrupt a call already inside the JVM/JVMTI + // implementation), and heapReferenceCallback() checks it on every + // invocation, aborting the walk within one callback rather than letting + // pthread_join() block until the walk finishes on its own - see both + // methods' own comments. startThread() resets it back to false, since a + // dynamic-attach profiler can cycle through multiple start()/stop() calls + // in one JVM lifetime and a stale abort request would instantly kill the + // next cycle's very first pass. std::atomic: written from + // stopThread()/startThread() on the calling (shutdown) thread and read + // from heapReferenceCallback() on the BFS thread - the same cross-thread + // shape _running above already has, just made explicit via atomic rather + // than a plain volatile bool. + std::atomic _abort_pass_requested; + + // Wall-clock deadline for the pass currently in flight (OS::nanotime() + // ticks; 0 = no deadline). Set once at the top of runPassManualWalk() from + // _pause_target_ms and shared across that same call's static-field sweep + // and expandFrontier() calls (both read it via heapReferenceCallback()'s + // own periodic check). Deliberately NOT applied to root/stack-ref + // enumeration (heapRootCallback()) - a live experiment truncating that + // call early on a wall-clock basis measurably reduced total edges admitted + // over a fixed test window versus letting it run to its own (much larger) + // edge budget, because the call's fixed root-walk-and-dispatch cost is paid + // in full regardless of how early it's cut off - see + // ROOT_ENUM_MIN_INTERVAL_NS's own comment for the mechanism that now + // controls how often that call runs instead. Single-threaded: only + // threadLoop() ever calls runPassManualWalk(), so this needs no atomicity. + u64 _pass_deadline_ns = 0; + + // Last time root/stack-ref enumeration actually ran (OS::nanotime() ticks; + // 0 before the search's first pass). runPass() compares this against + // ROOT_ENUM_MIN_INTERVAL_NS to decide whether the current pass re-runs + // IterateOverReachableObjects or takes the cheap expandFrontier()-only + // path over the already-persisted frontier. Single-threaded, same as + // _pass_deadline_ns above. + u64 _last_root_enum_ns = 0; + + // Set true when the most recent root/stack-ref enumeration attempt ended + // via BUDGET_EXHAUSTED (not FRONTIER_CAP_HIT, which abandons the search + // outright) - runPass() treats this as grounds to retry root enumeration + // on the very next pass regardless of ROOT_ENUM_MIN_INTERVAL_NS, so a + // still-incomplete attempt is not left waiting out the full interval + // before continuing. Cleared as soon as an attempt completes without + // truncating. + bool _root_enum_truncated_last_time = false; + + ReferenceChainTracker() + : _enabled(false), + _frontier(nullptr), _configured_frontier_cap(0), + _last_class_map_generation(0), + _last_resolved_class_count(0), + _last_static_field_class_count(-1), + _gc_start_epoch(0), + _gc_finish_epoch(0), _next_tag(1), _next_class_tag_magnitude(1), + _hop_cap(0), _budget(0), _first_pass_budget(0), _ttl_ms(0), _pause_target_ms(0), + _effective_budget(0), _effective_cadence_ns(PASS_CADENCE_NS), + _pause_pid(1, 1.0, 1.0, 1.0, 1, 1.0), _search_started(false), + _tags_released(true), _search_state(SearchState::RUNNING), + _abandon_reason(SearchAbandonReason::NONE), _search_start_ns(0), + _last_pass_gc_finish_epoch(0), _last_pass_ns(0), + _passes_run(0), + _root_kind_rotation_cursor(1), + _pain_budget(0.0), _search_pain_ms(0), + _thread(), _running(false), _abort_pass_requested(false) {} + + void onGCStart(); + void onGCFinish(); + + static void *threadEntry(void *self) { + ((ReferenceChainTracker *)self)->threadLoop(); + return nullptr; + } + void threadLoop(); + + // Combines Open Question 5's two candidate pass-scheduling triggers + // (design doc's Triggering section) rather than picking one: true if the + // GC-finish epoch has advanced since the last pass ("a GC just happened, a + // pass may be worth running soon") or PASS_CADENCE_NS has elapsed since + // the last pass, whichever comes first. Also true before the first pass + // has ever run. A future measurement pass decides whether one of these + // two triggers should be dropped as unnecessary once real cost data + // exists - for now both are implemented, combined, rather than adding an + // unmeasured config knob to switch between them. + bool shouldRunPass(u64 now_ns); + + // Cheap probe (max=1, not the real poll pollWatchedTargets() makes) into + // LivenessTracker's population-trend table: true if at least one klass + // shows a positive population slope worth chasing. Always true when + // LivenessTracker::gcGenerationsEnabled() is off, since there is no + // candidate signal to gate on in that mode - callers fall back to their + // pre-existing behavior in that case. Shared by canAffordNewSearch() + // (restart gate) and threadLoop()'s own steady-state gate below, so a GC + // with no accompanying population growth doesn't trigger either a restart + // or a fresh pass. + bool hasLeakSignal(); + + // Search restart gate (this class's own header comment): true once + // _pain_budget has drained back to zero (canStartNow()) *and* + // hasLeakSignal() above reports at least one leak candidate. Also reused by + // shouldRunPass() to gate the very first search, not just restarts - the + // pain-budget half is always a no-op there (nothing has been spent yet). + // Always true when LivenessTracker::gcGenerationsEnabled() is off, since + // there is no candidate signal to gate on in that mode (see the header + // comment's last paragraph), so a reference-chains-without-generations + // setup is unaffected either way. + bool canAffordNewSearch(u64 now_ns); + + // Resets every per-search field back to its just-constructed value so the + // next runPass() call takes the "first pass of a search" branch again, + // exactly like a fresh ReferenceChainTracker would. Called by + // shouldRunPass() once a terminal search's tags have already been released + // (runPass() calls releaseSearchTags() itself before returning, so that + // has always already happened by the time this runs) and + // canAffordNewSearch() has approved a restart. Spends the finishing + // search's accumulated cost into _pain_budget first, so the *next* + // restart's gate reflects what this one actually cost. Does not touch + // _class_tags/_next_class_tag_magnitude - classes do not change identity + // across searches, so their resolved names stay valid and do not need + // re-resolving (mirrors _frontier's own stop()/start()-survival + // rationale). frontierTable()'s own resetForRestart() keeps the + // table's allocation but marks every slot unoccupied again, so tags + // restarting from 1 (nextTag()'s only outstanding scheme) do not read back + // stale metadata from the previous search. + void restartSearch(); + + // Marks every entry still queued in _pending_expand EXPANDED and drains the + // queue. Called after a *first-pass*, root-seeded FollowReferences call + // that completed without truncation: an uninterrupted walk from the heap + // roots already visits every admitted object's own outgoing edges inline + // (as part of the same call, not a separate one per object - see + // heapReferenceCallback()'s own comment), so nothing is left FRONTIER by + // accident; this just makes that explicit so a later resumed pass has + // nothing pending to expand. + void markAllFrontierExpanded(); + + // Resumed-pass counterpart to the first pass's root-seeded FollowReferences + // call in runPass(): resolves every not-yet-expanded entry queued in + // _pending_expand via GetObjectsWithTags - design doc Algorithm step 2's + // "resolve currently- + // live tagged frontier objects; objects that fail to resolve are dropped + // (dead - free pruning)" - then calls FollowReferences with the resolved + // object as initial_object to discover its own outgoing edges, exactly as + // the root walk does inline for a first pass. Repeats over newly- + // discovered entries within the same call, stopping the moment + // *edges_admitted reaches `budget` or the frontier table reports capacity + // exhaustion (the same "abort expansion past the cap rather than + // discovering-then-discarding" rule the root-seeded path already follows) + // - leaving the remaining range untouched for a later call to retry. + // + // *frontier_cap_hit distinguishes "budget for this call ran out" (normal; + // the search stays RUNNING, more work remains for the next pass) from + // "the frontier table itself is full" (design doc: "stop admitting new + // entries ... report it" - runPass() treats this as grounds to ABANDON the + // whole search, not just truncate this pass). If GetObjectsWithTags itself + // fails, *truncated is set (there is pending work, just not resolvable + // this call) so runPass() does not mistake that for the search having + // reached natural completion. + void expandFrontier(jvmtiEnv *jvmti, JNIEnv *jni, int hop_cap, int budget, + int *edges_admitted, bool *truncated, + bool *frontier_cap_hit); + + // Static-field counterpart to heapRootCallback()'s GC-root enumeration: + // IterateOverReachableObjects's root/stack-ref callbacks never report a + // class's static fields (there is no jvmtiHeapRootKind for STATIC_FIELD - + // translateHeapRootKind()'s own comment), so without this call an object + // retained only via `SomeClass.staticField` is never discovered by either + // root enumeration or expandFrontier() (which only descends from + // already-admitted, non-class frontier entries - class objects are never + // admitted, see heapReferenceCallback()'s own comment). This drives one + // batched FollowReferences(initial_object=) call - + // mirroring expandFrontier()'s array-holder batching, one FollowReferences + // for every loaded class rather than one per class - so + // heapReferenceCallback()'s existing referrer-is-a-pre-tagged-class + // ("rtag < 0") root-like handling actually gets invoked. An empty + // batch_tags set forces exactly one hop past each class, exactly like + // expandFrontier()'s per-level batching: each admitted static-field + // referent becomes an ordinary frontier entry that a later + // expandFrontier() call expands on its own turn. Best-effort: on any + // failure (no JNIEnv, OOM/local-ref exhaustion building the holder array, + // JVMTI error) this simply skips the sweep for this pass rather than + // treating it as this pass's own truncation - it is discovery on top of + // the manual walk, not part of its budget/frontier-cap accounting. + void admitStaticFieldRoots(jvmtiEnv *jvmti, JNIEnv *jni, int hop_cap, + int budget, int *edges_admitted, + bool *truncated, bool *frontier_cap_hit); + + // Clears the live JVMTI tag (via clearTag(), i.e. SetTag(obj, 0)) for + // every FrontierTable entry this search has not already marked ABANDONED - + // design doc's Termination section: "on abandonment or completion, every + // JVMTI tag this search assigned ... must be cleared before the search's + // state is discarded." Does not discard the FrontierTable's own records + // (referrer_klass/parent_tag/depth survive, so reconstructChain() keeps + // working from memory after the search ends) - only the underlying + // object's live JVMTI tag is released, via the same batch + // resolve-then-clear sequence GetObjectsWithTags makes possible for + // expandFrontier()'s resolve-or-drop path. + // + // Returns true if every live tag scanned this call was successfully + // resolved-and-cleared (or there were none to begin with), false if + // GetObjectsWithTags() itself failed - in which case NO entry is marked + // ABANDONED (unlike a resolve failure for an individual tag, which means + // the object is already dead and safe to treat as released): a batch + // GetObjectsWithTags() failure tells us nothing about which, if any, + // objects in the batch are still live, so marking them ABANDONED here + // would let restartSearch() reset _next_tag/the frontier table while a + // still-live object could still be holding this search's JVMTI tag, + // corrupting the next search's tag-uniqueness invariant. Callers must not + // allow a restart until this returns true; calling it again later safely + // retries only the tags still not marked ABANDONED from a prior failed + // call. + bool releaseSearchTags(jvmtiEnv *jvmti, JNIEnv *jni); + + // Pause-time pacing controller (doc/architecture/LiveHeapReferenceChains- + // RemainingWorkPlan.md): feeds `pass_wall_ns` - the wall-clock duration of the FollowReferences/ + // GetObjectsWithTags call runPass() just made (the safepoint-triggering + // call itself, per the design doc's Triggering section; no new + // instrumentation needed, since this class is already the thread blocked + // inside it) - into `_pause_pid`, and scales `_effective_budget`/ + // `_effective_cadence_ns` from its output. Folds Open Questions 2 and 5 + // into the one controller call the plan asks for, rather than two + // separately tuned mechanisms. + // + // `_pause_pid.compute()`'s sign convention (pidController.cpp): a positive + // signal means the measured value came in *under* the controller's + // target - here, the last pass finished comfortably inside + // `_pause_target_ms`, so there is headroom to admit a larger budget next + // time. This is the opposite of ObjectSampler/MallocTracer/ + // NativeSocketSampler's own usage (objectSampler.cpp:220-224, + // mallocTracer.cpp:317-318, rateLimiter.h), which *subtract* the signal + // from their interval because their controlled variable (a sampling + // interval) is inversely related to their target rate; `_effective_budget` + // is directly related to pass duration (more budget -> longer pass), so + // the signal is *added* here instead. + // + // The result is clamped to [floor, _budget] - `_budget` (the config value, + // Arguments::_reference_chains_budget) becomes this controller's ceiling + // rather than a fixed per-pass value, per the plan's "clamped, never above + // the frontier/hop caps": the hop cap and frontier cap stay untouched, + // fixed correctness bounds exactly as before (design doc: "not + // controller-tuned"). Whatever part of the signal the clamp could not + // absorb (`overflow` below) drives `_effective_cadence_ns` instead - the + // plan's "fold the cadence decision into the same controller output rather + // than a second mechanism": a search still running long even at the + // minimum budget backs off the fallback cadence instead of trying to + // shrink the budget further (avoiding a degenerate near-zero budget just + // to hit an aggressive cadence, per the plan's own wording); a search with + // spare headroom even at the maximum (config) budget relaxes the cadence + // toward MIN_EFFECTIVE_CADENCE_NS instead, letting the GC-finish-epoch + // trigger (shouldRunPass(), already unconditional on cadence) make + // progress as often as it fires. + void updatePacing(u64 pass_wall_ticks); + + // Root/stack-ref enumeration passes never reach updatePacing() (runPass()'s + // own comment: their fixed dispatch cost would wrongly throttle + // _effective_budget for every unrelated later pass), but a slow one still + // spends real pause-time-SLO budget the borrow ceiling promised was safe to + // hand out. _borrowed_budget's own comment requires it be revoked the + // instant ANY pass is not comfortably under target, so this call - made in + // updatePacing()'s place for a root-enum pass - only ever revokes, never + // grows the streak/borrow: the warmup counter is calibrated against + // expandFrontier()'s per-node cost, not this call's unrelated fixed cost. + void maybeRevokeBorrowForRootEnumPass(u64 pass_wall_ticks); + + // Tags every not-yet-tagged loaded class (GetLoadedClasses()) with a + // fresh nextClassTag() and resolves its name into _class_tags, via the + // same GetClassSignature + normalizeClassSignature + Profiler::lookupClass + // sequence ObjectSampler::recordAllocation() already uses + // (objectSampler.cpp:76-90) - reusing that normalization helper rather + // than re-deriving it. Run once at the start of every runPass() (before + // FollowReferences) rather than lazily during the walk, because + // GetClassSignature/JNI calls are not allowed from inside + // heapReferenceCallback() (see the file header comment) - by pre-tagging, + // every class_tag the callback sees is already resolvable with no further + // JVMTI/JNI calls of its own. Already-tagged classes (from a previous + // pass) are skipped, not re-resolved. + void resolveLoadedClasses(jvmtiEnv *jvmti, JNIEnv *jni); + + // jvmtiHeapReferenceCallback for runPass()'s FollowReferences call (see + // runPass() below for the full walk). `user_data` is a PassContext* + // (referenceChains.cpp, private to the .cpp - the type never needs to be + // visible here since only runPass() constructs one). + static jint JNICALL heapReferenceCallback( + jvmtiHeapReferenceKind reference_kind, + const jvmtiHeapReferenceInfo *reference_info, jlong class_tag, + jlong referrer_class_tag, jlong size, jlong *tag_ptr, + jlong *referrer_tag_ptr, jint length, void *user_data); + + // Outcome of admitObject() below - lets each of its two call sites + // (heapReferenceCallback() above and the IterateOverReachableObjects root/ + // stack-ref callbacks, both in referenceChains.cpp) translate the same + // admission decision into its own callback-shape-appropriate return + // value/truncation flag, instead of duplicating the decision twice. + enum class AdmitResult { + ALREADY_ADMITTED, // *tag_ptr != 0: nothing to do, not a truncation + HOP_CAP, // depth >= hop_cap: not admitted, not a truncation + BUDGET_EXHAUSTED, // edges_admitted >= budget: this pass's cap + FRONTIER_CAP_HIT, // FrontierTable::insert() itself is full: abandon-worthy + ADMITTED, + }; + + // First-discovery admission core (implementation plan's Phase 4 item 2): + // factored out of heapReferenceCallback()'s inline admission branch so the + // manual-walk driver's root/stack-ref callbacks stay in sync with + // FollowReferences' own admission by construction, not by copy-paste. + // `*tag_ptr` is the in/out tag slot each call site already has as a real + // JVMTI out-parameter, and `*edges_admitted` + // is likewise each call site's own running counter for this call. On + // AdmitResult::ADMITTED, `*tag_ptr` is filled with the freshly assigned tag + // and the tag is queued onto _pending_expand (or, when `priority` is true, + // onto _priority_expand instead - see that field's own comment) exactly as + // heapReferenceCallback() already did inline. + AdmitResult admitObject(FrontierTable *frontier, int hop_cap, int budget, + int *edges_admitted, jlong *tag_ptr, + jlong parent_tag, u32 referrer_klass, u32 depth, + u8 root_kind, bool priority = false); + + // Durability tie-break (design doc's "Fix for root-attribution staleness" + // point 1 / Phase 5 item 1) for an object rediscovered as a heap root by + // heapRootCallback()/stackRefCallback() while already admitted (same pass + // or a previous one) - factored out of those callbacks, rather than + // inlined, so it is unit-testable without a PassContext/JVMTI mock (both + // callbacks' user_data type is private to referenceChains.cpp). Only ever + // overwrites root_kind - never parent_tag - and only for an entry that is + // already root-attached (entry.parent_tag == 0); this is the option (a) + // resolution of the parent_tag==0/root_kind invariant conflict (Phase 5's + // own callout): a re-expansion-driven, non-root rediscovery of an edge to + // some already-tracked, non-root-attached object must never reach this + // method at all (re-expansion child admission always passes + // root_kind=0, which loses every tie-break, so it structurally cannot + // trigger an upgrade even if it were mistakenly routed here). Returns true + // if an upgrade was applied, false otherwise (already at least as durable, + // not root-attached, or not found) - purely informational for callers/ + // tests, not required for correctness. + bool maybeUpgradeRootAttachedRootKind(FrontierTable *frontier, jlong tag, + u8 new_root_kind); + + // True if `tag` is already sitting in _priority_expand - either queued + // earlier this same pass by the other rotation collector, or left over + // from a prior pass's truncated batch (expandFrontier() leaves those at + // the front of the queue for a later retry rather than popping them). + // Shared by both rotation collectors below so neither can push a tag + // that's already pending re-expansion; a linear scan is deliberate over + // building a hash set - _priority_expand is bounded by + // ROOT_KIND_ROTATION_BUDGET + STALE_EXPANDED_ROTATION_BUDGET (a few + // hundred entries at most), where a linear scan is cheaper than hashing + // and allocating a table for every rotation-collecting pass. + bool isQueuedForRotation(jlong tag) const { + return std::find(_priority_expand.begin(), _priority_expand.end(), + tag) != _priority_expand.end(); + } + + // Bounded rotating re-expansion (design doc's closing section / Phase 5 + // item 3): each manual-walk pass, feed up to `max_count` already-EXPANDED, + // root-attached entries whose root_kind is still transient + // (isTransientRootKind()) back into _priority_expand so + // expandFrontier() re-walks their fields - giving a stale root_kind + // another chance to be superseded by a durable root discovered elsewhere + // in the interim, via the same admitObject()/tie-break machinery every + // other admission uses. Scans FrontierTable slots in tag order starting + // from _root_kind_rotation_cursor, wrapping at size(), so repeated calls + // sweep the whole table over time instead of only ever revisiting the + // first `max_count` transient entries. Pure table scan/queue push - no + // JVMTI call of its own - so it is unit-testable directly. + // Returns the tags selected (also already pushed onto _priority_expand). + std::vector collectStaleRootKindEntriesForRotation(int max_count); + + // Bounded rotating re-expansion for stale mutable fields: + // expandFrontier() observes an object's outgoing references exactly once + // (on the FollowReferences call that marks it EXPANDED) and never + // revisits it, so a field that is later reassigned to point at a + // different object - e.g. HashMap.table on resize - has its new value + // permanently unobserved once the map itself is EXPANDED; the old table + // array's own frontier entry eventually resolves to a dead object via + // GetObjectsWithTags and gets silently cleared with zero children, + // orphaning everything only reachable through the *current* table. + // Feeds up to `max_count` already-EXPANDED entries (any parent_tag/ + // root_kind - unlike collectStaleRootKindEntriesForRotation() above, which + // is scoped to root-attached transient entries for a different reason) + // back into _priority_expand so expandFrontier() re-runs FollowReferences + // on them and observes their current field values. Already-admitted + // children are ALREADY_ADMITTED no-ops (admitObject()'s own idempotency); + // only a genuinely new edge (i.e. a mutated field) is admitted. Always + // sweeps from the lowest tag rather than round-robin cursor: see this + // method's own comment in referenceChains.cpp for why low tags (the + // earliest-admitted, most likely long-lived entries) get priority. + std::vector collectStaleExpandedEntriesForRotation(int max_count); + + // jvmtiHeapRootCallback/jvmtiStackReferenceCallback for runPassManualWalk()'s + // IterateOverReachableObjects call (referenceChains.cpp). `user_data` is a + // PassContext* (the same private-to-the-.cpp type heapReferenceCallback() + // already uses above) - both callbacks only ever admit a root-attached + // entry (parent_tag=0, depth=0), translating the JVMTI-owned + // jvmtiHeapRootKind into FrontierEntry::root_kind's jvmtiHeapReferenceKind + // numbering first (see referenceChains.cpp's translateHeapRootKind() for + // why this translation is required, not optional). + static jvmtiIterationControl JNICALL + heapRootCallback(jvmtiHeapRootKind root_kind, jlong class_tag, jlong size, + jlong *tag_ptr, void *user_data); + static jvmtiIterationControl JNICALL stackRefCallback( + jvmtiHeapRootKind root_kind, jlong class_tag, jlong size, + jlong *tag_ptr, jlong thread_tag, jint depth, jmethodID method, + jint slot, void *user_data); + + // Manual-walk pass driver (implementation plan Phase 4): when + // `run_root_enum` is true, seeds/refreshes root-attached frontier entries + // via IterateOverReachableObjects (heapRootCallback()/stackRefCallback() + // above) using `root_enum_budget`; then, regardless of `run_root_enum`, + // drains _pending_expand via admitStaticFieldRoots()/expandFrontier() up to + // `expand_budget`. The two budgets are independent, not shared - see + // ROOT_ENUM_MIN_INTERVAL_NS's own comment for why root enumeration gets its + // own, much larger, infrequent allowance instead of competing with the + // small steady-state budget every tick's expansion uses. runPass() decides + // `run_root_enum`/`root_enum_budget`; when false, this call takes the + // cheap expandFrontier()-only path over whatever the last enumeration + // already admitted into the frontier. + void runPassManualWalk(jvmtiEnv *jvmti, JNIEnv *jni, bool run_root_enum, + int root_enum_budget, int expand_budget, + int *edges_admitted, bool *truncated, + bool *frontier_cap_hit); + + // Inserts (or refreshes) klass_id's resolved chain in _resolved_chains, + // recording the source_tag/source_search_ns it was reconstructed from so a + // later poll can tell a stale entry from a current one. Drops (counting via + // REFERENCE_CHAIN_EVENTS_DROPPED) rather than evicting when a brand-new + // klass_id arrives with the cache already at MAX_RESOLVED_CHAINS - see that + // constant's own comment and this method's definition (referenceChains.cpp). + void cacheResolvedChain(u32 klass_id, ReferenceChainEvent &&event, + jlong source_tag, u64 source_search_ns); + +public: + static ReferenceChainTracker *instance() { + static ReferenceChainTracker instance; + return &instance; + } + + ReferenceChainTracker(const ReferenceChainTracker &) = delete; + ReferenceChainTracker &operator=(const ReferenceChainTracker &) = delete; + + Error start(Arguments &args); + void stop(); + + // Spawns the BFS thread (threadEntry()/threadLoop()) if reference chain + // tracking is enabled and no thread is already running. Deliberately kept + // separate from start() itself: start() must stay safely callable with no + // live JVM attached (referenceChains_ut.cpp calls it directly against a + // mocked jvmtiEnv, with VM::_vm never set), while startThread()'s + // threadLoop() calls VM::attachThread() unconditionally - safe only once + // the JVM is actually up. Wired from Profiler::start() (profiler.cpp), + // which only calls this after the JVM/JVMTI environment is fully + // initialized, resolving the ordering concern start()'s own comment used + // to raise. No-op if disabled or already running. + void startThread(); + + // Stops and joins the BFS thread started by startThread(), mirroring + // BaseWallClock::stop()'s pthread_kill(WAKEUP_SIGNAL) + pthread_join() + // shape (wallClock.cpp) - WAKEUP_SIGNAL is already installed + // unconditionally in vmEntry.cpp, so no extra signal setup is needed here. + // No-op if the thread was never started. + void stopThread(); + + bool enabled() const { return _enabled; } + + u64 gcStartEpoch() { return load(_gc_start_epoch); } + u64 gcFinishEpoch() { return load(_gc_finish_epoch); } + + // Tag round-trip helpers, reused by resolveLoadedClasses()/ + // heapReferenceCallback() (the heap-walk engine) to drive FrontierTable's tag-indexed + // slots. + jlong nextTag() { return atomicIncRelaxed(_next_tag, (jlong)1); } + jlong tagObject(jvmtiEnv *jvmti, jobject obj); + jlong getTag(jvmtiEnv *jvmti, jobject obj); + void clearTag(jvmtiEnv *jvmti, jobject obj); + + // Hands out a fresh negative class tag (see _next_class_tag_magnitude + // above for why negative). Exposed (not just used internally by + // resolveLoadedClasses()) so tests can drive class tagging directly + // against a mocked jvmtiEnv without going through GetLoadedClasses. + jlong nextClassTag() { + return -atomicIncRelaxed(_next_class_tag_magnitude, (jlong)1); + } + + // Returns the frontier metadata table, or nullptr if the subsystem was + // never started with the flag enabled. + FrontierTable *frontierTable() { return _frontier; } + + // Returns the class-tag resolution table. Exposed for testing in + // isolation, matching frontierTable()'s existing rationale. + ClassTagTable *classTags() { return &_class_tags; } + + // Runs exactly one bounded BFS pass and returns. The first call for a + // search seeds FollowReferences from the heap roots (heap_filter=0, + // klass=NULL, initial_object=NULL - see this method's own comment in + // referenceChains.cpp for why FollowReferences rather than + // IterateThroughHeap); every later call resumes from the persisted + // frontier via expandFrontier() instead of re-walking from the roots (see + // expandFrontier()'s comment for why - re-walking from the roots each call + // would re-traverse the entire already-discovered subgraph every pass, + // defeating the point of a per-pass budget). Newly discovered objects are + // admitted into frontierTable() up to _hop_cap/_budget/the frontier + // table's own capacity cap. + // + // Returns false if reference chain tracking is disabled, jvmti is null, or + // the frontier table was never constructed (start() never ran with the + // flag enabled). A pass that hits its budget/hop/frontier cap is still a + // *successful* call (returns true) - *out_truncated (if non-null) reports + // whether *this pass* ran to full exhaustion of the currently-known + // reachable graph or was cut short, per the design doc's "no silent + // truncation" requirement; this is call-scoped, unlike searchState() + // below which reports the whole search's outcome. + // + // Once searchState() is no longer RUNNING (the reachable graph was fully + // explored within caps, or the search was abandoned - see the Termination + // section implemented below), further calls are no-ops that return true + // immediately, *unless* shouldRunPass() has already called restartSearch() + // to begin a fresh search (this class's own header comment) - in that case + // _search_started is false again and this method takes the first-pass + // branch exactly as it would for a brand-new tracker. + bool runPass(jvmtiEnv *jvmti, JNIEnv *jni, bool *out_truncated = nullptr); + + // Search-level outcome (SearchState's constants) - see runPass()'s comment + // for exactly when this leaves RUNNING. Acquire-loaded, pairing with + // runPass()'s release store of this same field (referenceChains.cpp), so a + // caller that observes a non-RUNNING value here also sees every detail + // field (_abandon_reason, _passes_run, ...) runPass() wrote before that + // release store. + u8 searchState() { return loadAcquire(_search_state); } + + // Total passes run for the current/most recent search. Exposed for tests + // to confirm multi-pass resumption actually happened. + int passesRun() { return load(_passes_run); } + + // Which SearchAbandonReason cutoff moved the search out of RUNNING, or + // SearchAbandonReason::NONE if it never left RUNNING or left via + // SearchState::COMPLETED instead. + u8 abandonReason() { return load(_abandon_reason); } + + // Reference-chain JFR event surface: fills *out from frontierTable()-> + // reconstructChain(target_tag, ...) (see that method's own comment for + // the leaf-to-root ordering and the parent_tag walk it performs). Returns + // false (leaving *out untouched) if target_tag was never inserted into + // the frontier table - the same failure case reconstructChain() itself + // reports, just wrapped into the JFR-event shape + // Recording::recordReferenceChain() (flightRecorder.cpp) expects. + // + // Deliberately does not decide *when* to call this or *which* target_tag + // to use - this codebase has no target-sample feed into + // ReferenceChainTracker yet (see runPass()'s own comment), so wiring an + // automatic call site here would have to invent + // that feed rather than reuse one. A future consumer that knows which + // tag it is chasing (e.g. an ObjectSampler-driven target) calls this + // directly once that feed exists. + bool buildChainEvent(jlong target_tag, ReferenceChainEvent *out) { + if (_frontier == nullptr || out == nullptr) { + return false; + } + FrontierEntry entry{}; + if (!_frontier->lookup(target_tag, &entry)) { + return false; + } + std::vector chain; + u8 root_kind = 0; + if (!_frontier->reconstructChain(target_tag, &chain, &root_kind)) { + return false; + } + TEST_LOG("ReferenceChainTracker::buildChainEvent target_tag=%lld chain_size=%zu " + "chain[0]=%u depth=%u root_kind=%u", + (long long)target_tag, chain.size(), chain.empty() ? 0u : chain[0], + entry.depth, (unsigned)root_kind); + out->_target_tag = (u64)target_tag; + out->_depth = entry.depth; + out->_root_kind = root_kind; + out->_chain = std::move(chain); + return true; + } + + // Abandoned-search JFR event surface for the design doc's "explicit reporting of + // abandoned searches" requirement - unlike buildChainEvent() above, this + // needs no target_tag: it reports the search's own termination state, + // which runPass() (referenceChains.cpp) already tracks unconditionally. + // Returns false (leaving *out untouched) if the search was never + // abandoned (searchState() != SearchState::ABANDONED). Called from + // Profiler::dump() (profiler.cpp), mirroring LivenessTracker::flush()'s + // own call site there, whenever a dump is requested while the search is + // ABANDONED - unlike LivenessTracker's table this does not clear any + // state, so a dump taken after the search already abandoned reports the + // same event again; this is a read of current state, not a queue drain. + bool buildAbandonedEvent(ReferenceChainAbandonedEvent *out) { + // Acquire-load, not a plain relaxed load - see searchState()'s own + // comment for why: this is the same guard-then-read-details pattern. + if (out == nullptr || loadAcquire(_search_state) != SearchState::ABANDONED) { + return false; + } + out->_reason = load(_abandon_reason); + out->_passes_run = (u32)load(_passes_run); + out->_frontier_size = _frontier != nullptr ? (u32)_frontier->size() : 0; + out->_hop_cap = _hop_cap; + out->_budget = _budget; + out->_ttl_ms = _ttl_ms; + out->_elapsed_ns = load(_last_pass_ns) - load(_search_start_ns); + return true; + } + + // Target-selection bridging step (design doc's Open Question 3, corrected + // mechanism - see this class's own header comment's bridging-step note and + // doc/architecture/LiveHeapReferenceChains-RemainingWorkPlan.md's + // "Correction to the design doc's Open Question 3 mechanism"): polls + // LivenessTracker::selectLeakCandidates() and, for each candidate whose + // representative instance has already been discovered by an ordinary + // runPass() walk (getTag() > 0 - a read, never a SetTag seed), reconstructs + // its datadog.ReferenceChain and caches it in _resolved_chains keyed by + // klass_id - see that field's own comment for why a resolved chain is + // cached (and re-emitted on every dump) rather than emitted once. The write + // itself is still deferred to drainPendingChainEvents() on the dump() + // thread, since Profiler::writeReferenceChain() can block this method's + // caller (the BFS scheduling thread) for up to ~50ms per event. A candidate + // still at tag 0 (not yet discovered) is left for a later poll to retry, + // since runPass()'s whole-graph walk eventually visits every root-reachable + // object, barring the hop/budget/frontier caps. A klass already cached from + // the current search generation is not reconstructed again; a restart + // (new _search_start_ns) or a re-tag makes the next poll refresh it. Every + // cached entry whose representative no longer resolves (collected/evicted) + // is pruned here, so the cache tracks the set of still-live flagged samples. + // Called from threadLoop() once per scheduling cycle, after runPass(), so + // this poll always sees the most recent pass's tagging. No-op if + // disabled, or if jvmti/jni is null (mirrors runPass()'s own null-safety, + // so a test can call this directly without a live JVM attached, the same + // way referenceChains_ut.cpp already does for runPass()). + void pollWatchedTargets(jvmtiEnv *jvmti, JNIEnv *jni); + + // Appends a copy of every currently-cached resolved chain to *out, + // re-stamped with a fresh _start_time so it lands in the dumping chunk's + // time window, WITHOUT clearing the cache - a repeatable snapshot, not a + // drain, so the same live sample's chain is re-emitted into every JFR chunk + // it survives into (see _resolved_chains' own comment). Called from + // Profiler::dump() (profiler.cpp), which then calls + // Profiler::writeReferenceChain() for each event on its own thread - never + // the BFS scheduling thread. A no-op (leaves *out untouched) if the cache + // is currently empty. The name is retained from the drain-once era for its + // stable call site; the semantics are now snapshot-and-keep. + void drainPendingChainEvents(std::vector *out); + + static void JNICALL GarbageCollectionStart(jvmtiEnv *jvmti_env); + static void JNICALL GarbageCollectionFinish(jvmtiEnv *jvmti_env); + + // Test seam - not part of the production API. Mirrors LivenessTracker's + // own "Test seams" block (livenessTracker.h). Production code only ever + // discovers frontier roots via runPass()'s root-seeded FollowReferences + // walk; this lets a test tag and insert one specific, caller-chosen live + // object as a frontier root directly, so runPass()/pollWatchedTargets()/ + // buildChainEvent() can be exercised end-to-end against a known target + // without depending on LivenessTracker's probabilistic allocation sampler + // to organically select and surface the same object. Returns the assigned + // tag (matching the value buildChainEvent()'s target_tag expects), or 0 on + // failure (obj/jvmti/jni null, SetTag failed, or the frontier table is at + // capacity). + jlong tagAsRootForTest(jvmtiEnv *jvmti, JNIEnv *jni, jobject obj); + + // Test seam - not part of the production API. Since ReferenceChainTracker + // is a process-wide singleton (ExternalProcessReferenceChainTest's own + // class javadoc explains why that matters: only the *first* test to ever + // call runPass() in a shared JVM gets a real root-seeded walk, since + // runPass() only re-walks from the roots once per search's whole + // lifetime), an in-process test that needs its own genuine first-ever + // root walk calls this at the start of its test body to force exactly + // that - releasing any tags a previous test's search still held, then + // resetting search/frontier state to the same "brand-new tracker" state + // restartSearch() (referenceChains.cpp) produces, plus the target- + // dedup/pending-event state restartSearch() itself intentionally leaves + // for pollWatchedTargets()/drainPendingChainEvents() to self-clear (this + // is an immediate, out-of-band reset - there is no next real pass here to + // observe the change and clear them the ordinary way). + void resetSearchStateForTest(jvmtiEnv *jvmti, JNIEnv *jni); + + // Test seam - not part of the production API. Diagnostic-only: reports how + // far a given (already-tagged) object sits from the front of + // _pending_expand's FIFO queue, to distinguish "not yet expanded because + // its own FIFO position hasn't come up yet" from "already expanded" or + // "never admitted at all" without needing a debugger. Returns >=0 (the + // 0-based distance from the front - 0 means it expands next) if tag is + // still queued, -1 if tag is nonzero but not currently queued (already + // expanded, or never admitted), or -2 if tag itself is 0. + long pendingExpandPositionForTest(jlong tag) const; + + // Test seam - not part of the production API. Companion to + // pendingExpandPositionForTest() above, for computing a position's + // fraction of the current backlog. + size_t pendingExpandSizeForTest() const; +}; + +#endif // _REFERENCECHAINS_H diff --git a/ddprof-lib/src/main/cpp/safeAccess.h b/ddprof-lib/src/main/cpp/safeAccess.h index 564743b153..0ab3ed380c 100644 --- a/ddprof-lib/src/main/cpp/safeAccess.h +++ b/ddprof-lib/src/main/cpp/safeAccess.h @@ -1,6 +1,6 @@ /* * Copyright 2021 Andrei Pangin -* Copyright 2026 Datadog, Inc + * Copyright 2026 Datadog, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/ddprof-lib/src/main/cpp/stringDictionary.h b/ddprof-lib/src/main/cpp/stringDictionary.h index b5572b6237..f16f802a72 100644 --- a/ddprof-lib/src/main/cpp/stringDictionary.h +++ b/ddprof-lib/src/main/cpp/stringDictionary.h @@ -459,6 +459,11 @@ class StringDictionaryBuffer { class StringDictionary { std::atomic _next_id{1}; // starts at 1; id=0 reserved as "no entry" std::atomic _accepting{true}; // false while clearAll() is resetting buffers + // Bumped by clearAll() only. Lets a cache keyed by ids from this + // dictionary (e.g. ReferenceChainTracker::_class_tags, referenceChains.h) + // detect "the id namespace was wiped out from under me" and invalidate + // itself, rather than assuming ids stay valid across a clearAll(). + std::atomic _generation{0}; StringDictionaryBuffer _a, _b, _c; TripleBufferRotator _rot; int _counter_offset; // offset into DICTIONARY_KEYS / DICTIONARY_KEYS_BYTES counter rows @@ -489,6 +494,9 @@ class StringDictionary { } } + // Current id-namespace generation; see _generation's own comment. + u64 generation() const { return _generation.load(std::memory_order_acquire); } + // Insert into active buffer; returns globally stable id. NOT signal-safe. u32 lookup(const char* key, size_t len) { if (!_accepting.load(std::memory_order_acquire)) return 0; @@ -635,6 +643,7 @@ class StringDictionary { _next_id.store(1, std::memory_order_relaxed); Counters::set(DICTIONARY_KEYS, 0, _counter_offset); Counters::set(DICTIONARY_KEYS_BYTES, 0, _counter_offset); + _generation.fetch_add(1, std::memory_order_release); _accepting.store(true, std::memory_order_release); } }; diff --git a/ddprof-lib/src/main/cpp/symbols_linux.cpp b/ddprof-lib/src/main/cpp/symbols_linux.cpp index b328fcfd56..8a15165600 100644 --- a/ddprof-lib/src/main/cpp/symbols_linux.cpp +++ b/ddprof-lib/src/main/cpp/symbols_linux.cpp @@ -575,7 +575,15 @@ void ElfParser::calcVirtualLoadAddress() { for (int i = 0; i < _header->e_phnum; i++) { ElfProgramHeader* pheader = phdrAt(i); if (pheader != NULL && pheader->p_type == PT_LOAD) { - _vaddr_diff = _base - pheader->p_vaddr; + // p_vaddr is an unrelated virtual address, not an offset within the + // _base allocation - subtracting it via pointer arithmetic can wrap + // to (or through) a null representation, which UBSan flags even + // though the resulting bit pattern is only ever used as an offset + // to add back later (at()/base()/dyn_ptr() above). Do the + // subtraction in integer space and reinterpret, matching this + // file's existing "validate in integer space before forming a + // pointer" pattern (see phdrAt() above). + _vaddr_diff = (const char*)((uintptr_t)_base - (uintptr_t)pheader->p_vaddr); return; } } @@ -665,7 +673,11 @@ void ElfParser::parseDynamicSection() { loadSymbolTable(symtab, syment * nsyms, syment, strtab, strsz); } - const char* base = this->base(); + // base() is NULL for ET_EXEC (non-PIE) images - adding r->r_offset to it + // via pointer arithmetic is UB (base + r->r_offset on a null base), even + // though the intent is just "sym addresses are already absolute". Do the + // addition in integer space, same fix as the .plt case above. + uintptr_t base_addr = (uintptr_t)this->base(); if (jmprel != NULL && pltrelsz != 0) { // Parse .rela.plt table for (size_t offs = 0; offs < pltrelsz; offs += relent) { @@ -674,7 +686,7 @@ void ElfParser::parseDynamicSection() { if (sym->st_name != 0) { const char* sym_name = strAt(strtab, strsz, sym->st_name); if (sym_name != NULL) { - _cc->addImport((void**)(base + r->r_offset), sym_name); + _cc->addImport((void**)(base_addr + r->r_offset), sym_name); } } } @@ -691,7 +703,7 @@ void ElfParser::parseDynamicSection() { if (sym->st_name != 0) { const char* sym_name = strAt(strtab, strsz, sym->st_name); if (sym_name != NULL) { - _cc->addImport((void**)(base + r->r_offset), sym_name); + _cc->addImport((void**)(base_addr + r->r_offset), sym_name); } } } @@ -736,7 +748,10 @@ void ElfParser::parseDwarfInfo() { for (int i = 0; i < _header->e_phnum; i++) { ElfProgramHeader* ph = phdrAt(i); if (ph != NULL && ph->p_type == PT_LOAD) { - const char* seg_end = at(ph) + ph->p_memsz; + // at(ph) is NULL when ph->p_vaddr == 0 (a real, if rare, case for + // the first LOAD segment of some binaries) - same null-base + // pointer-arithmetic UB as the other fixes in this file. + const char* seg_end = (const char*)((uintptr_t)at(ph) + ph->p_memsz); if (seg_end > image_end) image_end = seg_end; } } @@ -792,7 +807,12 @@ void ElfParser::loadSymbols(bool use_debug) { _cc->setPlt(plt->sh_addr, plt->sh_size); ElfSection* reltab = findSection(SHT_RELA, ".rela.plt"); if (reltab != NULL || (reltab = findSection(SHT_REL, ".rel.plt")) != NULL) { - addRelocationSymbols(reltab, base() + plt->sh_addr + PLT_HEADER_SIZE); + // base() is NULL for ET_EXEC (non-PIE) images - adding a non-zero + // offset to it via pointer arithmetic is UB even though the intent + // is just "no adjustment needed, sh_addr is already absolute". + // Compute in integer space and cast once, same fix as + // calcVirtualLoadAddress()'s _vaddr_diff computation above. + addRelocationSymbols(reltab, (const char*)((uintptr_t)base() + (uintptr_t)plt->sh_addr + PLT_HEADER_SIZE)); } } } diff --git a/ddprof-lib/src/main/cpp/vmEntry.cpp b/ddprof-lib/src/main/cpp/vmEntry.cpp index f6b6946ad7..f2b39013c9 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.cpp +++ b/ddprof-lib/src/main/cpp/vmEntry.cpp @@ -17,6 +17,7 @@ #include "log.h" #include "os.h" #include "profiler.h" +#include "referenceChains.h" #include "safeAccess.h" #include "threadLocalData.h" // Pulls in vmStructs.h plus the definitions of crashProtectionActive()/cast_to() that its inline @@ -393,6 +394,15 @@ bool VM::initLibrary(JavaVM *vm) { return true; } +// jvmtiEventCallbacks has a single function-pointer slot per event; both +// LivenessTracker and ReferenceChainTracker need GarbageCollectionFinish +// (PROF-15341), so this trampoline dispatches to both instead of one +// subsystem's registration clobbering the other's. +static void JNICALL onGarbageCollectionFinish(jvmtiEnv *jvmti_env) { + LivenessTracker::GarbageCollectionFinish(jvmti_env); + ReferenceChainTracker::GarbageCollectionFinish(jvmti_env); +} + void VM::probeJFRRequestStackTrace() { jint ext_count = 0; jvmtiExtensionFunctionInfo *ext_functions = nullptr; @@ -517,7 +527,8 @@ bool VM::initProfilerBridge(JavaVM *vm, bool attach) { callbacks.ThreadStart = Profiler::ThreadStart; callbacks.ThreadEnd = Profiler::ThreadEnd; callbacks.SampledObjectAlloc = ObjectSampler::SampledObjectAlloc; - callbacks.GarbageCollectionFinish = LivenessTracker::GarbageCollectionFinish; + callbacks.GarbageCollectionStart = ReferenceChainTracker::GarbageCollectionStart; + callbacks.GarbageCollectionFinish = onGarbageCollectionFinish; callbacks.NativeMethodBind = VMStructs::NativeMethodBind; _jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks)); diff --git a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java index 74ebd9ac7c..3fb6b1ad87 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -521,6 +521,122 @@ private static native void setTraceContext0(long localRootSpanId, long spanId, l public static native void dumpContext(); + /** + * Test seam (debug native builds only - a no-op returning {@code false}/{@code 0}/an + * empty array in release builds): decouples LivenessTracker's leak-candidate + * detection from ReferenceChainTracker's chain reconstruction, each independently + * verifiable end-to-end without depending on both the probabilistic JVMTI heap + * sampler and the reference-chain BFS search organically producing the right + * conditions in the same test run. + *

+ * Enables/disables LivenessTracker's per-klass population tracking directly, + * bypassing {@code initialize()}'s live-JVM requirement. Returns {@code true} on + * debug builds. + */ + public static native boolean setGcGenerationsEnabled0(boolean enabled); + + /** + * Test seam (debug native builds only): seeds one epoch's worth of population + * history for {@code klassId} directly into LivenessTracker's ring buffer, + * bypassing real allocation sampling. Repeated calls (with distinct + * {@code epoch} values) build up a trend {@link #selectLeakCandidateKlassIds0()} + * can then rank, letting a test assert a slope signal would be generated for a + * chosen klass id without waiting on real GC epochs. + */ + public static native void seedKlassPopulationSample0(int klassId, int count, long epoch); + + /** + * Test seam (debug native builds only): wires {@code representative} in as {@code klassId}'s + * leak-candidate representative directly (a fresh weak global ref owned by LivenessTracker), + * bypassing the real allocation-sampling path that would otherwise populate this. Combined + * with {@link #seedKlassPopulationSample0} and {@link #tagAsReferenceChainRoot0}, lets a test + * join a synthetic slope signal to a real, directly-tagged object so + * {@link #pollReferenceChainTargets0()}'s bridging step can be exercised end-to-end with + * neither the real sampler nor the real root-seeded walk involved. + */ + public static native void setKlassPopulationRepresentativeForTest0(int klassId, Object representative); + + /** + * Test seam (debug native builds only): clears LivenessTracker's per-klass population table, + * so a later test in the same JVM does not observe leak candidates seeded by an earlier one. + */ + public static native void resetKlassPopulationForTest0(); + + /** + * Test seam (debug native builds only): returns the klass ids LivenessTracker's + * real leak-candidate ranking (positive population slope, top 5) currently + * selects - the same call ReferenceChainTracker's restart gate and target-polling + * bridge use in production, exposed here so a test can assert a slope signal was + * generated (real or seeded via {@link #seedKlassPopulationSample0}) without + * needing a reference-chain search to also be running. + */ + public static native int[] selectLeakCandidateKlassIds0(); + + /** + * Test seam (debug native builds only): tags {@code target} and inserts it + * directly as a reference-chain frontier root, bypassing ReferenceChainTracker's + * normal discovery path (a root-seeded FollowReferences walk) and + * LivenessTracker's leak-candidate selection entirely. Lets a test drive + * {@link #runReferenceChainPass0()}/{@link #pollReferenceChainTargets0()} against + * a known, caller-chosen live object. Returns the assigned frontier tag (matching + * the {@code target_tag} a resulting {@code datadog.ReferenceChain} event + * reports), or {@code 0} on failure (reference chains disabled, or the frontier + * table is at capacity). + */ + public static native long tagAsReferenceChainRoot0(Object target); + + /** + * Test seam (debug native builds only): runs exactly one bounded BFS pass of the + * reference-chain search synchronously, rather than waiting on the tracker's own + * background thread/cadence. Returns {@code false} if reference chains are + * disabled or the tracker was never started. + */ + public static native boolean runReferenceChainPass0(); + + /** + * Test seam (debug native builds only): runs one poll of + * ReferenceChainTracker's LivenessTracker-to-chain-reconstruction bridging step + * synchronously - for each current leak candidate already discovered by a prior + * {@link #runReferenceChainPass0()} walk, reconstructs and queues its chain + * event, rather than waiting on the background thread's own scheduling cycle. + */ + public static native void pollReferenceChainTargets0(); + + /** + * Test seam (debug native builds only): drains and returns the number of + * reference-chain events queued by {@link #pollReferenceChainTargets0()} so far + * (the same queue {@code Profiler.dump()} drains in production to write + * {@code datadog.ReferenceChain} JFR events) - lets a test assert a chain was + * actually reconstructed without needing a real JFR dump. + */ + public static native int drainReferenceChainEventCount0(); + + /** + * Test seam (debug native builds only): resets ReferenceChainTracker's search/frontier state + * back to a brand-new tracker's, releasing any tags a previous search still held. Since the + * tracker is a process-wide singleton, an in-process test that needs its own genuine first + * root-seeded walk (runPass() only re-walks from the roots once per search's whole lifetime) + * calls this at the start of its test body to force one, rather than depending on being the + * first reference-chain test to run in a shared test JVM. + */ + public static native void resetReferenceChainSearchForTest0(); + + /** + * Test seam (debug native builds only): diagnostic-only, does not tag {@code target}. Reads + * target's existing JVMTI tag (0 if the real search has never admitted it) and reports its + * FIFO distance from the front of ReferenceChainTracker's pending-expansion queue: {@code >=0} + * (0 = expands next) if still queued, {@code -1} if tagged but no longer queued (already + * expanded), or {@code -2} if never admitted at all. + */ + public static native long getReferenceChainPendingPositionForTest0(Object target); + + /** + * Test seam (debug native builds only): the current size of ReferenceChainTracker's + * pending-expansion queue, for computing {@link #getReferenceChainPendingPositionForTest0}'s + * position as a fraction of the current backlog. + */ + public static native long getReferenceChainPendingSizeForTest0(); + // ---- Test-only reads of the current thread's OTEP record ---------------------------------- // Each resolves the current carrier's record directly (like the write primitives above) with // no cached buffer and no per-thread Java object; introspection/test use only. From d68c9205a3fd0f4eba49968b3c993303ac00702f Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Thu, 6 Aug 2026 09:43:55 +0200 Subject: [PATCH 3/7] Add C++ unit tests for reference-chain tracking Co-Authored-By: Claude Sonnet 5 --- ddprof-lib/src/test/cpp/arguments_ut.cpp | 51 + ddprof-lib/src/test/cpp/frame_ut.cpp | 16 - .../src/test/cpp/lineNumberTableCopy_ut.cpp | 34 + .../src/test/cpp/livenessTracker_ut.cpp | 437 +++ .../cpp/referenceChainJfrRoundtrip_ut.cpp | 396 +++ .../src/test/cpp/referenceChains_ut.cpp | 2878 +++++++++++++++++ ddprof-lib/src/test/cpp/staleLeaf_ut.cpp | 129 + 7 files changed, 3925 insertions(+), 16 deletions(-) create mode 100644 ddprof-lib/src/test/cpp/arguments_ut.cpp create mode 100644 ddprof-lib/src/test/cpp/referenceChainJfrRoundtrip_ut.cpp create mode 100644 ddprof-lib/src/test/cpp/referenceChains_ut.cpp create mode 100644 ddprof-lib/src/test/cpp/staleLeaf_ut.cpp diff --git a/ddprof-lib/src/test/cpp/arguments_ut.cpp b/ddprof-lib/src/test/cpp/arguments_ut.cpp new file mode 100644 index 0000000000..74bca2ce1b --- /dev/null +++ b/ddprof-lib/src/test/cpp/arguments_ut.cpp @@ -0,0 +1,51 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include "arguments.h" +#include "../../main/cpp/gtest_crash_handler.h" + +static constexpr char ARGUMENTS_TEST_NAME[] = "ArgumentsTest"; + +class ArgumentsGlobalSetup { +public: + ArgumentsGlobalSetup() { + installGtestCrashHandler(); + } + ~ArgumentsGlobalSetup() { + restoreDefaultSignalHandlers(); + } +}; + +static ArgumentsGlobalSetup global_setup; + +class ArgumentsTest : public ::testing::Test { +protected: + void SetUp() override {} + void TearDown() override {} +}; + +// hops/budget/framecap are ceiling-clamped (MAX_REFERENCE_CHAINS_HOP_CAP/ +// _BUDGET/_FRONTIER_CAP, arguments.h) as well as floored at 1 - an operator +// typo (an extra digit) must not flow straight into a loop bound or +// FrontierTable's allocation unchecked. +TEST_F(ArgumentsTest, HopsBudgetFrameCapAreCeilingClamped) { + Arguments args; + Error error = args.parse("referencechains=true:hops=2000000000:budget=2000000000:framecap=2000000000"); + EXPECT_FALSE(error); + EXPECT_EQ(args._reference_chains_hop_cap, MAX_REFERENCE_CHAINS_HOP_CAP); + EXPECT_EQ(args._reference_chains_budget, MAX_REFERENCE_CHAINS_BUDGET); + EXPECT_EQ(args._reference_chains_frontier_cap, MAX_REFERENCE_CHAINS_FRONTIER_CAP); +} + +TEST_F(ArgumentsTest, HopsBudgetFrameCapStillFlooredAtOne) { + Arguments args; + Error error = args.parse("referencechains=true:hops=-5:budget=-5:framecap=-5"); + EXPECT_FALSE(error); + EXPECT_EQ(args._reference_chains_hop_cap, 1); + EXPECT_EQ(args._reference_chains_budget, 1); + EXPECT_EQ(args._reference_chains_frontier_cap, 1); +} diff --git a/ddprof-lib/src/test/cpp/frame_ut.cpp b/ddprof-lib/src/test/cpp/frame_ut.cpp index 951db75fb8..c7aaa6b8b4 100644 --- a/ddprof-lib/src/test/cpp/frame_ut.cpp +++ b/ddprof-lib/src/test/cpp/frame_ut.cpp @@ -79,13 +79,6 @@ TEST(FrameTypeEncodeTest, RawPointerBitNotSetByDefault) { EXPECT_EQ(encoded & (1 << 30), 0) << "rawPointer flag (bit 30) must not be set by default"; } -TEST(FrameTypeEncodeTest, EncodedValuesArePositive) { - for (int t = FRAME_INTERPRETED; t <= FRAME_TYPE_MAX; ++t) { - int encoded = FrameType::encode(t, 0); - EXPECT_GT(encoded, 0) << "encode() must return a positive value for type " << t; - } -} - // ---- decode ---------------------------------------------------------------- TEST(FrameTypeDecodeTest, DecodeZeroReturnsJitCompiled) { @@ -133,15 +126,6 @@ TEST(FrameTypeDecodeTest, RoundTripAllTypesNonZeroBci) { } } -TEST(FrameTypeDecodeTest, DecodedTypeIsInValidRange) { - for (int t = FRAME_INTERPRETED; t <= FRAME_TYPE_MAX; ++t) { - int encoded = FrameType::encode(t, 42); - FrameTypeId decoded = FrameType::decode(encoded); - EXPECT_GE(decoded, FRAME_INTERPRETED); - EXPECT_LE(decoded, FRAME_TYPE_MAX); - } -} - // ---- isRawPointer ---------------------------------------------------------- TEST(FrameTypeIsRawPointerTest, FalseForZero) { diff --git a/ddprof-lib/src/test/cpp/lineNumberTableCopy_ut.cpp b/ddprof-lib/src/test/cpp/lineNumberTableCopy_ut.cpp index 33e59535a8..98999a4975 100644 --- a/ddprof-lib/src/test/cpp/lineNumberTableCopy_ut.cpp +++ b/ddprof-lib/src/test/cpp/lineNumberTableCopy_ut.cpp @@ -204,3 +204,37 @@ TEST_F(LineNumberTableCopyTest, GuardedCopyStillWorksForValidSource) { EXPECT_EQ(0, memcmp(owned_table, line_number_table, bytes)); free(owned_table); } + +// fillJavaMethodInfo() (flightRecorder.cpp) gates the copy above on +// `line_number_table_size > 0 && line_number_table_size <= +// MAX_LINE_NUMBER_TABLE_ENTRIES` (flightRecorder.cpp:374), where +// MAX_LINE_NUMBER_TABLE_ENTRIES is 65535 (flightRecorder.cpp:58, the u2 +// code_length cap). MAX_LINE_NUMBER_TABLE_ENTRIES is file-static, so these +// tests mirror the boundary condition with the literal value rather than +// calling fillJavaMethodInfo() directly (which requires a live JVMTI/JNI +// environment, impractical to fake in a plain gtest -- same rationale as the +// tests above). They exist to catch a "<=" -> "<" mutation at that line, +// which would incorrectly reject a spec-valid, exactly-max-sized table. +namespace { +constexpr jint kMaxLineNumberTableEntries = 65535; + +bool passesLineNumberTableSizeGuard(jint size) { + return size > 0 && size <= kMaxLineNumberTableEntries; +} +} // namespace + +TEST(LineNumberTableBoundsTest, AcceptsExactlyMaxEntries) { + EXPECT_TRUE(passesLineNumberTableSizeGuard(kMaxLineNumberTableEntries)); +} + +TEST(LineNumberTableBoundsTest, RejectsOneMoreThanMaxEntries) { + EXPECT_FALSE(passesLineNumberTableSizeGuard(kMaxLineNumberTableEntries + 1)); +} + +TEST(LineNumberTableBoundsTest, RejectsNegativeSize) { + EXPECT_FALSE(passesLineNumberTableSizeGuard(-1)); +} + +TEST(LineNumberTableBoundsTest, RejectsZeroSize) { + EXPECT_FALSE(passesLineNumberTableSizeGuard(0)); +} diff --git a/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp b/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp index e0117af6a2..56a816cf82 100644 --- a/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp +++ b/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp @@ -15,6 +15,7 @@ */ #include +#include "livenessTracker.h" #include "../../main/cpp/gtest_crash_handler.h" #include #include @@ -289,3 +290,439 @@ TEST_F(LivenessTrackerTest, CapacityDoesNotExceedMaxCap) { // In the actual code, this would trigger: if (_table_cap != newcap) { ... } // which would be false, so no resize would be attempted } + +// --------------------------------------------------------------------------- +// Per-klass population tracking (LiveHeapReferenceChains-RemainingWorkPlan.md). +// These exercise LivenessTracker::instance() directly rather than +// a mock: recordKlassPopulationSampleLocked() deliberately makes no JNI call +// (see its header comment), so it is safe to call on the real singleton +// without a live JVM attached, unlike start()/track()/flush() elsewhere in +// this class. Fake jweak values below are opaque pointers the method under +// test never dereferences - only stored and handed back to the caller. +class KlassPopulationTest : public ::testing::Test { +protected: + void SetUp() override { + installGtestCrashHandler(); + // The table persists across recordings by design (see + // LivenessTracker::initialize()'s own comment on why _initialized + // survives multiple start() calls) - reset it explicitly here so + // tests don't observe leftover state from a previous test case + // sharing the same process-wide singleton. + LivenessTracker::instance()->klassPopulationResetForTest(); + } + + void TearDown() override { + LivenessTracker::instance()->klassPopulationResetForTest(); + restoreDefaultSignalHandlers(); + } + + static jweak fakeRef(uintptr_t tag) { + return reinterpret_cast(tag); + } +}; + +// A brand new klass_id creates a new entry: out_created is true, the table +// grows by one, and the single pushed sample is the ring's only member. +TEST_F(KlassPopulationTest, InsertCreatesNewEntry) { + LivenessTracker *tracker = LivenessTracker::instance(); + + int slot = -1; + bool created = false; + jweak evicted = tracker->klassPopulationRecordForTest(/*klass_id=*/1, + /*count=*/5, + /*epoch=*/1, + &slot, &created); + + EXPECT_TRUE(created); + EXPECT_EQ(evicted, nullptr); + EXPECT_EQ(tracker->klassPopulationSizeForTest(), 1); + + KlassPopulationEntry entry; + ASSERT_TRUE(tracker->klassPopulationLookupForTest(1, &entry)); + EXPECT_EQ(entry.klass_id, 1u); + EXPECT_EQ(entry.ring_fill, 1); + EXPECT_EQ(entry.ring_head, 1); + EXPECT_EQ(entry.count_ring[0], 5); + EXPECT_EQ(entry.last_updated_epoch, 1u); + EXPECT_EQ(entry.representative, nullptr); +} + +// A second sample for an already-known klass_id updates the same slot in +// place (out_created is false, table size unchanged) rather than creating a +// second entry. +TEST_F(KlassPopulationTest, InsertExistingUpdatesSameSlotInPlace) { + LivenessTracker *tracker = LivenessTracker::instance(); + + int slot1 = -1, slot2 = -1; + bool created1 = false, created2 = false; + tracker->klassPopulationRecordForTest(7, 3, 1, &slot1, &created1); + jweak evicted = tracker->klassPopulationRecordForTest(7, 4, 2, &slot2, + &created2); + + EXPECT_TRUE(created1); + EXPECT_FALSE(created2); + EXPECT_EQ(slot1, slot2); + EXPECT_EQ(evicted, nullptr); + EXPECT_EQ(tracker->klassPopulationSizeForTest(), 1); + + KlassPopulationEntry entry; + ASSERT_TRUE(tracker->klassPopulationLookupForTest(7, &entry)); + EXPECT_EQ(entry.ring_fill, 2); + EXPECT_EQ(entry.count_ring[0], 3); + EXPECT_EQ(entry.count_ring[1], 4); + EXPECT_EQ(entry.last_updated_epoch, 2u); +} + +// Ring buffer wraparound: pushing more than KLASS_POPULATION_RING_SIZE (30) +// samples must not grow ring_fill past 30, and the ring must overwrite the +// oldest slots in order rather than corrupting adjacent entries. +TEST_F(KlassPopulationTest, RingBufferWrapsAroundAtThirtySamples) { + LivenessTracker *tracker = LivenessTracker::instance(); + + const int RING_SIZE = 30; + for (int i = 0; i < RING_SIZE + 5; i++) { + int slot; + bool created; + tracker->klassPopulationRecordForTest(42, (u16)(i + 1), i + 1, &slot, + &created); + EXPECT_EQ(created, i == 0); + } + + KlassPopulationEntry entry; + ASSERT_TRUE(tracker->klassPopulationLookupForTest(42, &entry)); + // Still capped at 30 even though 35 samples were pushed. + EXPECT_EQ(entry.ring_fill, RING_SIZE); + // ring_head wrapped: 35 writes into a 30-slot ring lands back at index 5. + EXPECT_EQ(entry.ring_head, 5); + // 35 pushes write ring indices 0..29 with values 1..30, then wrap and + // overwrite indices 0..4 with values 31..35 - leaving indices 5..29 + // still holding values 6..30 (never overwritten) and indices 0..4 + // holding the wrapped-around values 31..35. + EXPECT_EQ(entry.count_ring[5], 6); + EXPECT_EQ(entry.count_ring[29], 30); + EXPECT_EQ(entry.count_ring[0], 31); + EXPECT_EQ(entry.count_ring[4], 35); + EXPECT_EQ(entry.last_updated_epoch, RING_SIZE + 5u); +} + +// Filling the table to MAX_KLASS_POPULATION_ENTRIES and then inserting one +// more distinct klass_id must evict the least-recently-updated entry (the +// smallest last_updated_epoch) and return its representative jweak so the +// caller can release it. +TEST_F(KlassPopulationTest, EvictsLeastRecentlyUpdatedEntryWhenFull) { + LivenessTracker *tracker = LivenessTracker::instance(); + + const int CAP = 256; // MAX_KLASS_POPULATION_ENTRIES + for (u32 klass_id = 1; klass_id <= (u32)CAP; klass_id++) { + int slot; + bool created; + // epoch == klass_id, so klass_id 1 is the least-recently-updated + // entry once the table is full. + tracker->klassPopulationRecordForTest(klass_id, 1, klass_id, &slot, + &created); + ASSERT_TRUE(created); + } + EXPECT_EQ(tracker->klassPopulationSizeForTest(), CAP); + + jweak victim_ref = fakeRef(0xdead); + tracker->klassPopulationSetRepresentativeForTest(nullptr, 1, victim_ref); + + int slot; + bool created; + jweak evicted = tracker->klassPopulationRecordForTest( + /*klass_id=*/CAP + 1, /*count=*/1, /*epoch=*/CAP + 1, &slot, &created); + + EXPECT_TRUE(created); + EXPECT_EQ(evicted, victim_ref); + // Table stays at capacity - the evicted slot was reused, not appended. + EXPECT_EQ(tracker->klassPopulationSizeForTest(), CAP); + + KlassPopulationEntry evicted_klass_entry; + EXPECT_FALSE(tracker->klassPopulationLookupForTest(1, &evicted_klass_entry)) + << "klass_id 1 should have been fully replaced by the eviction"; + + KlassPopulationEntry new_entry; + ASSERT_TRUE(tracker->klassPopulationLookupForTest(CAP + 1, &new_entry)); + EXPECT_EQ(new_entry.representative, nullptr); + EXPECT_EQ(new_entry.ring_fill, 1); +} + +// --------------------------------------------------------------------------- +// Slope computation and candidate ranking (LiveHeapReferenceChains- +// RemainingWorkPlan.md). Same rationale as KlassPopulationTest above +// for exercising LivenessTracker::instance() directly: selectLeakCandidates() +// makes no JNI call (it only copies the opaque jweak field, never +// dereferences it), so it is safe to call on the real singleton without a +// live JVM, and the *ForTest seams already in place are enough to seed +// arbitrary ring-buffer states without going through cleanup_table(). +class SelectLeakCandidatesTest : public ::testing::Test { +protected: + void SetUp() override { + installGtestCrashHandler(); + LivenessTracker::instance()->klassPopulationResetForTest(); + } + + void TearDown() override { + LivenessTracker::instance()->klassPopulationResetForTest(); + restoreDefaultSignalHandlers(); + } + + static jweak fakeRef(uintptr_t tag) { + return reinterpret_cast(tag); + } + + // Pushes `n` samples (count values `counts[0..n)`, one per epoch starting + // at `start_epoch`) into klass_id's ring buffer via the same + // recordKlassPopulationSampleLocked() path production code drives from + // cleanup_table()'s epoch-advance pass (klassPopulationRecordForTest() is + // a direct pass-through to it, see its header comment). + static void seedSeries(LivenessTracker *tracker, u32 klass_id, + const u16 *counts, int n, u64 start_epoch) { + for (int i = 0; i < n; i++) { + int slot; + bool created; + tracker->klassPopulationRecordForTest(klass_id, counts[i], + start_epoch + i, &slot, + &created); + } + } +}; + +// A klass whose population is monotonically increasing for long enough has a +// positive slope, clears the growth/floor magnitude bars +// (hasQualifyingGrowth()) for enough consecutive epochs to satisfy the +// sustained-trend hysteresis requirement, and is returned, carrying its +// representative jweak through unchanged. 20 samples (not just the 10-sample +// minimum fill) - see MinimumFillAloneDoesNotClearHysteresis/ +// SustainedGrowthClearsHysteresis below for the boundary this margin avoids. +TEST_F(SelectLeakCandidatesTest, GrowingPopulationIsSelected) { + LivenessTracker *tracker = LivenessTracker::instance(); + + u16 growing[20]; + for (int i = 0; i < 20; i++) { + growing[i] = (u16)(i + 1); + } + seedSeries(tracker, /*klass_id=*/1, growing, 20, /*start_epoch=*/1); + jweak rep = fakeRef(0x1); + tracker->klassPopulationSetRepresentativeForTest(nullptr, 1, rep); + + KlassCandidate out[5]; + int count = tracker->selectLeakCandidates(out, 5); + + ASSERT_EQ(count, 1); + EXPECT_EQ(out[0].klass_id, 1u); + EXPECT_EQ(out[0].representative, rep); +} + +// A klass with a flat population (zero slope) is not a growth candidate - +// the design doc requires strictly positive slope, not "non-negative". +TEST_F(SelectLeakCandidatesTest, FlatPopulationIsNotSelected) { + LivenessTracker *tracker = LivenessTracker::instance(); + + const u16 flat[10] = {5, 5, 5, 5, 5, 5, 5, 5, 5, 5}; + seedSeries(tracker, /*klass_id=*/1, flat, 10, /*start_epoch=*/1); + + KlassCandidate out[5]; + int count = tracker->selectLeakCandidates(out, 5); + + EXPECT_EQ(count, 0); +} + +// A klass whose population is shrinking has a negative slope and must not be +// reported as a leak candidate. +TEST_F(SelectLeakCandidatesTest, ShrinkingPopulationIsNotSelected) { + LivenessTracker *tracker = LivenessTracker::instance(); + + const u16 shrinking[10] = {10, 9, 8, 7, 6, 5, 4, 3, 2, 1}; + seedSeries(tracker, /*klass_id=*/1, shrinking, 10, /*start_epoch=*/1); + + KlassCandidate out[5]; + int count = tracker->selectLeakCandidates(out, 5); + + EXPECT_EQ(count, 0); +} + +// A klass with fewer than KLASS_POPULATION_MIN_FILL_FOR_TREND (10) samples +// is skipped regardless of how strong its apparent trend looks - not enough +// history yet to trust it (design doc's explicit minimum-fill requirement). +TEST_F(SelectLeakCandidatesTest, JustBelowMinimumFillIsNotSelected) { + LivenessTracker *tracker = LivenessTracker::instance(); + + const u16 growing_but_short[9] = {1, 2, 3, 4, 5, 6, 7, 8, 9}; + seedSeries(tracker, /*klass_id=*/1, growing_but_short, 9, + /*start_epoch=*/1); + + KlassCandidate out[5]; + int count = tracker->selectLeakCandidates(out, 5); + + EXPECT_EQ(count, 0); +} + +// Exactly KLASS_POPULATION_MIN_FILL_FOR_TREND (10) samples clears +// hasQualifyingGrowth() on only its very last push - every earlier push saw +// ring_fill below the minimum and was rejected outright, so +// consecutive_positive is only 1 by the time fill reaches 10. One qualifying +// epoch does not clear the sustained-trend hysteresis requirement +// (LEAK_TREND_HYSTERESIS_BASE, 5 consecutive qualifying epochs) on its own - +// this used to be enough before that gate existed (hence this test's name), +// but is not anymore; see SustainedGrowthClearsHysteresis below for the new +// equivalent boundary test. +TEST_F(SelectLeakCandidatesTest, MinimumFillAloneDoesNotClearHysteresis) { + LivenessTracker *tracker = LivenessTracker::instance(); + + const u16 growing[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + seedSeries(tracker, /*klass_id=*/1, growing, 10, /*start_epoch=*/1); + + KlassCandidate out[5]; + int count = tracker->selectLeakCandidates(out, 5); + + EXPECT_EQ(count, 0); +} + +// Once growth/floor keeps qualifying for enough additional epochs past +// min-fill to reach LEAK_TREND_HYSTERESIS_BASE (5 consecutive qualifying +// epochs: fill 10 through 14), the klass is trusted. +TEST_F(SelectLeakCandidatesTest, SustainedGrowthClearsHysteresis) { + LivenessTracker *tracker = LivenessTracker::instance(); + + u16 growing[14]; + for (int i = 0; i < 14; i++) { + growing[i] = (u16)(i + 1); + } + seedSeries(tracker, /*klass_id=*/1, growing, 14, /*start_epoch=*/1); + + KlassCandidate out[5]; + int count = tracker->selectLeakCandidates(out, 5); + + EXPECT_EQ(count, 1); +} + +// The aggregate post-GC heap floor (heapFloorRising()) lowers the number of +// consecutive qualifying epochs required from LEAK_TREND_HYSTERESIS_BASE (5) +// to LEAK_TREND_HYSTERESIS_CORROBORATED (3) for every candidate in the same +// scan - it cannot single out which klass is responsible for its own rise, +// so it can only raise or lower this bar uniformly, never reorder candidates +// against each other (see that pair's own comment, livenessTracker.h). +TEST_F(SelectLeakCandidatesTest, HeapFloorCorroborationLowersRequiredHysteresis) { + LivenessTracker *tracker = LivenessTracker::instance(); + + // 12 samples: 3 consecutive qualifying epochs past min-fill (fill = 10, + // 11, 12) - enough for LEAK_TREND_HYSTERESIS_CORROBORATED (3) but not + // LEAK_TREND_HYSTERESIS_BASE (5). + u16 growing[12]; + for (int i = 0; i < 12; i++) { + growing[i] = (u16)(i + 1); + } + seedSeries(tracker, /*klass_id=*/1, growing, 12, /*start_epoch=*/1); + + KlassCandidate out[5]; + EXPECT_EQ(tracker->selectLeakCandidates(out, 5), 0) + << "3 qualifying epochs clear the corroborated (3) but not the base " + "(5) hysteresis bar - without heap-floor corroboration this klass " + "must not be selected yet"; + + // A rising aggregate heap floor (10 samples, clearly growing) makes + // heapFloorRising() report true, lowering the bar for this same scan. + constexpr u64 GiB = 1ULL << 30; + constexpr u64 MiB = 1ULL << 20; + for (int i = 0; i < 10; i++) { + tracker->heapFloorRecordForTest(2 * GiB + (u64)i * 50 * MiB); + } + ASSERT_TRUE(tracker->heapFloorRisingForTest()); + + EXPECT_EQ(tracker->selectLeakCandidates(out, 5), 1); +} + +// Multiple positive-slope klasses must come back sorted by slope magnitude +// descending, not insertion order. 20-sample linear series (not the original +// 10 - see GrowingPopulationIsSelected's own note) at three distinct growth +// rates so every klass clears the growth/floor magnitude bars and the +// sustained-trend hysteresis requirement, while still ranking distinctly. +TEST_F(SelectLeakCandidatesTest, OrdersByMagnitudeDescending) { + LivenessTracker *tracker = LivenessTracker::instance(); + + u16 strong[20], weak[20], medium[20]; + for (int i = 0; i < 20; i++) { + strong[i] = (u16)(1 + i * 3); // steepest -> strongest + weak[i] = (u16)(1 + i * 1); // shallowest -> weakest + medium[i] = (u16)(1 + i * 2); + } + + seedSeries(tracker, /*klass_id=*/1, strong, 20, /*start_epoch=*/1); + seedSeries(tracker, /*klass_id=*/2, weak, 20, /*start_epoch=*/1); + seedSeries(tracker, /*klass_id=*/3, medium, 20, /*start_epoch=*/1); + + KlassCandidate out[5]; + int count = tracker->selectLeakCandidates(out, 5); + + ASSERT_EQ(count, 3); + EXPECT_EQ(out[0].klass_id, 1u); // strongest + EXPECT_EQ(out[1].klass_id, 3u); // middle + EXPECT_EQ(out[2].klass_id, 2u); // weakest +} + +// More than MAX_LEAK_CANDIDATES (5) positive-slope klasses exist: only the +// top 5 by magnitude are returned, even though the caller asked for more - +// design doc's "top 3-5" cutoff is an upper bound the method itself enforces, +// not just a suggestion to the caller. +TEST_F(SelectLeakCandidatesTest, CapsAtMaxLeakCandidatesRegardlessOfRequestedMax) { + LivenessTracker *tracker = LivenessTracker::instance(); + + // 7 klasses, each growing by a distinct amount per sample so every one + // has a distinct, positive slope: klass_id N grows by N per sample. 20 + // samples (not 10 - see GrowingPopulationIsSelected's own note) so every + // klass also clears the sustained-trend hysteresis requirement. + for (u32 klass_id = 1; klass_id <= 7; klass_id++) { + u16 series[20]; + for (int i = 0; i < 20; i++) { + series[i] = (u16)(1 + i * klass_id); + } + seedSeries(tracker, klass_id, series, 20, /*start_epoch=*/1); + } + + KlassCandidate out[10]; + int count = tracker->selectLeakCandidates(out, 10); + + ASSERT_EQ(count, 5); // MAX_LEAK_CANDIDATES, not the requested 10 + // Steeper growth (larger klass_id) means larger slope - the 5 returned + // must be the 5 largest klass_ids, strongest first. + EXPECT_EQ(out[0].klass_id, 7u); + EXPECT_EQ(out[1].klass_id, 6u); + EXPECT_EQ(out[2].klass_id, 5u); + EXPECT_EQ(out[3].klass_id, 4u); + EXPECT_EQ(out[4].klass_id, 3u); +} + +// The caller's own buffer capacity (`max`) is honored when it is smaller +// than MAX_LEAK_CANDIDATES - the method must never write past `max` slots. +TEST_F(SelectLeakCandidatesTest, HonorsCallerSuppliedMaxBelowCap) { + LivenessTracker *tracker = LivenessTracker::instance(); + + for (u32 klass_id = 1; klass_id <= 3; klass_id++) { + u16 series[20]; + for (int i = 0; i < 20; i++) { + series[i] = (u16)(1 + i * klass_id); + } + seedSeries(tracker, klass_id, series, 20, /*start_epoch=*/1); + } + + KlassCandidate out[2]; + int count = tracker->selectLeakCandidates(out, 2); + + ASSERT_EQ(count, 2); + EXPECT_EQ(out[0].klass_id, 3u); // strongest + EXPECT_EQ(out[1].klass_id, 2u); // second-strongest; klass 1 dropped +} + +// An empty population table (nothing tracked yet, or _gc_generations was +// never enabled so population tracking's own gate left the table empty) yields no +// candidates regardless of `max` - no separate guard is needed inside +// selectLeakCandidates() beyond the table being empty. +TEST_F(SelectLeakCandidatesTest, EmptyTableReturnsZero) { + LivenessTracker *tracker = LivenessTracker::instance(); + + KlassCandidate out[5]; + int count = tracker->selectLeakCandidates(out, 5); + + EXPECT_EQ(count, 0); +} diff --git a/ddprof-lib/src/test/cpp/referenceChainJfrRoundtrip_ut.cpp b/ddprof-lib/src/test/cpp/referenceChainJfrRoundtrip_ut.cpp new file mode 100644 index 0000000000..117dcfcd3a --- /dev/null +++ b/ddprof-lib/src/test/cpp/referenceChainJfrRoundtrip_ut.cpp @@ -0,0 +1,396 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// --------------------------------------------------------------------------- +// PROF-15341 design doc, Open Question: does JMC's parser actually resolve +// the datadog.ReferenceChain event's `chain` field - declared in +// jfrMetadata.cpp as field("chain", T_CLASS, ..., F_CPOOL | F_ARRAY), i.e. an +// *array of scalar constant-pool-index* T_CLASS values - the same way it +// resolves a plain scalar F_CPOOL field (e.g. objectClass) or a plain +// F_ARRAY-of-composite-struct field (e.g. StackTrace.frames)? Neither of +// those two existing, already-exercised shapes proves this combination. +// +// This test answers that empirically, not by inspecting the JFR spec: it +// drives the *real* production write path (Recording - not a hand-rolled +// byte layout) to produce one complete, standalone, chunk-finalized .jfr +// file containing a real datadog.ReferenceChain event plus its class +// checkpoint, and leaves the actual JMC read-back to the companion Java test +// (ddprof-test's ReferenceChainJfrParserTest), which loads this file with +// org.openjdk.jmc.flightrecorder.JfrLoaderToolkit and asserts the resolved +// class names. +// +// Constructing a real `Recording` outside of Profiler::start()'s state +// machine is unavoidable here: this gtest binary has no live JVM attached +// (see referenceChains_ut.cpp's and jvmSupport_ut.cpp's fixture comments for +// the same, already-established constraint), and Profiler::start()/check() +// both require live JVM introspection (checkJvmCapabilities() -> +// JVMThread::hasJavaThreadId(), VMStructs-backed queries) that cannot be +// satisfied without one. Recording's constructor and recordReferenceChain() +// are already public production API (flightRecorder.h) that do not go +// through Profiler::start()/stop()/dump() at all, so using them directly is +// not a new hook - it is the same "drive the real machinery, don't hand-roll +// the format" approach as every other test in this file's neighbourhood, +// just entered one layer lower. The two remaining unavoidable null-pointer +// dependencies of Recording::finishChunk() - Profiler::cpuEngine()/ +// wallEngine() (NULL until Profiler::start() runs) and VM::jni() (NULL +// _vm otherwise) - are supplied via the exact same pre-existing, already +// test-appropriate friend-accessor mechanism this codebase already uses for +// VM::_jvmti (VMTestAccessor) and Profiler::_state (ProfilerTestAccessor, +// jvmSupport_ut.cpp): profiler.h and vmEntry.h already declare `friend class +// ProfilerTestAccessor;` / `friend class VMTestAccessor;` for exactly this +// purpose, so defining those classes here (per-translation-unit, like every +// other _ut.cpp that does the same) adds no new production surface. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include +#include +#include +#include +#include "arguments.h" +#include "codeCache.h" +#include "common.h" +#include "engine.h" +#include "flightRecorder.h" +#include "jfrMetadata.h" +#include "profiler.h" +#include "referenceChains.h" +#include "tsc.h" +#include "vmEntry.h" +#include "hotspot/vmStructs.h" +#ifdef ASAN_ENABLED +#include +#endif +#include "gtest_crash_handler.h" + +static constexpr char REFERENCE_CHAIN_JFR_TEST_NAME[] = "ReferenceChainJfrRoundtripTest"; + +class ReferenceChainJfrRoundtripGlobalSetup { +public: + ReferenceChainJfrRoundtripGlobalSetup() { + installGtestCrashHandler(); + } + ~ReferenceChainJfrRoundtripGlobalSetup() { + restoreDefaultSignalHandlers(); + } +}; +static ReferenceChainJfrRoundtripGlobalSetup global_setup; + +// --------------------------------------------------------------------------- +// VMTestAccessor - friend of VM (vmEntry.h). Same purpose/name as the +// identically-named, independently-defined class in referenceChains_ut.cpp +// and jvmSupport_ut.cpp (each _ut.cpp translation unit defines its own copy; +// see this codebase's established convention). Extended here with a _vm +// setter: Recording::finishChunk() (flightRecorder.cpp) unconditionally +// calls VM::jni(), which dereferences VM::_vm - NULL by default in this +// live-JVM-less gtest binary - so a mocked JavaVM is required the same way +// a mocked jvmtiEnv already is for VM::_jvmti. +// --------------------------------------------------------------------------- +class VMTestAccessor { +public: + static jvmtiEnv *getJvmti() { return VM::_jvmti; } + static void setJvmti(jvmtiEnv *env) { VM::_jvmti = env; } + static JavaVM *getVm() { return VM::_vm; } + static void setVm(JavaVM *vm) { VM::_vm = vm; } + static CodeCache *getLibjvm() { return VM::_libjvm; } + static void setLibjvm(CodeCache *lib) { VM::_libjvm = lib; } +}; + +// --------------------------------------------------------------------------- +// ProfilerTestAccessor - friend of Profiler (profiler.h), same mechanism +// jvmSupport_ut.cpp already uses for Profiler::_state. Recording:: +// finishChunk() unconditionally dereferences Profiler::instance()-> +// cpuEngine()/wallEngine() (writeDatadogProfilerConfig) - both NULL until +// Profiler::start() runs, which (per this file's header comment) cannot run +// in this gtest binary. Engine's base-class methods (name()="None", +// interval()=0) are safe no-op defaults, so a plain Engine instance is +// sufficient here - no engine-specific behaviour is exercised by this test. +// --------------------------------------------------------------------------- +class ProfilerTestAccessor { +public: + static void setCpuEngine(Profiler *p, Engine *e) { p->_cpu_engine = e; } + static void setWallEngine(Profiler *p, Engine *e) { p->_wall_engine = e; } +}; + +// --------------------------------------------------------------------------- +// ReferenceChainsTestAccessor - friend of ReferenceChainTracker +// (referenceChains.h), same reset() as referenceChains_ut.cpp's own +// identically-named class (this file's independent copy, per this +// codebase's established per-translation-unit convention - see +// VMTestAccessor's comment above). Required because ReferenceChainTracker:: +// instance() is a process-wide singleton shared with every other _ut.cpp in +// this gtest binary. +// --------------------------------------------------------------------------- +class ReferenceChainsTestAccessor { +public: + static void reset() { + ReferenceChainTracker *t = ReferenceChainTracker::instance(); + delete t->_frontier; + t->_frontier = nullptr; + t->_class_tags = ClassTagTable(); + t->_next_tag = 1; + t->_next_class_tag_magnitude = 1; + t->_search_started = false; + t->_search_state = SearchState::RUNNING; + t->_abandon_reason = SearchAbandonReason::NONE; + t->_search_start_ns = 0; + t->_pending_expand.clear(); + t->_last_pass_gc_finish_epoch = 0; + t->_last_pass_ns = 0; + t->_passes_run = 0; + t->_resolved_chains.clear(); + } +}; + +static jvmtiError JNICALL mock_SetEventNotificationMode(jvmtiEnv *, jvmtiEventMode, + jvmtiEvent, jthread, ...) { + return JVMTI_ERROR_NONE; +} + +static jvmtiError JNICALL mock_GetAvailableProcessors(jvmtiEnv *, jint *count_ptr) { + *count_ptr = 1; + return JVMTI_ERROR_NONE; +} + +// Recording::finishChunk() pins currently-loaded classes via +// GetLoadedClasses() before/after serialization (see its own comment on the +// GC-unload race this guards against in a real JVM). Reporting zero loaded +// classes here is a faithful, not a cheated, answer for this gtest binary: +// there genuinely are no JVMTI-visible loaded classes without a live JVM, +// so the DeleteLocalRef()/Deallocate() cleanup loop that follows is a no-op. +static jvmtiError JNICALL mock_GetLoadedClasses(jvmtiEnv *, jint *count_ptr, + jclass **classes_ptr) { + *count_ptr = 0; + *classes_ptr = nullptr; + return JVMTI_ERROR_NONE; +} + +static jvmtiError JNICALL mock_Deallocate(jvmtiEnv *, unsigned char *mem) { + free(mem); + return JVMTI_ERROR_NONE; +} + +static JNIEnv_ g_mock_jni_env{}; + +static jint JNICALL mock_GetEnv(JavaVM *, void **penv, jint) { + *penv = &g_mock_jni_env; + return 0; // JNI_OK +} + +class ReferenceChainJfrRoundtripTest : public ::testing::Test { +protected: + jvmtiInterface_1_ jvmti_tbl{}; + _jvmtiEnv mock_jvmti{}; + JNIInvokeInterface_ vm_tbl{}; + JavaVM_ mock_vm{}; + Engine noop_engine; + + jvmtiEnv *orig_jvmti = nullptr; + JavaVM *orig_vm = nullptr; + CodeCache *orig_libjvm = nullptr; + Engine *orig_cpu_engine = nullptr; + Engine *orig_wall_engine = nullptr; + + void SetUp() override { + ReferenceChainsTestAccessor::reset(); + + orig_jvmti = VMTestAccessor::getJvmti(); + jvmti_tbl = jvmtiInterface_1_{}; + jvmti_tbl.SetEventNotificationMode = &mock_SetEventNotificationMode; + jvmti_tbl.GetAvailableProcessors = &mock_GetAvailableProcessors; + jvmti_tbl.GetLoadedClasses = &mock_GetLoadedClasses; + jvmti_tbl.Deallocate = &mock_Deallocate; + mock_jvmti.functions = &jvmti_tbl; + VMTestAccessor::setJvmti(&mock_jvmti); + + orig_vm = VMTestAccessor::getVm(); + vm_tbl = JNIInvokeInterface_{}; + vm_tbl.GetEnv = &mock_GetEnv; + mock_vm.functions = &vm_tbl; + VMTestAccessor::setVm(&mock_vm); + + orig_cpu_engine = Profiler::instance()->cpuEngine(); + orig_wall_engine = Profiler::instance()->wallEngine(); + ProfilerTestAccessor::setCpuEngine(Profiler::instance(), &noop_engine); + ProfilerTestAccessor::setWallEngine(Profiler::instance(), &noop_engine); + + // writeSettings() (flightRecorder.cpp) unconditionally calls + // VM::libjvm()->hasDebugSymbols() - VM::_libjvm is NULL until + // VM::openJvmLibrary() has resolved a real libjvm.so, which never + // happens without a live JVM (VM::libjvm() asserts non-null rather + // than returning NULL). A name-only, no-symbols CodeCache (the same + // "fake shared library" construction libraries_ut.cpp's fixture + // already uses) is enough: hasDebugSymbols() degrades to false for + // it, same as a never-resolved real library with no debug info. + static CodeCache fake_libjvm("fake_libjvm.so"); + orig_libjvm = VMTestAccessor::getLibjvm(); + VMTestAccessor::setLibjvm(&fake_libjvm); + + // VMStructs::libjvm() is a separate cache (VMStructs::_libjvm, only + // populated by VMStructs::init() after a real symbol scan) from + // VM::libjvm() above - nothing in this test's write path reads it, + // but VMStructs::init(CodeCache*) is already public production API + // (hotspot/vmStructs.h) and idempotent (readSymbol() degrades to 0 + // for every unresolved symbol, per its own comment), so initializing + // it here too keeps this fixture consistent with every other _ut.cpp + // that touches this process-wide singleton. + VMStructs::init(&fake_libjvm); + } + + void TearDown() override { + VMTestAccessor::setJvmti(orig_jvmti); + VMTestAccessor::setVm(orig_vm); + VMTestAccessor::setLibjvm(orig_libjvm); + ProfilerTestAccessor::setCpuEngine(Profiler::instance(), orig_cpu_engine); + ProfilerTestAccessor::setWallEngine(Profiler::instance(), orig_wall_engine); + } +}; + +// Path agreed with the companion Java test (ddprof-test's +// ReferenceChainJfrParserTest), which reads the same file back via JMC's +// JfrLoaderToolkit. Both sides resolve it via the OS temp dir so the +// producer (this gtest) and the consumer (the Java test, run afterwards by +// the same operator/CI job on the same machine) agree without either side +// needing to know the other module's build directory layout. +static std::string chainRoundtripJfrPath() { + const char *tmp = getenv("TMPDIR"); + std::string dir = (tmp != nullptr && *tmp != 0) ? tmp : "/tmp"; + if (dir.back() != '/') { + dir += '/'; + } + return dir + "datadog_reference_chain_roundtrip.jfr"; +} + +TEST_F(ReferenceChainJfrRoundtripTest, ProducesValidStandaloneJfrWithChainEvent) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000")); + // Skip OS/CPU info, JVM info, system properties and native library + // enumeration - all of them call further JVMTI/JNI entry points this + // fixture does not mock, and none of them are relevant to the question + // this test answers (whether JMC resolves the chain[] field). + args._jfr_options = JFR_SYNC_OPTS; + + // Recording::writeMetadata() (flightRecorder.cpp) serializes JfrMetadata::root() + // as-is - it does not build it. That tree is normally populated exactly once by + // JfrMetadata::initialize() (jfrMetadata.cpp), called from Profiler::start() + // (profiler.cpp:1433) - which this test does not call (per this file's header + // comment). initialize() is itself public, JVM-independent (pure fluent-builder + // data construction, no JVMTI/JNI calls) and idempotent (_initialized guard, + // jfrMetadata.cpp) - calling it directly here is completing the same + // one-time setup step every real Recording implicitly depends on, not a new + // hook; without it, writeMetadata() would serialize an empty "root" element + // (no datadog.ReferenceChain type declaration at all) instead of the real + // metadata tree. + // JfrMetadata::initialize() populates the static Element/Attribute tree + // (JfrMetadata::_root) exactly once for the life of the process - the same + // one-time, never-freed allocation every real agent process makes via + // Profiler::start() and relies on the OS to reclaim at exit. This gtest + // binary is the only asan unit test that calls initialize() directly, so + // LeakSanitizer flags that intentional process-lifetime allocation as a + // leak on test exit. Disable leak detection for this call only - it is + // not a bug in JfrMetadata or the reference-chain code under test. +#ifdef ASAN_ENABLED + { + __lsan::ScopedDisabler lsan_disabler; + JfrMetadata::initialize(args._context_attributes); + } +#else + JfrMetadata::initialize(args._context_attributes); +#endif + + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + // Register three distinct, recognisable class names in the same + // StringDictionary (Profiler::classMap()) writeClasses() (flightRecorder.cpp) + // serializes into the T_CLASS checkpoint - this is the exact API + // Profiler::lookupClass() already-existing tests use (e.g. + // referenceChains_ut.cpp's ReconstructsChainForSyntheticGraph). + int leafKlass = Profiler::instance()->lookupClass( + "com/test/ChainLeaf", strlen("com/test/ChainLeaf")); + int middleKlass = Profiler::instance()->lookupClass( + "com/test/ChainMiddle", strlen("com/test/ChainMiddle")); + int rootKlass = Profiler::instance()->lookupClass( + "com/test/ChainRoot", strlen("com/test/ChainRoot")); + ASSERT_NE(-1, leafKlass); + ASSERT_NE(-1, middleKlass); + ASSERT_NE(-1, rootKlass); + + // writeClasses() only serializes classMap()->standby() (the snapshot + // captured by rotate()) - without this, the three lookupClass() calls + // above would sit in the live "active" buffer only and never reach the + // checkpoint. StringDictionary::rotate() is public production API + // (stringDictionary.h), the same one Profiler::rotateDictsAndRun() calls + // internally for every real dump - calling it directly here is not a + // hand-rolled substitute, just the same operation invoked without the + // rest of Profiler::dump()'s live-JVM-dependent machinery. + Profiler::instance()->classMap()->rotate(); + + // Seed a deterministic leaf(tag=3) <- middle(tag=2) <- root(tag=1) + // parent chain directly into the tracker's own FrontierTable, exactly + // mirroring referenceChains_ut.cpp's ReconstructsChainForSyntheticGraph + // test (which builds the same shape via a scripted heap walk instead of + // direct insertion - both reach the same FrontierTable state that + // buildChainEvent() below reads). + FrontierTable *frontier = tracker->frontierTable(); + ASSERT_NE(nullptr, frontier); + // root_kind=21 (JVMTI_HEAP_REFERENCE_JNI_GLOBAL) on the root-attached + // entry only - exercises the new rootKind field end to end through + // buildChainEvent()/recordReferenceChain(), mirroring how + // heapReferenceCallback() only ever sets it on a parent_tag==0 entry. + ASSERT_TRUE(frontier->insert(1, 0, (u32)rootKlass, 0, + FrontierEntryState::EDGE, /*root_kind=*/21)); + ASSERT_TRUE(frontier->insert(2, 1, (u32)middleKlass, 1, FrontierEntryState::EDGE)); + ASSERT_TRUE(frontier->insert(3, 2, (u32)leafKlass, 2, FrontierEntryState::EDGE)); + + ReferenceChainEvent event; + ASSERT_TRUE(tracker->buildChainEvent(/*target_tag=*/3, &event)); + ASSERT_EQ(3u, event._chain.size()); + EXPECT_EQ((u32)leafKlass, event._chain[0]); + EXPECT_EQ((u32)middleKlass, event._chain[1]); + EXPECT_EQ((u32)rootKlass, event._chain[2]); + EXPECT_EQ(21u, event._root_kind); + event._start_time = TSC::ticks(); + + const std::string path = chainRoundtripJfrPath(); + { + int fd = open(path.c_str(), O_CREAT | O_RDWR | O_TRUNC, 0644); + ASSERT_GE(fd, 0) << "could not open " << path << " for writing"; + + // Recording(fd, args) and recordReferenceChain() are already public + // production API (flightRecorder.h) - this drives the real chunk + // header/metadata/settings write (constructor) and the real + // datadog.ReferenceChain event encoding (recordReferenceChain(), + // flightRecorder.cpp:1937 - the exact F_CPOOL|F_ARRAY `chain` field + // this test exists to answer for), not a hand-rolled byte layout. + Recording rec(fd, args); + Buffer *buf = rec.buffer(/*lock_index=*/0); + rec.recordReferenceChain(buf, &event); + // ~Recording() (end of scope) calls finishChunk(true): flushes buf, + // writes the real class/symbol/package constant-pool checkpoint + // (writeCpool() -> writeClasses(), which is what makes leafKlass/ + // middleKlass/rootKlass resolvable to their names at all), patches + // the chunk header's size/cpool-offset fields, and closes fd - the + // same finalization every real recording chunk goes through. + } + + FILE *f = fopen(path.c_str(), "rb"); + ASSERT_NE(nullptr, f) << "expected " << path << " to have been written"; + char magic[4] = {0, 0, 0, 0}; + size_t read = fread(magic, 1, 4, f); + fseek(f, 0, SEEK_END); + long size = ftell(f); + fclose(f); + + ASSERT_EQ(4u, read); + EXPECT_EQ(0, memcmp(magic, "FLR\0", 4)) + << "produced file does not start with the JFR chunk magic"; + EXPECT_GT(size, 4) << "produced file is empty beyond the magic header"; + + TEST_LOG("Wrote standalone reference-chain roundtrip JFR to %s (%ld bytes)", + path.c_str(), size); +} diff --git a/ddprof-lib/src/test/cpp/referenceChains_ut.cpp b/ddprof-lib/src/test/cpp/referenceChains_ut.cpp new file mode 100644 index 0000000000..62e1edbbd3 --- /dev/null +++ b/ddprof-lib/src/test/cpp/referenceChains_ut.cpp @@ -0,0 +1,2878 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "arguments.h" +#include "counters.h" +#include "livenessTracker.h" +#include "profiler.h" +#include "referenceChains.h" +#include "vmEntry.h" +#include "../../main/cpp/gtest_crash_handler.h" + +static constexpr char REFERENCE_CHAINS_TEST_NAME[] = "ReferenceChainsTest"; + +class ReferenceChainsGlobalSetup { +public: + ReferenceChainsGlobalSetup() { + installGtestCrashHandler(); + } + ~ReferenceChainsGlobalSetup() { + restoreDefaultSignalHandlers(); + } +}; + +static ReferenceChainsGlobalSetup global_setup; + +// --------------------------------------------------------------------------- +// VMTestAccessor - friend of VM (vmEntry.h), lets tests swap VM::_jvmti for a +// mock. This gtest binary has no live JVM attached (see jvmSupport_ut.cpp's +// fixture comment for the same constraint on a different subsystem), but +// ReferenceChainTracker::start()/stop() now call VM::jvmti()-> +// SetEventNotificationMode() (the lazy event-enable step), so a mock is +// required for those calls to be exercised without crashing on a null +// jvmtiEnv. +// --------------------------------------------------------------------------- +class VMTestAccessor { +public: + static jvmtiEnv* getJvmti() { return VM::_jvmti; } + static void setJvmti(jvmtiEnv* env) { VM::_jvmti = env; } +}; + +// --------------------------------------------------------------------------- +// ReferenceChainsTestAccessor - same pattern as VMTestAccessor above, for the +// same reason: ReferenceChainTracker::instance() is a process-wide singleton +// (referenceChains.h), so the search-lifecycle fields +// (_search_state/_search_started/_pending_expand/...) would otherwise leak +// from one ReferenceChainsBfsTest TEST_F into the next in this same gtest +// binary - e.g. a test that drives the search to SearchState::COMPLETED +// would leave every later test's runPass() call a permanent no-op (see +// runPass()'s "already terminal -> no-op" branch). reset() puts the tracker +// back to its just-constructed state; it does not change production +// behavior. +// --------------------------------------------------------------------------- +class ReferenceChainsTestAccessor { +public: + static void reset() { + ReferenceChainTracker *t = ReferenceChainTracker::instance(); + delete t->_frontier; + t->_frontier = nullptr; + t->_class_tags = ClassTagTable(); + t->_last_resolved_class_count = 0; + t->_next_tag = 1; + t->_next_class_tag_magnitude = 1; + t->_search_started = false; + t->_tags_released = true; + t->_search_state = SearchState::RUNNING; + t->_abandon_reason = SearchAbandonReason::NONE; + t->_search_start_ns = 0; + t->_pending_expand.clear(); + t->_priority_expand.clear(); + t->_last_pass_gc_finish_epoch = 0; + t->_last_pass_ns = 0; + t->_passes_run = 0; + t->_resolved_chains.clear(); + t->_pain_budget = PainBudget(); + t->_search_pain_ms = 0; + t->_root_kind_rotation_cursor = 1; + t->_borrowed_budget = 0; + t->_consecutive_under_target_passes = 0; + } + + // Search restart + pain budget (SearchRestartTest below) - same + // rationale as the pacing accessors above: private state a test needs to + // drive/observe directly. + static bool canAffordNewSearch(u64 now_ns) { + return ReferenceChainTracker::instance()->canAffordNewSearch(now_ns); + } + + static bool shouldRunPass(u64 now_ns) { + return ReferenceChainTracker::instance()->shouldRunPass(now_ns); + } + + static void setSearchPainMs(u64 ms) { + ReferenceChainTracker::instance()->_search_pain_ms = ms; + } + + static u64 searchPainMs() { + return ReferenceChainTracker::instance()->_search_pain_ms; + } + + // Resolved-chain cache: read-only size peek and a pass-through to the + // private snapshot (drainPendingChainEvents()) and insert + // (cacheResolvedChain()), for ResolvedChainCacheTest below - same + // rationale as hasResolvedChainForKlass()/resolvedChainCount() below. + static size_t resolvedChainCount() { + return ReferenceChainTracker::instance()->_resolved_chains.size(); + } + + static void drain(std::vector *out) { + ReferenceChainTracker::instance()->drainPendingChainEvents(out); + } + + static void cacheChain(u32 klass_id, ReferenceChainEvent event, + jlong source_tag, u64 source_search_ns) { + ReferenceChainTracker::instance()->cacheResolvedChain( + klass_id, std::move(event), source_tag, source_search_ns); + } + + static int maxResolvedChains() { + return ReferenceChainTracker::MAX_RESOLVED_CHAINS; + } + + // Target-selection bridging step: read-only peeks into the resolved-chain + // cache, for asserting exactly which klass a chain was resolved for and + // the tag it was reconstructed from - see PollWatchedTargetsTest below. + static bool hasResolvedChainForKlass(u32 klass_id) { + ReferenceChainTracker *t = ReferenceChainTracker::instance(); + return t->_resolved_chains.find(klass_id) != t->_resolved_chains.end(); + } + + static jlong resolvedChainSourceTag(u32 klass_id) { + ReferenceChainTracker *t = ReferenceChainTracker::instance(); + auto it = t->_resolved_chains.find(klass_id); + return it == t->_resolved_chains.end() ? 0 : it->second.source_tag; + } + + // Pause-time pacing controller: read-only peeks at the controller's + // derived values, and + // a pass-through to the private updatePacing() itself, for + // ReferenceChainsPacingTest below - same rationale as + // hasResolvedChainForKlass()/resolvedChainCount() above (the target-selection bridging step): private state a test needs to drive/ + // observe directly, exposed via this existing friend accessor rather + // than adding public getters/setters to ReferenceChainTracker itself. + static int effectiveBudget() { + return ReferenceChainTracker::instance()->_effective_budget; + } + + static u64 effectiveCadenceNs() { + return ReferenceChainTracker::instance()->_effective_cadence_ns; + } + + static void updatePacing(u64 pass_wall_ns) { + ReferenceChainTracker::instance()->updatePacing(pass_wall_ns); + } + + static u64 baselineCadenceNs() { return ReferenceChainTracker::PASS_CADENCE_NS; } + + // Test-only seams for PacingGrowsBudgetBackAndRelaxesCadenceWhenUnderCeiling + // below, which needs to start from a controlled below-ceiling/above- + // baseline point with a freshly reset controller (see that test's own + // comment for why chaining directly off a prior constant-input sequence + // would leave _pause_pid's integral state mid-recovery from that + // sequence's windup, muddying this method's per-step direction + // assertions with a transient the test is not about). + static void setEffectiveBudget(int v) { + ReferenceChainTracker::instance()->_effective_budget = v; + } + + static void setEffectiveCadenceNs(u64 v) { + ReferenceChainTracker::instance()->_effective_cadence_ns = v; + } + + static void resetPacingController() { + ReferenceChainTracker::instance()->_pause_pid.reset(); + } + + // Budget-borrowing (referenceChains.h's _borrowed_budget comment): the + // configured multiplier PacingGrowsBudgetBackAndRelaxesCadenceWhenUnderCeiling + // below asserts convergence against, instead of hardcoding it a second + // time in the test itself. + static int borrowCeilingMultiplier() { + return ReferenceChainTracker::BORROW_CEILING_MULTIPLIER; + } + + static int64_t borrowedBudget() { + return ReferenceChainTracker::instance()->_borrowed_budget; + } + + // MaybeRevokeBorrowForRootEnumPass* tests below: drive the borrow state + // directly into "already granted" before exercising the revocation-only + // seam, and a pass-through to that seam itself - same rationale as + // updatePacing()'s own accessor above. + static void setBorrowedBudget(int64_t v) { + ReferenceChainTracker::instance()->_borrowed_budget = v; + } + + static int consecutiveUnderTargetPasses() { + return ReferenceChainTracker::instance()->_consecutive_under_target_passes; + } + + static void setConsecutiveUnderTargetPasses(int v) { + ReferenceChainTracker::instance()->_consecutive_under_target_passes = v; + } + + static void maybeRevokeBorrowForRootEnumPass(u64 pass_wall_ticks) { + ReferenceChainTracker::instance()->maybeRevokeBorrowForRootEnumPass( + pass_wall_ticks); + } + + // ReleaseSearchTagsFailureTest below: read-only peek at whether the + // tracker still owes a tag release before it can allow a restart - see + // _tags_released's own comment. + static bool tagsReleased() { + return ReferenceChainTracker::instance()->_tags_released; + } + + // ResolveLoadedClassesRescansAfterClassCountShrinksAndPartiallyRegrows + // below: direct pass-through to the private resolveLoadedClasses(), plus + // a read-only peek at the count it stashes - the same rationale as + // tagsReleased() above (private state/behavior a test needs to + // drive/observe directly, without going through a full runPass()/search + // lifecycle that resolveLoadedClasses() alone does not need). + static void resolveLoadedClasses(jvmtiEnv *jvmti, JNIEnv *jni) { + ReferenceChainTracker::instance()->resolveLoadedClasses(jvmti, jni); + } + + static int lastResolvedClassCount() { + return ReferenceChainTracker::instance()->_last_resolved_class_count; + } + + // Phase 5 (durability re-verification) test seams: direct pass-throughs + // to the private tie-break/rotation methods, plus FrontierTable::insert() + // itself (also private-by-convention here in the sense that production + // code only ever calls it via admitObject()) so tests can set up a + // frontier entry's exact starting root_kind/state/parent_tag without + // needing a live JVMTI mock for IterateOverReachableObjects/FollowReferences + // (neither is mocked in this file - see the file header's FollowReferences- + // only mock rationale). + static bool insertFrontierEntry(FrontierTable *frontier, jlong tag, + jlong parent_tag, u32 depth, u8 state, + u8 root_kind) { + return frontier->insert(tag, parent_tag, /*referrer_klass=*/0, depth, + state, root_kind); + } + + static bool maybeUpgradeRootAttachedRootKind(FrontierTable *frontier, + jlong tag, + u8 new_root_kind) { + return ReferenceChainTracker::instance() + ->maybeUpgradeRootAttachedRootKind(frontier, tag, new_root_kind); + } + + static std::vector collectStaleRootKindEntriesForRotation( + int max_count) { + return ReferenceChainTracker::instance() + ->collectStaleRootKindEntriesForRotation(max_count); + } + + static std::vector collectStaleExpandedEntriesForRotation( + int max_count) { + return ReferenceChainTracker::instance() + ->collectStaleExpandedEntriesForRotation(max_count); + } + + // Snapshot of _priority_expand's current contents, in queue order - used + // by tests to check for duplicate tags after both rotation collectors + // have run against it within the same simulated pass. + static std::vector priorityExpandContents() { + ReferenceChainTracker *t = ReferenceChainTracker::instance(); + return std::vector(t->_priority_expand.begin(), + t->_priority_expand.end()); + } + + // StaleExpandedRotationSkipsPreexistingQueueEntries below: simulates a + // tag left in _priority_expand by a prior pass's truncated expandFrontier() + // batch (expandFrontier()'s own "leave the batch at the front of the + // source queue for a later pass to retry" comment) without driving a full + // expandFrontier()/JVMTI round-trip to produce one. + static void pushPriorityExpand(jlong tag) { + ReferenceChainTracker::instance()->_priority_expand.push_back(tag); + } + + static void setRootKindRotationCursor(jlong tag) { + ReferenceChainTracker::instance()->_root_kind_rotation_cursor = tag; + } + + static jlong rootKindRotationCursor() { + return ReferenceChainTracker::instance()->_root_kind_rotation_cursor; + } + + static int rootKindRotationBudget() { + return ReferenceChainTracker::ROOT_KIND_ROTATION_BUDGET; + } + + static int staleExpandedRotationBudget() { + return ReferenceChainTracker::STALE_EXPANDED_ROTATION_BUDGET; + } + + static size_t priorityExpandSize() { + return ReferenceChainTracker::instance()->_priority_expand.size(); + } +}; + +static jvmtiError JNICALL mock_SetEventNotificationMode(jvmtiEnv *, jvmtiEventMode, + jvmtiEvent, jthread, ...) { + return JVMTI_ERROR_NONE; +} + +class ReferenceChainsTest : public ::testing::Test { +protected: + jvmtiInterface_1_ tbl{}; + _jvmtiEnv mock_env{}; + jvmtiEnv *orig_jvmti = nullptr; + + void SetUp() override { + orig_jvmti = VMTestAccessor::getJvmti(); + tbl = jvmtiInterface_1_{}; + tbl.SetEventNotificationMode = &mock_SetEventNotificationMode; + mock_env.functions = &tbl; + VMTestAccessor::setJvmti(&mock_env); + } + + void TearDown() override { + VMTestAccessor::setJvmti(orig_jvmti); + } +}; + +TEST_F(ReferenceChainsTest, DefaultDisabled) { + Arguments args; + EXPECT_FALSE(args._reference_chains); +} + +TEST_F(ReferenceChainsTest, FlagParsesEnabled) { + Arguments args; + Error error = args.parse("referencechains=true"); + EXPECT_FALSE(error); + EXPECT_TRUE(args._reference_chains); +} + +TEST_F(ReferenceChainsTest, FlagParsesDisabled) { + Arguments args; + Error error = args.parse("referencechains=false"); + EXPECT_FALSE(error); + EXPECT_FALSE(args._reference_chains); +} + +TEST_F(ReferenceChainsTest, FlagParsesSubOptions) { + Arguments args; + Error error = args.parse("referencechains=true:hops=64:budget=2000:ttl=5000:framecap=128"); + EXPECT_FALSE(error); + EXPECT_TRUE(args._reference_chains); + EXPECT_EQ(64, args._reference_chains_hop_cap); + EXPECT_EQ(2000, args._reference_chains_budget); + EXPECT_EQ(5000, args._reference_chains_ttl_ms); + EXPECT_EQ(128, args._reference_chains_frontier_cap); +} + +// Negative/out-of-range sub-options must be floored/clamped at the parse +// boundary (Arguments::parse(), arguments.cpp) rather than stored verbatim - +// see that call site's own comment for why an unclamped negative hops in +// particular is dangerous: `depth >= (u32)ctx->hop_cap` (referenceChains.cpp) +// casts a negative int to u32, wrapping to ~4e9 and silently disabling the +// hop cap entirely. +TEST_F(ReferenceChainsTest, FlagClampsNegativeSubOptions) { + Arguments args; + Error error = args.parse( + "referencechains=true:hops=-1:budget=-5:ttl=-1:framecap=-3:" + "pausetarget=-1:painbudget=-10"); + EXPECT_FALSE(error); + EXPECT_TRUE(args._reference_chains); + // Floored to a sane minimum (1), not left negative - a negative value + // cast to u32 downstream would otherwise wrap to a huge positive number. + EXPECT_GT(args._reference_chains_hop_cap, 0); + EXPECT_GT(args._reference_chains_budget, 0); + EXPECT_GT(args._reference_chains_frontier_cap, 0); + // ttl/pausetarget are floored at 0 (their own downstream gates already + // treat 0 as "disabled", so 0 - not 1 - is the correct floor). + EXPECT_GE(args._reference_chains_ttl_ms, 0); + EXPECT_GE(args._reference_chains_pause_target_ms, 0); + // painbudget is a percentage - clamped into [0, 100]. + EXPECT_GE(args._reference_chains_pain_budget_percent, 0); + EXPECT_LE(args._reference_chains_pain_budget_percent, 100); +} + +// A too-large painbudget must be clamped down to 100, not stored verbatim - +// the sibling of FlagClampsNegativeSubOptions above, for the upper bound +// rather than the lower one. +TEST_F(ReferenceChainsTest, FlagClampsOversizedPainBudgetPercent) { + Arguments args; + Error error = args.parse("referencechains=true:painbudget=250"); + EXPECT_FALSE(error); + EXPECT_EQ(100, args._reference_chains_pain_budget_percent); +} + +TEST_F(ReferenceChainsTest, FlagWithOtherArgsDoesNotClobberOuterParse) { + Arguments args; + Error error = args.parse("event=cpu,referencechains=true:hops=32,interval=1000000"); + EXPECT_FALSE(error); + EXPECT_TRUE(args._reference_chains); + EXPECT_EQ(32, args._reference_chains_hop_cap); + EXPECT_STREQ("cpu", args._event); + EXPECT_EQ(1000000, args._interval); +} + +TEST_F(ReferenceChainsTest, StartStopDisabledDoesNotCrash) { + Arguments args; + Error error = args.parse("referencechains=false"); + ASSERT_FALSE(error); + + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + Error startError = tracker->start(args); + EXPECT_FALSE(startError); + EXPECT_FALSE(tracker->enabled()); + tracker->stop(); +} + +TEST_F(ReferenceChainsTest, StartStopEnabledDoesNotCrash) { + Arguments args; + Error error = args.parse("referencechains=true:hops=10:budget=100"); + ASSERT_FALSE(error); + + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + Error startError = tracker->start(args); + EXPECT_FALSE(startError); + EXPECT_TRUE(tracker->enabled()); + tracker->stop(); +} + +// --------------------------------------------------------------------------- +// GC signal (GarbageCollectionStart/Finish -> epoch counters). +// +// The callback trampolines (ReferenceChainTracker::GarbageCollectionStart/ +// Finish) ignore the jvmtiEnv* argument entirely - onGCStart()/onGCFinish() +// only bump an atomic counter, per the JVMTI spec restriction documented in +// referenceChains.h - so passing nullptr here exercises the real production +// code path. +// --------------------------------------------------------------------------- + +TEST_F(ReferenceChainsTest, GCCallbacksIncrementEpochWhenEnabled) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + u64 startBefore = tracker->gcStartEpoch(); + u64 finishBefore = tracker->gcFinishEpoch(); + + ReferenceChainTracker::GarbageCollectionStart(nullptr); + ReferenceChainTracker::GarbageCollectionFinish(nullptr); + + EXPECT_EQ(startBefore + 1, tracker->gcStartEpoch()); + EXPECT_EQ(finishBefore + 1, tracker->gcFinishEpoch()); + + tracker->stop(); +} + +TEST_F(ReferenceChainsTest, GCCallbacksAreNoOpWhenDisabled) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=false")); + + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + ASSERT_FALSE(tracker->enabled()); + + u64 startBefore = tracker->gcStartEpoch(); + u64 finishBefore = tracker->gcFinishEpoch(); + + ReferenceChainTracker::GarbageCollectionStart(nullptr); + ReferenceChainTracker::GarbageCollectionFinish(nullptr); + + EXPECT_EQ(startBefore, tracker->gcStartEpoch()); + EXPECT_EQ(finishBefore, tracker->gcFinishEpoch()); +} + +// --------------------------------------------------------------------------- +// Tag round-trip (SetTag/GetTag/clear). +// +// The implementation plan's suggested test ("allocate an object, tag it, +// force a GC, confirm the tag is still readable via GetObjectsWithTags") +// assumes a live embedded JVM. This gtest binary has no live JVM attached +// (see jvmSupport_ut.cpp's fixture comment for the same constraint on a +// different subsystem), so - following this repo's established pattern for +// testing JVMTI call sites without a real JVM (objectSampler_ut.cpp's mock +// jvmtiInterface_1_ table) - these tests exercise tagObject()/getTag()/ +// clearTag() against a mock jvmtiEnv backed by an in-memory tag map, rather +// than a real GC. This proves the SetTag/GetTag/SetTag(obj,0) call sequence +// and unique-tag allocation are correct; it does not prove GC-move- +// transparency, which requires a real collector and is out of reach of this +// native-only gtest binary. +// --------------------------------------------------------------------------- + +class ReferenceChainsTagTest : public ::testing::Test { +protected: + jvmtiInterface_1_ tbl{}; + _jvmtiEnv mock_env{}; + std::unordered_map tags; + + static ReferenceChainsTagTest *active_fixture; + + void SetUp() override { + active_fixture = this; + tbl = jvmtiInterface_1_{}; + tbl.SetTag = &mock_SetTag; + tbl.GetTag = &mock_GetTag; + mock_env.functions = &tbl; + } + + void TearDown() override { + active_fixture = nullptr; + } + + static jvmtiError JNICALL mock_SetTag(jvmtiEnv *, jobject object, jlong tag) { + if (tag == 0) { + active_fixture->tags.erase(object); + } else { + active_fixture->tags[object] = tag; + } + return JVMTI_ERROR_NONE; + } + + static jvmtiError JNICALL mock_GetTag(jvmtiEnv *, jobject object, jlong *tag_ptr) { + auto it = active_fixture->tags.find(object); + *tag_ptr = it != active_fixture->tags.end() ? it->second : 0; + return JVMTI_ERROR_NONE; + } +}; + +ReferenceChainsTagTest *ReferenceChainsTagTest::active_fixture = nullptr; + +TEST_F(ReferenceChainsTagTest, TagRoundTripsThenClears) { + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + int fake_object_storage = 0; + jobject obj = reinterpret_cast(&fake_object_storage); + + jlong tag = tracker->tagObject(&mock_env, obj); + EXPECT_NE(0, tag); + EXPECT_EQ(tag, tracker->getTag(&mock_env, obj)); + + tracker->clearTag(&mock_env, obj); + EXPECT_EQ(0, tracker->getTag(&mock_env, obj)); +} + +TEST_F(ReferenceChainsTagTest, TagsAreUniqueAndNeverZero) { + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + int a = 0, b = 0; + jlong tagA = tracker->tagObject(&mock_env, reinterpret_cast(&a)); + jlong tagB = tracker->tagObject(&mock_env, reinterpret_cast(&b)); + + EXPECT_NE(0, tagA); + EXPECT_NE(0, tagB); + EXPECT_NE(tagA, tagB); +} + +TEST_F(ReferenceChainsTagTest, UntaggedObjectReadsBackZero) { + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + int untagged = 0; + EXPECT_EQ(0, tracker->getTag(&mock_env, reinterpret_cast(&untagged))); +} + +// --------------------------------------------------------------------------- +// FrontierTable (tag-indexed frontier metadata table). +// +// No live JVM/JVMTI involvement here - FrontierTable is pure native slot +// storage indexed by an already-issued tag value, so these tests exercise it +// directly rather than through ReferenceChainTracker's tag helpers. +// --------------------------------------------------------------------------- + +TEST(FrontierTableTest, InsertThenLookupRoundTrips) { + FrontierTable table(64); + + ASSERT_TRUE(table.insert(1, /*parent_tag=*/0, /*referrer_klass=*/7, + /*depth=*/0, FrontierEntryState::FRONTIER)); + + FrontierEntry entry{}; + ASSERT_TRUE(table.lookup(1, &entry)); + EXPECT_EQ(0, entry.parent_tag); + EXPECT_EQ(7u, entry.referrer_klass); + EXPECT_EQ(0u, entry.depth); + EXPECT_EQ(FrontierEntryState::FRONTIER, entry.state); +} + +TEST(FrontierTableTest, LookupOfNeverInsertedTagFails) { + FrontierTable table(64); + FrontierEntry entry{}; + EXPECT_FALSE(table.lookup(1, &entry)); + EXPECT_FALSE(table.lookup(5, &entry)); +} + +TEST(FrontierTableTest, NonPositiveTagIsRejected) { + FrontierTable table(64); + FrontierEntry entry{}; + EXPECT_FALSE(table.insert(0, 0, 0, 0)); + EXPECT_FALSE(table.insert(-1, 0, 0, 0)); + EXPECT_FALSE(table.lookup(0, &entry)); + EXPECT_FALSE(table.lookup(-1, &entry)); +} + +TEST(FrontierTableTest, LookupLockedRejectsNonPositiveTag) { + FrontierTable table(64); + ASSERT_TRUE(table.insert(1, 0, 7, 0, FrontierEntryState::FRONTIER)); + FrontierEntry entry{}; + // tag=0 must be rejected the same way lookup() rejects it - callers + // index the table with tag-1, so a `tag <= 0` check (not just `tag < 0`) + // is required to keep that subtraction from wrapping into a valid slot. + EXPECT_FALSE(table.lookupLocked(0, &entry)); + EXPECT_FALSE(table.lookupLocked(-1, &entry)); +} + +TEST(FrontierTableTest, LookupLockedRejectsTagPastCurrentSize) { + FrontierTable table(64); + ASSERT_TRUE(table.insert(1, 0, 7, 0, FrontierEntryState::FRONTIER)); + FrontierEntry entry{}; + // Only tag=1 has ever been inserted (table_size == 1); tag=2 maps to + // idx=1, exactly at the current size boundary, and must be rejected + // rather than read out of bounds. + EXPECT_FALSE(table.lookupLocked(2, &entry)); +} + +TEST(FrontierTableTest, ParentTagChainReconstructsAcrossHops) { + // Mirrors how the heap-walk engine walks parent_tag links back to a root: insert + // a small chain root(tag=1) <- mid(tag=2) <- leaf(tag=3) and confirm the + // links resolve in order. + FrontierTable table(64); + ASSERT_TRUE(table.insert(1, 0, 100, 0, FrontierEntryState::EDGE)); + ASSERT_TRUE(table.insert(2, 1, 200, 1, FrontierEntryState::EDGE)); + ASSERT_TRUE(table.insert(3, 2, 300, 2, FrontierEntryState::EDGE)); + + FrontierEntry entry{}; + jlong tag = 3; + std::vector chain; + while (tag != 0) { + ASSERT_TRUE(table.lookup(tag, &entry)); + chain.push_back(entry.referrer_klass); + tag = entry.parent_tag; + } + + ASSERT_EQ(3u, chain.size()); + EXPECT_EQ(300u, chain[0]); + EXPECT_EQ(200u, chain[1]); + EXPECT_EQ(100u, chain[2]); +} + +TEST(FrontierTableTest, ClearMarksAbandonedWithoutRemovingEntry) { + FrontierTable table(64); + ASSERT_TRUE(table.insert(1, 0, 7, 0, FrontierEntryState::FRONTIER)); + + table.clear(1); + + FrontierEntry entry{}; + ASSERT_TRUE(table.lookup(1, &entry)); + EXPECT_EQ(FrontierEntryState::ABANDONED, entry.state); +} + +TEST(FrontierTableTest, ClearOfNeverInsertedTagIsNoOp) { + FrontierTable table(64); + table.clear(1); // must not crash + FrontierEntry entry{}; + EXPECT_FALSE(table.lookup(1, &entry)); +} + +TEST(FrontierTableTest, GrowsPastInitialCapacityUpToMaxCap) { + // Force at least one resize by inserting beyond the small max_cap. + const int max_cap = 10; + FrontierTable table(max_cap); + ASSERT_LE(table.capacity(), max_cap); + + for (jlong tag = 1; tag <= max_cap; tag++) { + ASSERT_TRUE(table.insert(tag, tag - 1, (u32)tag, (u32)(tag - 1))) + << "insert failed for tag " << tag; + } + EXPECT_EQ(max_cap, table.capacity()); + + for (jlong tag = 1; tag <= max_cap; tag++) { + FrontierEntry entry{}; + ASSERT_TRUE(table.lookup(tag, &entry)); + EXPECT_EQ((u32)tag, entry.referrer_klass); + } +} + +TEST(FrontierTableTest, CapacityExhaustedReportsFailureInsteadOfCrashing) { + const int max_cap = 4; + FrontierTable table(max_cap); + + for (jlong tag = 1; tag <= max_cap; tag++) { + ASSERT_TRUE(table.insert(tag, 0, 0, 0)); + } + // One past max_cap must be rejected, not silently dropped-but-crashing. + EXPECT_FALSE(table.insert(max_cap + 1, 0, 0, 0)); + EXPECT_EQ(max_cap, table.capacity()); + + // Existing entries remain intact after the failed insert. + FrontierEntry entry{}; + EXPECT_TRUE(table.lookup(1, &entry)); +} + +TEST(FrontierTableTest, ZeroMaxCapRejectsEveryInsert) { + FrontierTable table(0); + EXPECT_EQ(0, table.capacity()); + EXPECT_FALSE(table.insert(1, 0, 0, 0)); +} + +TEST(FrontierTableTest, ConcurrentInsertWhileGrowingDoesNotCrash) { + // Small max_cap relative to thread/tag count forces repeated resizes + // while other threads are concurrently inserting distinct tags. + const int max_cap = 4096; + const int thread_count = 8; + const int tags_per_thread = 256; + FrontierTable table(max_cap); + + std::vector threads; + for (int t = 0; t < thread_count; t++) { + threads.emplace_back([&table, t, tags_per_thread]() { + for (int i = 0; i < tags_per_thread; i++) { + jlong tag = (jlong)t * tags_per_thread + i + 1; + table.insert(tag, 0, (u32)tag, 0); + } + }); + } + for (auto &th : threads) { + th.join(); + } + + int found = 0; + for (jlong tag = 1; tag <= (jlong)thread_count * tags_per_thread; tag++) { + FrontierEntry entry{}; + if (table.lookup(tag, &entry)) { + EXPECT_EQ((u32)tag, entry.referrer_klass); + found++; + } + } + // Every tag fits well within max_cap, so all inserts must have + // succeeded and be independently readable. + EXPECT_EQ(thread_count * tags_per_thread, found); +} + +TEST(FrontierTableTest, ReconstructChainWalksParentTagsAndMarksEdge) { + FrontierTable table(64); + ASSERT_TRUE(table.insert(1, 0, 100, 0, FrontierEntryState::FRONTIER)); + ASSERT_TRUE(table.insert(2, 1, 200, 1, FrontierEntryState::FRONTIER)); + ASSERT_TRUE(table.insert(3, 2, 300, 2, FrontierEntryState::FRONTIER)); + + std::vector chain; + ASSERT_TRUE(table.reconstructChain(3, &chain)); + ASSERT_EQ(3u, chain.size()); + EXPECT_EQ(300u, chain[0]); + EXPECT_EQ(200u, chain[1]); + EXPECT_EQ(100u, chain[2]); + + // Every hop walked must be marked EDGE - this table's degenerate + // EdgeStore (design doc: "on a path toward a target sample"). + for (jlong tag = 1; tag <= 3; tag++) { + FrontierEntry entry{}; + ASSERT_TRUE(table.lookup(tag, &entry)); + EXPECT_EQ(FrontierEntryState::EDGE, entry.state); + } +} + +TEST(FrontierTableTest, ReconstructChainOfNeverInsertedTagFails) { + FrontierTable table(64); + std::vector chain; + EXPECT_FALSE(table.reconstructChain(1, &chain)); +} + +// --------------------------------------------------------------------------- +// Heap-walk engine (ReferenceChainTracker::runPass()/ +// heapReferenceCallback()/resolveLoadedClasses()). +// +// The implementation plan suggests testing this against "a small live-object +// graph in a test JVM (via JNI from the test)". This native-only gtest +// binary has no live JVM at all (see this file's GC-signal/tag-round-trip +// comment above, and jvmSupport_ut.cpp's fixture comment, for the same +// pre-existing constraint) - no gtest binary in this codebase embeds a +// JNI_CreateJavaVM-created JVM. So, exactly like ReferenceChainsTagTest above +// mocks SetTag/GetTag with an in-memory map, these tests mock the JVMTI/JNI +// call boundary (FollowReferences/GetLoadedClasses/GetClassSignature/ +// DeleteLocalRef) to play back a scripted synthetic object graph, and run +// the *real* production heapReferenceCallback()/resolveLoadedClasses()/ +// reconstructChain() code against it - only the JVMTI/JNI calls are faked, +// not the logic under test. A live-JVM end-to-end test belongs to a +// Java-side integration test (see ReferenceChainTrackingTest.java), not this +// native gtest binary. +// --------------------------------------------------------------------------- + +namespace { + +struct ScriptedEdge { + jvmtiHeapReferenceKind kind; + int referrer_idx; // -1 = heap root (no referrer) + int referee_idx; // index into ReferenceChainsBfsTest::node_tags + int class_idx; // index into ReferenceChainsBfsTest::classes, or -1 +}; + +struct ScriptedClass { + void *klass; + const char *signature; // JVMTI class signature, e.g. "Lcom/example/Foo;" +}; + +} // namespace + +class ReferenceChainsBfsTest : public ::testing::Test { +protected: + jvmtiInterface_1_ jvmti_tbl{}; + _jvmtiEnv mock_jvmti{}; + JNINativeInterface_ jni_tbl{}; + JNIEnv_ mock_jni{}; + + std::unordered_map tags; + std::vector classes; + std::vector script; + std::vector node_tags; + + // node_tags[idx] mirrors "the object's *current* live JVMTI + // tag" (0 once releaseSearchTags() clears it, exactly like a real + // GetTag() would report after SetTag(obj, 0)). tags_ever_assigned[idx] + // instead remembers the tag heapReferenceCallback() ever wrote through + // tag_ptr for this node, and is never reset - a production consumer + // would capture a target sample's tag the same way (at assignment time, + // e.g. via its own sample-tracking), not by re-reading GetTag() after + // the search has already released it. Tests use this to fetch a tag for + // reconstructChain() without depending on whether the search released + // it before or after the test could observe node_tags[idx]. + std::vector tags_ever_assigned; + + // Tags that GetObjectsWithTags() below reports as unresolvable, + // simulating the referenced object having died (GC'd) between passes - + // see the resolve-or-drop tests. + std::unordered_set dead_tags; + + // When true, mock_GetObjectsWithTags() below fails outright (as if the + // real JVMTI call had hit e.g. JVMTI_ERROR_OUT_OF_MEMORY), for + // ReleaseSearchTagsFailureTest - simulates releaseSearchTags()'s own + // GetObjectsWithTags() call failing rather than an individual tag + // failing to resolve (dead_tags above). + bool fail_get_objects_with_tags = false; + + // Synthetic frontier-holder arrays for expandFrontier()'s array-holder + // walk: mock_NewObjectArray() hands back an opaque handle, + // mock_SetObjectArrayElement() records its elements here, and + // mock_FollowReferences() treats every recorded element as an expansion + // seed (one hop, gated by the production callback's batch_tags) when the + // holder is passed as initial_object. + std::unordered_map> holders; + uintptr_t next_holder = 0xF00D0000; + + jvmtiEnv *orig_jvmti = nullptr; + + static ReferenceChainsBfsTest *active_fixture; + + void SetUp() override { + active_fixture = this; + // See ReferenceChainsTestAccessor's own comment - without this, a + // prior test in this suite that drove the search to + // SearchState::COMPLETED/ABANDONED would make every runPass() call + // below a permanent no-op. + ReferenceChainsTestAccessor::reset(); + jvmti_tbl = jvmtiInterface_1_{}; + // start() calls VM::jvmti()->SetEventNotificationMode() - + // stub it and swap VM::_jvmti (VMTestAccessor, declared above) the + // same way ReferenceChainsTest's fixture does, so start() does not + // dereference the real (null, no live JVM) jvmtiEnv. + jvmti_tbl.SetEventNotificationMode = &mock_SetEventNotificationMode; + jvmti_tbl.SetTag = &mock_SetTag; + jvmti_tbl.GetTag = &mock_GetTag; + jvmti_tbl.GetLoadedClasses = &mock_GetLoadedClasses; + jvmti_tbl.GetClassSignature = &mock_GetClassSignature; + jvmti_tbl.Deallocate = &mock_Deallocate; + jvmti_tbl.FollowReferences = &mock_FollowReferences; + jvmti_tbl.IterateOverReachableObjects = &mock_IterateOverReachableObjects; + jvmti_tbl.GetObjectsWithTags = &mock_GetObjectsWithTags; + mock_jvmti.functions = &jvmti_tbl; + orig_jvmti = VMTestAccessor::getJvmti(); + VMTestAccessor::setJvmti(&mock_jvmti); + + jni_tbl = JNINativeInterface_{}; + jni_tbl.DeleteLocalRef = &mock_DeleteLocalRef; + jni_tbl.FindClass = &mock_FindClass; + jni_tbl.EnsureLocalCapacity = &mock_EnsureLocalCapacity; + jni_tbl.NewObjectArray = &mock_NewObjectArray; + jni_tbl.SetObjectArrayElement = &mock_SetObjectArrayElement; + jni_tbl.ExceptionCheck = &mock_ExceptionCheck; + jni_tbl.ExceptionClear = &mock_ExceptionClear; + mock_jni.functions = &jni_tbl; + } + + void TearDown() override { + VMTestAccessor::setJvmti(orig_jvmti); + active_fixture = nullptr; + } + + // Registers a fake class (matched by identity, not by any real JNI + // semantics) that resolveLoadedClasses() will discover via the mocked + // GetLoadedClasses(). Returns its index into `classes`. + int addClass(void *klass, const char *signature) { + classes.push_back({klass, signature}); + return (int)classes.size() - 1; + } + + // Adds an as-yet-untagged frontier node, returning its index into + // node_tags for use as a ScriptedEdge referrer_idx/referee_idx. + int addNode() { + node_tags.push_back(0); + tags_ever_assigned.push_back(0); + return (int)node_tags.size() - 1; + } + + // Reverse lookup from a node's synthetic identity + // (&node_tags[idx], see mock_FollowReferences' initial_object handling + // below) back to its index. Returns -1 for anything else (e.g. a + // ScriptedClass's `klass` pointer, which never aliases node_tags' + // backing storage). Requires every addNode() call to happen before any + // runPass() call in a test, so node_tags never reallocates out from + // under a previously-taken address - true of every test in this file. + int indexOfNode(jobject obj) const { + for (size_t i = 0; i < node_tags.size(); i++) { + if (obj == (jobject)&node_tags[i]) { + return (int)i; + } + } + return -1; + } + + static jvmtiError JNICALL mock_SetTag(jvmtiEnv *, jobject object, jlong tag) { + // releaseSearchTags() calls SetTag(obj, 0) on the resolved + // objects GetObjectsWithTags() (below) hands back for a frontier + // node - route that through node_tags[idx] directly (the same + // storage GetObjectsWithTags's resolution and the production + // callback's tag_ptr writes both key off of), so the release is + // actually observable, not just recorded in a side map nothing else + // reads. + int idx = active_fixture->indexOfNode(object); + if (idx >= 0) { + active_fixture->node_tags[idx] = tag; + return JVMTI_ERROR_NONE; + } + if (tag == 0) { + active_fixture->tags.erase(object); + } else { + active_fixture->tags[object] = tag; + } + return JVMTI_ERROR_NONE; + } + + static jvmtiError JNICALL mock_GetTag(jvmtiEnv *, jobject object, jlong *tag_ptr) { + int idx = active_fixture->indexOfNode(object); + if (idx >= 0) { + *tag_ptr = active_fixture->node_tags[idx]; + return JVMTI_ERROR_NONE; + } + auto it = active_fixture->tags.find(object); + *tag_ptr = it != active_fixture->tags.end() ? it->second : 0; + return JVMTI_ERROR_NONE; + } + + static jvmtiError JNICALL mock_GetLoadedClasses(jvmtiEnv *, jint *count_ptr, + jclass **classes_ptr) { + auto &classes = active_fixture->classes; + *count_ptr = (jint)classes.size(); + *classes_ptr = classes.empty() + ? nullptr + : (jclass *)malloc(sizeof(jclass) * classes.size()); + for (size_t i = 0; i < classes.size(); i++) { + (*classes_ptr)[i] = (jclass)classes[i].klass; + } + return JVMTI_ERROR_NONE; + } + + static jvmtiError JNICALL mock_GetClassSignature(jvmtiEnv *, jclass klass, + char **signature_ptr, + char **generic_ptr) { + for (auto &c : active_fixture->classes) { + if (c.klass == (void *)klass) { + *signature_ptr = strdup(c.signature); + if (generic_ptr != nullptr) { + *generic_ptr = nullptr; + } + return JVMTI_ERROR_NONE; + } + } + return JVMTI_ERROR_INVALID_CLASS; + } + + static jvmtiError JNICALL mock_Deallocate(jvmtiEnv *, unsigned char *mem) { + free(mem); + return JVMTI_ERROR_NONE; + } + + static void JNICALL mock_DeleteLocalRef(JNIEnv *, jobject) { + // no-op: this fixture's fake jobject/jclass values are not real JNI + // local refs. + } + + // expandFrontier() resolves java/lang/Object once per call as the holder + // array's element type - a non-null fake jclass is all it needs (the type + // is never introspected, only passed to NewObjectArray()). + static jclass JNICALL mock_FindClass(JNIEnv *, const char *) { + return (jclass)0xC1A55; + } + + static jint JNICALL mock_EnsureLocalCapacity(JNIEnv *, jint) { + return JNI_OK; + } + + // expandFrontier() calls jniExceptionCheck() after every upcall that can + // legally throw (NewObjectArray/SetObjectArrayElement/EnsureLocalCapacity + // failures) - this fixture's mocks never throw, so there is never a + // pending exception to report or clear. + static jboolean JNICALL mock_ExceptionCheck(JNIEnv *) { + return JNI_FALSE; + } + + static void JNICALL mock_ExceptionClear(JNIEnv *) { + // no-op: mock_ExceptionCheck() never reports a pending exception. + } + + // Hands back a fresh opaque holder handle and registers it in `holders` so + // mock_SetObjectArrayElement()/mock_FollowReferences() can find its + // elements. `length`/`elementClass`/`initialElement` are unused - the + // fixture never reads the array back, only its recorded element list. + static jobjectArray JNICALL mock_NewObjectArray(JNIEnv *, jsize, jclass, + jobject) { + jobject handle = (jobject)(active_fixture->next_holder++); + active_fixture->holders[handle] = {}; + return (jobjectArray)handle; + } + + static void JNICALL mock_SetObjectArrayElement(JNIEnv *, jobjectArray array, + jsize, jobject value) { + active_fixture->holders[(jobject)array].push_back(value); + } + + // runPassManualWalk()'s root enumeration (the default, non-fallback path): + // reports each scripted root edge's referee to heapRootCallback() exactly + // as a real IterateOverReachableObjects() reports a root-held object - + // tag_ptr only, no oop, no transitive children (see runPassManualWalk()'s + // own comment). Expansion past the roots is then driven by expandFrontier() + // through the same mock_FollowReferences() array-holder path the resumed + // fallback passes use. stack_ref/object_ref callbacks are unused here - a + // JNI-global root (durable) is all these tests need to model. + static jvmtiError JNICALL mock_IterateOverReachableObjects( + jvmtiEnv *, jvmtiHeapRootCallback heap_root_cb, + jvmtiStackReferenceCallback, jvmtiObjectReferenceCallback, + const void *user_data) { + for (auto &e : active_fixture->script) { + if (e.referrer_idx != -1) { + continue; + } + jlong class_tag = 0; + if (e.class_idx >= 0) { + class_tag = active_fixture->tags[active_fixture->classes[e.class_idx].klass]; + } + jlong *tag_ptr = &active_fixture->node_tags[e.referee_idx]; + jvmtiIterationControl ctl = heap_root_cb( + JVMTI_HEAP_ROOT_JNI_GLOBAL, class_tag, /*size=*/0, tag_ptr, + const_cast(user_data)); + if (*tag_ptr != 0) { + active_fixture->tags_ever_assigned[e.referee_idx] = *tag_ptr; + } + if (ctl == JVMTI_ITERATION_ABORT) { + break; + } + } + return JVMTI_ERROR_NONE; + } + + // Resolves each requested tag to its node's synthetic identity + // (&node_tags[idx]) by scanning node_tags for a matching current value - + // mirroring real GetObjectsWithTags()'s "only currently-live tags come + // back" contract. A tag in `dead_tags` is deliberately omitted even if + // node_tags still holds it, simulating "the object died, JVMTI forgot + // the tag with it" for the resolve-or-drop tests. + static jvmtiError JNICALL mock_GetObjectsWithTags( + jvmtiEnv *, jint tag_count, const jlong *req_tags, jint *count_ptr, + jobject **object_result_ptr, jlong **tag_result_ptr) { + if (active_fixture->fail_get_objects_with_tags) { + // Deliberately leave *count_ptr/*object_result_ptr/*tag_result_ptr + // untouched - a real failed JVMTI call makes no promise about + // them, and releaseSearchTags() must not read them on this path. + return JVMTI_ERROR_OUT_OF_MEMORY; + } + std::vector objs; + std::vector found; + for (jint i = 0; i < tag_count; i++) { + jlong want = req_tags[i]; + if (want == 0 || active_fixture->dead_tags.count(want) > 0) { + continue; + } + for (size_t idx = 0; idx < active_fixture->node_tags.size(); idx++) { + if (active_fixture->node_tags[idx] == want) { + objs.push_back((jobject)&active_fixture->node_tags[idx]); + found.push_back(want); + break; + } + } + } + *count_ptr = (jint)objs.size(); + *object_result_ptr = objs.empty() + ? nullptr : (jobject *)malloc(sizeof(jobject) * objs.size()); + *tag_result_ptr = found.empty() + ? nullptr : (jlong *)malloc(sizeof(jlong) * found.size()); + for (size_t i = 0; i < objs.size(); i++) { + (*object_result_ptr)[i] = objs[i]; + (*tag_result_ptr)[i] = found[i]; + } + return JVMTI_ERROR_NONE; + } + + // Plays back `script` against the real production heap_reference_callback, + // modelling enough of FollowReferences' actual semantics for these + // heap-walk tests to be meaningful: + // - "a reference from A to B is not traversed until A is visited" - + // an edge whose referrer was not returned JVMTI_VISIT_OBJECTS for + // (or was never itself visited) is skipped, exactly as a real + // traversal would never reach it. + // - a JVMTI_VISIT_ABORT return stops delivery immediately. + // - when `initial_object` is non-NULL (expandFrontier()'s + // resumed-pass calls), only edges reachable from that object's own + // node are replayed - root edges (referrer_idx == -1) are skipped + // entirely, matching FollowReferences' real "the specified object is + // used instead of the heap roots" contract. `initial_object == NULL` + // (the first-pass, root-seeded call) is unchanged from the original + // single-pass heap-walk engine. + // This is not a full JVMTI implementation (real traversal order, + // multi-referrer objects, and primitive/array callbacks are all out of + // scope) - just enough fidelity to exercise the hop-cap/budget/ + // frontier-cap/class-skip/resumption logic in heapReferenceCallback()/ + // expandFrontier() themselves. + static jvmtiError JNICALL mock_FollowReferences( + jvmtiEnv *, jint, jclass, jobject initial_object, + const jvmtiHeapCallbacks *callbacks, const void *user_data) { + std::unordered_map expandable; + // seed_idx == -2 marks the root walk (initial_object == NULL); any + // other value marks an expansion walk seeded from one or more boundary + // objects, in which case root edges are never replayed. -1 is the + // array-holder walk (a whole BFS level's boundary objects at once); + // >= 0 is the single-object legacy per-entry walk. + int seed_idx = -2; + // The transient holder array itself is never tagged (mirrors real + // production: admitStaticFieldRoots()/expandFrontier() never call + // SetTag on the frontier-holder array they build), so every + // holder->element ARRAY_ELEMENT edge below is replayed with a + // referrer tag of 0. + static jlong holder_tag = 0; + if (initial_object != nullptr) { + auto holder_it = active_fixture->holders.find(initial_object); + if (holder_it != active_fixture->holders.end()) { + // Array-holder walk (expandFrontier()'s already-tagged + // boundary batch, or admitStaticFieldRoots()'s negative- + // tagged class-object seed): actually invoke the production + // callback for each holder->element edge, exactly like a + // real FollowReferences(initial_object=holder_array) call + // would - this is what lets heap_reference_callback()'s own + // tag-sign/reference_kind logic (e.g. the *tag_ptr < 0 + // early-return and its admitStaticFieldRoots() carve-out) + // actually run, rather than assuming every element is + // expandable. + seed_idx = -1; + for (jobject elem : holder_it->second) { + int idx = active_fixture->indexOfNode(elem); + if (idx < 0) { + continue; + } + jlong *tag_ptr = &active_fixture->node_tags[idx]; + jint ctl = callbacks->heap_reference_callback( + JVMTI_HEAP_REFERENCE_ARRAY_ELEMENT, nullptr, + /*class_tag=*/0, /*referrer_class_tag=*/0, + /*size=*/0, tag_ptr, &holder_tag, + /*length=*/-1, const_cast(user_data)); + if (*tag_ptr != 0) { + active_fixture->tags_ever_assigned[idx] = *tag_ptr; + } + if (ctl & JVMTI_VISIT_ABORT) { + return JVMTI_ERROR_NONE; + } + expandable[idx] = (ctl & JVMTI_VISIT_OBJECTS) != 0; + } + } else { + seed_idx = active_fixture->indexOfNode(initial_object); + expandable[seed_idx] = true; + } + } + for (auto &e : active_fixture->script) { + if (e.referrer_idx == -1) { + if (seed_idx != -2) { + continue; // resumed pass: never replay root edges + } + } else { + auto it = expandable.find(e.referrer_idx); + if (it == expandable.end() || !it->second) { + continue; + } + } + jlong class_tag = 0; + if (e.class_idx >= 0) { + class_tag = active_fixture->tags[active_fixture->classes[e.class_idx].klass]; + } + jlong *referrer_tag_ptr = e.referrer_idx >= 0 + ? &active_fixture->node_tags[e.referrer_idx] : nullptr; + jlong *tag_ptr = &active_fixture->node_tags[e.referee_idx]; + jint ctl = callbacks->heap_reference_callback( + e.kind, nullptr, class_tag, /*referrer_class_tag=*/0, + /*size=*/0, tag_ptr, referrer_tag_ptr, /*length=*/-1, + const_cast(user_data)); + if (*tag_ptr != 0) { + active_fixture->tags_ever_assigned[e.referee_idx] = *tag_ptr; + } + if (ctl & JVMTI_VISIT_ABORT) { + return JVMTI_ERROR_NONE; + } + expandable[e.referee_idx] = (ctl & JVMTI_VISIT_OBJECTS) != 0; + } + return JVMTI_ERROR_NONE; + } +}; + +ReferenceChainsBfsTest *ReferenceChainsBfsTest::active_fixture = nullptr; + +TEST_F(ReferenceChainsBfsTest, ReconstructsChainForSyntheticGraph) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + void *classA = (void *)0x2001, *classB = (void *)0x2002, + *classTarget = (void *)0x2003; + int ca = addClass(classA, "Lcom/rc/phase3/graph/A;"); + int cb = addClass(classB, "Lcom/rc/phase3/graph/B;"); + int ct = addClass(classTarget, "Lcom/rc/phase3/graph/Target;"); + + int nodeA = addNode(); + int nodeB = addNode(); + int nodeTarget = addNode(); + + script = { + {JVMTI_HEAP_REFERENCE_JNI_GLOBAL, -1, nodeA, ca}, + {JVMTI_HEAP_REFERENCE_FIELD, nodeA, nodeB, cb}, + {JVMTI_HEAP_REFERENCE_FIELD, nodeB, nodeTarget, ct}, + }; + + bool truncated = true; + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); + EXPECT_FALSE(truncated); + + // A single pass that reaches full exhaustion of the reachable + // graph completes the search and releases every tag it assigned - so + // the tag must be fetched via tags_ever_assigned (captured at + // assignment time), not node_tags (already reset to 0 by + // releaseSearchTags() by the time runPass() returns; see + // ReleasesTagsOnCompletion below for the release itself). + EXPECT_EQ(SearchState::COMPLETED, tracker->searchState()); + jlong targetTag = tags_ever_assigned[nodeTarget]; + ASSERT_NE(0, targetTag); + EXPECT_EQ(0, node_tags[nodeTarget]); // released - see the comment above + + std::vector chain; + ASSERT_TRUE(tracker->frontierTable()->reconstructChain(targetTag, &chain)); + ASSERT_EQ(3u, chain.size()); + + int expectedTarget = Profiler::instance()->lookupClass( + "com/rc/phase3/graph/Target", strlen("com/rc/phase3/graph/Target")); + int expectedB = Profiler::instance()->lookupClass( + "com/rc/phase3/graph/B", strlen("com/rc/phase3/graph/B")); + int expectedA = Profiler::instance()->lookupClass( + "com/rc/phase3/graph/A", strlen("com/rc/phase3/graph/A")); + ASSERT_NE(-1, expectedTarget); + ASSERT_NE(-1, expectedB); + ASSERT_NE(-1, expectedA); + + EXPECT_EQ((u32)expectedTarget, chain[0]); + EXPECT_EQ((u32)expectedB, chain[1]); + EXPECT_EQ((u32)expectedA, chain[2]); + + // buildChainEvent() wraps the same reconstructChain() call into + // the ReferenceChainEvent shape Recording::recordReferenceChain() + // (flightRecorder.cpp) expects - same chain/order, plus the target's own + // depth from FrontierEntry. + ReferenceChainEvent event; + ASSERT_TRUE(tracker->buildChainEvent(targetTag, &event)); + EXPECT_EQ((u64)targetTag, event._target_tag); + EXPECT_EQ(2u, event._depth); // root(A, depth0) -> B(depth1) -> Target(depth2) + ASSERT_EQ(chain, event._chain); + + tracker->stop(); +} + +TEST_F(ReferenceChainsBfsTest, BuildChainEventFailsForUnknownTag) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + ReferenceChainEvent event; + EXPECT_FALSE(tracker->buildChainEvent(12345, &event)); + + tracker->stop(); +} + +TEST_F(ReferenceChainsBfsTest, BuildAbandonedEventFailsUnlessSearchAbandoned) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + // Freshly started: never abandoned (never even run a pass yet). + ReferenceChainAbandonedEvent event; + EXPECT_FALSE(tracker->buildAbandonedEvent(&event)); + + int nodeA = addNode(); + script = {{JVMTI_HEAP_REFERENCE_JNI_GLOBAL, -1, nodeA, -1}}; + bool truncated = true; + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); + // Small graph, no caps hit -> COMPLETED, not ABANDONED. + ASSERT_EQ(SearchState::COMPLETED, tracker->searchState()); + EXPECT_FALSE(tracker->buildAbandonedEvent(&event)); + + tracker->stop(); +} + +TEST_F(ReferenceChainsBfsTest, HopCapStopsAdmittingBeyondCap) { + Arguments args; + // hops=1: only depth 0 (direct root references) may be admitted. + ASSERT_FALSE(args.parse("referencechains=true:hops=1:budget=1000")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int nodeA = addNode(); + int nodeB = addNode(); + + script = { + {JVMTI_HEAP_REFERENCE_JNI_GLOBAL, -1, nodeA, -1}, // depth 0 - admitted + {JVMTI_HEAP_REFERENCE_FIELD, nodeA, nodeB, -1}, // depth 1 - capped + }; + + bool truncated = true; + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); + EXPECT_FALSE(truncated); // hop cap is not truncation - a normal boundary + // Not truncated -> graph fully explored within the hop cap -> the search + // completes and releases its tags in the same call (see the previous + // test's comment) - fetch nodeA's tag via tags_ever_assigned, not + // node_tags. + EXPECT_EQ(SearchState::COMPLETED, tracker->searchState()); + + EXPECT_NE(0, tags_ever_assigned[nodeA]); + EXPECT_EQ(0, node_tags[nodeB]); // never admitted into the frontier + + tracker->stop(); +} + +TEST_F(ReferenceChainsBfsTest, BudgetExhaustionTruncatesAndIsReported) { + Arguments args; + // budget=1: root enumeration and the expand phase draw from separate + // budget pools (see runPassManualWalk()'s own comment), each sized 1 + // here - root enum admits nodeA, then the expand phase's own 1-unit + // budget admits exactly one of nodeA's two children before exhausting. + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1:firstpassbudget=1")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int nodeA = addNode(); + int nodeB = addNode(); + int nodeC = addNode(); + + script = { + {JVMTI_HEAP_REFERENCE_JNI_GLOBAL, -1, nodeA, -1}, // admitted via root enum + {JVMTI_HEAP_REFERENCE_FIELD, nodeA, nodeB, -1}, // admitted via expand + {JVMTI_HEAP_REFERENCE_FIELD, nodeA, nodeC, -1}, // expand budget exhausted + }; + + bool truncated = false; + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); + EXPECT_TRUE(truncated); + // Budget exhaustion for *this pass* leaves pending work (nodeC's own + // edge was never even attempted) - the search stays RUNNING, not + // COMPLETED, so no tag release happens yet and node_tags[nodeA]/[nodeB] + // are still the real assigned tags. + EXPECT_EQ(SearchState::RUNNING, tracker->searchState()); + + EXPECT_NE(0, node_tags[nodeA]); + EXPECT_NE(0, node_tags[nodeB]); + EXPECT_EQ(0, node_tags[nodeC]); + + tracker->stop(); +} + +TEST_F(ReferenceChainsBfsTest, PreTaggedClassObjectsAreNeverExpandedOrAdmitted) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int classNode = addNode(); + node_tags[classNode] = -7; // simulate a class object already tagged by + // resolveLoadedClasses() before this pass - + // see ClassTagTable's tag-sign convention. + int fieldTargetNode = addNode(); + + script = { + // A root reference straight to the class object (e.g. a + // JVMTI_HEAP_REFERENCE_SYSTEM_CLASS root edge in a real walk). + {JVMTI_HEAP_REFERENCE_SYSTEM_CLASS, -1, classNode, -1}, + // A static field of that class - must never be delivered by a real + // FollowReferences call, since the class-object edge above must not + // return JVMTI_VISIT_OBJECTS; mock_FollowReferences enforces this + // the same way a real traversal would (see its own comment). + {JVMTI_HEAP_REFERENCE_STATIC_FIELD, classNode, fieldTargetNode, -1}, + }; + + bool truncated = true; + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); + EXPECT_FALSE(truncated); + + EXPECT_EQ(-7, node_tags[classNode]); // untouched - never treated as a + // frontier object + EXPECT_EQ(0, node_tags[fieldTargetNode]); // never reached - the class + // edge above must not expand + + tracker->stop(); +} + +// Regression test for admitStaticFieldRoots(): an object retained solely by +// a static field (no other GC root reaches it) must still be discovered. +// The scripted class is registered via addClass() with its own node's +// address as the jclass identity, so GetLoadedClasses()/resolveLoadedClasses() +// (real production code, driven through the same mocked jvmti) tag it +// negative through node_tags[classNode] exactly like a real Class object - +// the same identity the STATIC_FIELD script edge below uses as its +// referrer, mirroring how a real Class object is simultaneously "a loaded +// class" and "the referrer of its own static-field edges". +TEST_F(ReferenceChainsBfsTest, DiscoversObjectRetainedOnlyByStaticField) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int classNode = addNode(); + int fieldTargetNode = addNode(); + addClass((void *)&node_tags[classNode], "Lcom/rc/statics/Holder;"); + + script = { + // No GC-root path to fieldTargetNode at all - it is reachable only + // via classNode's static field. + {JVMTI_HEAP_REFERENCE_STATIC_FIELD, classNode, fieldTargetNode, -1}, + }; + + bool truncated = true; + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); + EXPECT_FALSE(truncated); + + // classNode got tagged negative by the real resolveLoadedClasses() scan + // (not manually, unlike PreTaggedClassObjectsAreNeverExpandedOrAdmitted + // above), and was never itself admitted as a frontier object. + EXPECT_LT(node_tags[classNode], 0); + + jlong target_tag = tags_ever_assigned[fieldTargetNode]; + ASSERT_NE(0, target_tag); + + std::vector chain; + ASSERT_TRUE(tracker->frontierTable()->reconstructChain(target_tag, &chain)); + ASSERT_EQ(1u, chain.size()); + + FrontierEntry entry{}; + ASSERT_TRUE(tracker->frontierTable()->lookup(target_tag, &entry)); + EXPECT_EQ(0, entry.parent_tag); // root-attached, not a child hop + EXPECT_EQ((u8)JVMTI_HEAP_REFERENCE_STATIC_FIELD, entry.root_kind); + + tracker->stop(); +} + +// Regression test for the resolveLoadedClasses() scan-skip guard: it must +// compare `class_count != _last_resolved_class_count`, not `class_count > +// _last_resolved_class_count`. GetLoadedClasses()'s count is not monotonic - +// class unloading can shrink it - so a `>` guard would stay permanently +// skipped once the count is loaded back up to, but not past, a prior +// historical peak, silently leaving any *different* classes loaded in that +// regrowth untagged forever. See resolveLoadedClasses()'s own comment for +// the full rationale. +TEST_F(ReferenceChainsBfsTest, ResolveLoadedClassesRescansAfterClassCountShrinksAndPartiallyRegrows) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + void *classA = (void *)0x3001, *classB = (void *)0x3002, *classC = (void *)0x3003; + addClass(classA, "Lcom/rc/regress/A;"); + int idxB = addClass(classB, "Lcom/rc/regress/B;"); + + // Pass 1: both A and B loaded (count == 2) - both get resolved/tagged. + ReferenceChainsTestAccessor::resolveLoadedClasses(&mock_jvmti, &mock_jni); + EXPECT_EQ(2, ReferenceChainsTestAccessor::lastResolvedClassCount()); + ASSERT_NE(0u, tags.count(classA)); + ASSERT_NE(0u, tags.count(classB)); + EXPECT_NE(0, tags[classA]); + EXPECT_NE(0, tags[classB]); + + // Simulate B's classloader being GC'd: GetLoadedClasses() now reports + // only A (count shrinks 2 -> 1), exactly like a real class unload. + classes.erase(classes.begin() + idxB); + ReferenceChainsTestAccessor::resolveLoadedClasses(&mock_jvmti, &mock_jni); + EXPECT_EQ(1, ReferenceChainsTestAccessor::lastResolvedClassCount()); + + // Simulate a *different* class C loading back in, bringing the count + // back to 2 - the same count as pass 1's peak, but not the same class + // set. The buggy `>` guard (2 > 2 is false, since it never re-lowered + // _last_resolved_class_count on the shrink above either) would skip the + // scan here and leave C's tag at 0 forever; the fixed `!=` guard must + // still resolve it. + addClass(classC, "Lcom/rc/regress/C;"); + ReferenceChainsTestAccessor::resolveLoadedClasses(&mock_jvmti, &mock_jni); + EXPECT_EQ(2, ReferenceChainsTestAccessor::lastResolvedClassCount()); + ASSERT_NE(0u, tags.count(classC)); + EXPECT_NE(0, tags[classC]); // the regression this test guards against + + tracker->stop(); +} + +// --------------------------------------------------------------------------- +// Incremental resumption across passes (ReferenceChainTracker:: +// expandFrontier()/releaseSearchTags()/shouldRunPass(), and runPass()'s +// SearchState transitions). +// --------------------------------------------------------------------------- + +TEST_F(ReferenceChainsBfsTest, MultiPassResumptionReconstructsChainAcrossPasses) { + Arguments args; + // budget=1 forces each pass to admit at most one new frontier entry, so + // this 3-hop chain cannot be discovered within a single pass - + // exercising expandFrontier() (resumed passes), not just the first + // pass's root walk. + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1:firstpassbudget=1")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int nodeA = addNode(); + int nodeB = addNode(); + int nodeTarget = addNode(); + + script = { + {JVMTI_HEAP_REFERENCE_JNI_GLOBAL, -1, nodeA, -1}, + {JVMTI_HEAP_REFERENCE_FIELD, nodeA, nodeB, -1}, + {JVMTI_HEAP_REFERENCE_FIELD, nodeB, nodeTarget, -1}, + }; + + // Drive the search to completion one pass at a time, exactly as + // threadLoop() would once wired up (each call bounded by `budget`). + bool truncated = true; + int passes_issued = 0; + while (tracker->searchState() == SearchState::RUNNING && passes_issued < 20) { + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); + passes_issued++; + } + + ASSERT_EQ(SearchState::COMPLETED, tracker->searchState()); + EXPECT_GT(tracker->passesRun(), 1); // did not fit in a single pass + EXPECT_EQ(tracker->passesRun(), passes_issued); + + jlong targetTag = tags_ever_assigned[nodeTarget]; + ASSERT_NE(0, targetTag); + std::vector chain; + ASSERT_TRUE(tracker->frontierTable()->reconstructChain(targetTag, &chain)); + // Depth/parent_tag linkage survived resumption intact - all 3 hops walk + // back to a root-attached (depth 0) entry, which reconstructChain() + // requires to succeed at all (see its own "reaching parent_tag == 0" + // contract). + EXPECT_EQ(3u, chain.size()); + + tracker->stop(); +} + +TEST_F(ReferenceChainsBfsTest, FrontierCapAbandonsSearchAndReleasesTags) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000:framecap=1")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + ASSERT_EQ(1, tracker->frontierTable()->maxCapacity()); + + int nodeA = addNode(); + int nodeB = addNode(); + + script = { + {JVMTI_HEAP_REFERENCE_JNI_GLOBAL, -1, nodeA, -1}, // fits (the one slot) + {JVMTI_HEAP_REFERENCE_FIELD, nodeA, nodeB, -1}, // frontier cap hit + }; + + bool truncated = false; + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); + EXPECT_TRUE(truncated); + // Frontier-size cap, not just this pass's budget, was hit - the whole + // search is abandoned immediately (design doc's Termination section), + // rather than staying RUNNING for a later pass to retry. + ASSERT_EQ(SearchState::ABANDONED, tracker->searchState()); + + // Tag release on abandonment (design doc's Termination section; the + // exit criteria for this work: "an abandoned search cleans up its tags + // completely, no leak") - nodeA's tag was assigned in this same call, + // then released before runPass() returned. + EXPECT_NE(0, tags_ever_assigned[nodeA]); + EXPECT_EQ(0, node_tags[nodeA]); + EXPECT_EQ(0, node_tags[nodeB]); // never admitted at all + + // Abandonment reason and the JFR-event builder built from it. + EXPECT_EQ(SearchAbandonReason::FRONTIER_CAP, tracker->abandonReason()); + ReferenceChainAbandonedEvent event; + ASSERT_TRUE(tracker->buildAbandonedEvent(&event)); + EXPECT_EQ(SearchAbandonReason::FRONTIER_CAP, event._reason); + EXPECT_EQ(1, event._passes_run); + EXPECT_EQ(1, event._frontier_size); // the one slot framecap=1 allowed + EXPECT_EQ(64, event._hop_cap); + EXPECT_EQ(1000, event._budget); + + // A further runPass() call is a no-op - the search already reached a + // terminal outcome (starting a new search once one ends is not + // implemented - see runPass()'s own comment). + int passesBefore = tracker->passesRun(); + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); + EXPECT_EQ(passesBefore, tracker->passesRun()); + + tracker->stop(); +} + +// releaseSearchTags()'s GetObjectsWithTags() call failing must NOT be treated +// as "released" - see that method's own comment for why: marking a tag +// ABANDONED (or resetting _next_tag on restart) while its object might still +// be live would let a restarted search's fresh tags collide with it, +// corrupting FrontierTable's tag-uniqueness invariant. This is the regression +// test for that failure path (previously the return value was discarded +// entirely). +TEST_F(ReferenceChainsBfsTest, ReleaseSearchTagsFailureBlocksTagReuseUntilItSucceeds) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000:framecap=1")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int nodeA = addNode(); + int nodeB = addNode(); + // Both root references (not a nodeA->nodeB field edge): the manual + // walk's heapRootCallback() hits FRONTIER_CAP_HIT directly off + // IterateOverReachableObjects, with no GetObjectsWithTags call involved, + // so the mocked GetObjectsWithTags failure below is only ever observed + // by the release-tags call this abandonment triggers - not by discovery + // itself. + script = { + {JVMTI_HEAP_REFERENCE_JNI_GLOBAL, -1, nodeA, -1}, // fits (the one slot) + {JVMTI_HEAP_REFERENCE_JNI_GLOBAL, -1, nodeB, -1}, // second root - frontier cap hit + }; + + long long failedBefore = + Counters::getCounter(REFERENCE_CHAIN_TAG_RELEASE_FAILED); + + fail_get_objects_with_tags = true; + bool truncated = false; + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); + // Frontier-cap abandonment itself is independent of whether the tag + // release that follows succeeds. + ASSERT_EQ(SearchState::ABANDONED, tracker->searchState()); + + // GetObjectsWithTags() failed - nodeA's still-live tag must NOT have been + // cleared, and the failure must be counted (previously silently + // swallowed). + EXPECT_NE(0, tags_ever_assigned[nodeA]); + EXPECT_NE(0, node_tags[nodeA]) << "tag must not be cleared when the " + "release batch itself failed"; + EXPECT_FALSE(ReferenceChainsTestAccessor::tagsReleased()); + EXPECT_EQ(failedBefore + 1, + Counters::getCounter(REFERENCE_CHAIN_TAG_RELEASE_FAILED)); + + // While the release is still outstanding, shouldRunPass() must force a + // retry unconditionally - restartSearch()'s _next_tag reset must never + // run while a stale tag could still be live (see shouldRunPass()'s own + // comment). + EXPECT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(1)); + EXPECT_EQ(SearchState::ABANDONED, tracker->searchState()) + << "must retry the release in place, not restart, while tags are " + "still unreleased"; + + // A further runPass() call retries the release; still failing, it must + // still refuse to mark the tag released. + int passesBefore = tracker->passesRun(); + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); + EXPECT_EQ(passesBefore, tracker->passesRun()); + EXPECT_NE(0, node_tags[nodeA]); + EXPECT_FALSE(ReferenceChainsTestAccessor::tagsReleased()); + EXPECT_EQ(failedBefore + 2, + Counters::getCounter(REFERENCE_CHAIN_TAG_RELEASE_FAILED)); + + // Once GetObjectsWithTags() starts succeeding again, the very next + // runPass() call must actually clear the tag and confirm the release. + fail_get_objects_with_tags = false; + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); + EXPECT_EQ(0, node_tags[nodeA]); + EXPECT_TRUE(ReferenceChainsTestAccessor::tagsReleased()); + EXPECT_EQ(failedBefore + 2, + Counters::getCounter(REFERENCE_CHAIN_TAG_RELEASE_FAILED)) + << "a successful release must not itself count as a failure"; + + tracker->stop(); +} + +TEST_F(ReferenceChainsBfsTest, TTLAbandonsSearchAndReleasesTags) { + Arguments args; + // ttl=1 (1ms) with budget=1 on a graph deeper than one pass can cover - + // the wall-clock TTL, not the hop/frontier cap, should force + // abandonment here. + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1:ttl=1:firstpassbudget=1")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int nodeA = addNode(); + int nodeB = addNode(); + int nodeC = addNode(); + int nodeD = addNode(); + + // A chain one node longer than either pass's 1-edge expand budget can + // fully drain in a single call, so each pass still ends truncated (see + // mock_FollowReferences()'s own comment: an array-holder walk chains + // through as many script edges as it can admit before budget aborts it) + // and there is still pending work left for the TTL check to catch. + script = { + {JVMTI_HEAP_REFERENCE_JNI_GLOBAL, -1, nodeA, -1}, + {JVMTI_HEAP_REFERENCE_FIELD, nodeA, nodeB, -1}, + {JVMTI_HEAP_REFERENCE_FIELD, nodeB, nodeC, -1}, + {JVMTI_HEAP_REFERENCE_FIELD, nodeC, nodeD, -1}, + }; + + bool truncated = false; + // Pass 1 (root walk + expand): root enum admits nodeA, and the expand + // phase's own separate 1-unit budget admits nodeB before aborting on + // nodeB->nodeC for lack of budget - truncated. + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); + EXPECT_TRUE(truncated); + ASSERT_EQ(SearchState::RUNNING, tracker->searchState()); + + std::this_thread::sleep_for(std::chrono::milliseconds(5)); // exceed the 1ms TTL + + // Pass 2 (resumed): admits nodeC, then aborts on nodeC->nodeD for lack + // of budget - still truncated, still pending work (nodeD undiscovered), + // but the TTL from the search's first pass has now elapsed. + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); + EXPECT_TRUE(truncated); + EXPECT_EQ(SearchState::ABANDONED, tracker->searchState()); + + // Tags released, including nodeA's and nodeB's from pass 1. + EXPECT_NE(0, tags_ever_assigned[nodeA]); + EXPECT_EQ(0, node_tags[nodeA]); + EXPECT_NE(0, tags_ever_assigned[nodeB]); + EXPECT_EQ(0, node_tags[nodeB]); + + // TTL, not the frontier cap, is reported as the reason. + EXPECT_EQ(SearchAbandonReason::TTL, tracker->abandonReason()); + ReferenceChainAbandonedEvent event; + ASSERT_TRUE(tracker->buildAbandonedEvent(&event)); + EXPECT_EQ(SearchAbandonReason::TTL, event._reason); + EXPECT_EQ(2, event._passes_run); + EXPECT_EQ(1, event._ttl_ms); + + tracker->stop(); +} + +TEST_F(ReferenceChainsBfsTest, ResolveOrDropPrunesDeadFrontierEntries) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1:firstpassbudget=1")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int nodeA = addNode(); + int nodeB = addNode(); + // A second root that root enumeration's own 1-unit budget (firstpassbudget=1) + // can't reach this pass - the resulting root-enum truncation makes + // runPassManualWalk() return before expandFrontier() ever runs (see its + // own comment on frontier-cap-hit/budget-exhausted root-enum truncation), + // so nodeA is admitted but never gets a chance to expand nodeA->nodeB. + int decoyRoot = addNode(); + + script = { + {JVMTI_HEAP_REFERENCE_JNI_GLOBAL, -1, nodeA, -1}, + {JVMTI_HEAP_REFERENCE_JNI_GLOBAL, -1, decoyRoot, -1}, + {JVMTI_HEAP_REFERENCE_FIELD, nodeA, nodeB, -1}, + }; + + bool truncated = false; + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); // pass 1 + ASSERT_TRUE(truncated); + ASSERT_EQ(SearchState::RUNNING, tracker->searchState()); + + jlong aTag = tags_ever_assigned[nodeA]; + ASSERT_NE(0, aTag); + // Simulate nodeA dying (collected) between pass 1 and pass 2 - + // GetObjectsWithTags will no longer report it as live. + dead_tags.insert(aTag); + + ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); // pass 2: resolve-or-drop + EXPECT_FALSE(truncated); + // The dead branch was pruned for free - with nothing else pending, the + // search completes rather than staying RUNNING or being ABANDONED. + EXPECT_EQ(SearchState::COMPLETED, tracker->searchState()); + EXPECT_EQ(2, tracker->passesRun()); + + FrontierEntry entry{}; + ASSERT_TRUE(tracker->frontierTable()->lookup(aTag, &entry)); + EXPECT_EQ(FrontierEntryState::ABANDONED, entry.state); + + // nodeB was never discovered - nodeA's subtree was pruned, not expanded. + EXPECT_EQ(0, tags_ever_assigned[nodeB]); + + tracker->stop(); +} + +// --------------------------------------------------------------------------- +// pollWatchedTargets() (design doc's Open Question 3 bridging +// step, corrected read-only mechanism - see referenceChains.h's own +// target-selection bridging step header comment and pollWatchedTargets()'s +// comment for the plan doc's "Correction to the design doc's Open Question 3 +// mechanism"). +// +// Mirrors referenceChainJfrRoundtrip_ut.cpp's FrontierTable::insert() +// seeding style rather than driving a full scripted runPass() (this test +// suite's gtest coverage bullet explicitly asks for reusing that seeding style over +// writing a third pattern): a candidate's "already discovered by an +// ordinary pass" tag is modelled directly as a FrontierTable entry plus a +// mocked GetTag() that reports it - both reach the same FrontierTable + tag +// state pollWatchedTargets()/buildChainEvent() read, regardless of whether a +// scripted heap walk or direct insertion produced it. +// +// LivenessTracker::instance() is a second process-wide singleton (see its +// own header comment) shared with livenessTracker_ut.cpp within this same +// gtest binary - klassPopulationResetForTest()/setGcGenerationsForTest() at +// SetUp/TearDown keep this suite's use of it self-contained, the same way +// ReferenceChainsTestAccessor::reset() already isolates +// ReferenceChainTracker::instance() above. +// --------------------------------------------------------------------------- + +class PollWatchedTargetsTest : public ::testing::Test { +protected: + jvmtiInterface_1_ jvmti_tbl{}; + _jvmtiEnv mock_jvmti{}; + JNINativeInterface_ jni_tbl{}; + JNIEnv_ mock_jni{}; + + std::unordered_map tags; + std::unordered_set dead_refs; // NewLocalRef returns NULL for these + + jvmtiEnv *orig_jvmti = nullptr; + static PollWatchedTargetsTest *active_fixture; + + void SetUp() override { + active_fixture = this; + ReferenceChainsTestAccessor::reset(); + LivenessTracker::instance()->klassPopulationResetForTest(); + LivenessTracker::instance()->setGcGenerationsForTest(true); + + jvmti_tbl = jvmtiInterface_1_{}; + jvmti_tbl.SetEventNotificationMode = &mock_SetEventNotificationMode; + jvmti_tbl.GetTag = &mock_GetTag; + mock_jvmti.functions = &jvmti_tbl; + orig_jvmti = VMTestAccessor::getJvmti(); + VMTestAccessor::setJvmti(&mock_jvmti); + + jni_tbl = JNINativeInterface_{}; + jni_tbl.NewLocalRef = &mock_NewLocalRef; + jni_tbl.DeleteLocalRef = &mock_DeleteLocalRef; + mock_jni.functions = &jni_tbl; + } + + void TearDown() override { + VMTestAccessor::setJvmti(orig_jvmti); + LivenessTracker::instance()->klassPopulationResetForTest(); + LivenessTracker::instance()->setGcGenerationsForTest(false); + active_fixture = nullptr; + } + + static jvmtiError JNICALL mock_GetTag(jvmtiEnv *, jobject object, jlong *tag_ptr) { + auto it = active_fixture->tags.find(object); + *tag_ptr = it != active_fixture->tags.end() ? it->second : 0; + return JVMTI_ERROR_NONE; + } + + static jobject JNICALL mock_NewLocalRef(JNIEnv *, jobject ref) { + if (active_fixture->dead_refs.count(ref) > 0) { + return nullptr; + } + return ref; // identity passthrough - see this fixture's own comment + } + + static void JNICALL mock_DeleteLocalRef(JNIEnv *, jobject) { + // no-op: this fixture's fake jobject values are not real JNI refs. + } + + // Seeds LivenessTracker's real population table with a growing series + // for `klass_id` (20 strictly-increasing samples - satisfies + // selectLeakCandidates()'s min-fill, growth/floor magnitude, and + // sustained-trend hysteresis requirements, livenessTracker.h; 20 rather + // than the 10-sample minimum fill leaves comfortable margin past the + // hysteresis threshold rather than sitting exactly on its boundary) and + // points its representative at `rep`. + void seedGrowingCandidate(u32 klass_id, jweak rep) { + int slot; + bool created; + for (u16 i = 1; i <= 20; i++) { + LivenessTracker::instance()->klassPopulationRecordForTest( + klass_id, i, i, &slot, &created); + } + LivenessTracker::instance()->klassPopulationSetRepresentativeForTest(nullptr, klass_id, rep); + } +}; + +PollWatchedTargetsTest *PollWatchedTargetsTest::active_fixture = nullptr; + +TEST_F(PollWatchedTargetsTest, EmitsEventForAlreadyDiscoveredCandidate) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int fake_object_storage = 0; + jobject obj = reinterpret_cast(&fake_object_storage); + seedGrowingCandidate(/*klass_id=*/1, /*rep=*/(jweak)obj); + + // Model "already discovered by an ordinary runPass()": a root-level + // FrontierTable entry plus a matching GetTag() result, mirroring + // referenceChainJfrRoundtrip_ut.cpp's seeding style. + ASSERT_TRUE(tracker->frontierTable()->insert( + /*tag=*/7, /*parent_tag=*/0, /*referrer_klass=*/1, /*depth=*/0, + FrontierEntryState::EDGE)); + tags[obj] = 7; + + tracker->pollWatchedTargets(&mock_jvmti, &mock_jni); + + EXPECT_EQ(1u, ReferenceChainsTestAccessor::resolvedChainCount()); + EXPECT_TRUE(ReferenceChainsTestAccessor::hasResolvedChainForKlass(1)); + EXPECT_EQ(7, ReferenceChainsTestAccessor::resolvedChainSourceTag(1)); + + tracker->stop(); +} + +TEST_F(PollWatchedTargetsTest, NoEventForNotYetDiscoveredCandidate) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int fake_object_storage = 0; + jobject obj = reinterpret_cast(&fake_object_storage); + seedGrowingCandidate(/*klass_id=*/1, /*rep=*/(jweak)obj); + // GetTag() reports 0 (default) - no pass has reached this object yet. + + tracker->pollWatchedTargets(&mock_jvmti, &mock_jni); + + EXPECT_EQ(0u, ReferenceChainsTestAccessor::resolvedChainCount()); + + tracker->stop(); +} + +TEST_F(PollWatchedTargetsTest, NoDuplicateOnRepeatPoll) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int fake_object_storage = 0; + jobject obj = reinterpret_cast(&fake_object_storage); + seedGrowingCandidate(/*klass_id=*/1, /*rep=*/(jweak)obj); + + ASSERT_TRUE(tracker->frontierTable()->insert( + 7, 0, 1, 0, FrontierEntryState::EDGE)); + tags[obj] = 7; + + tracker->pollWatchedTargets(&mock_jvmti, &mock_jni); + ASSERT_EQ(1u, ReferenceChainsTestAccessor::resolvedChainCount()); + + // Klass 1 is still flagged (LivenessTracker's ranking doesn't know an + // event was already emitted for it) - a second, third, ... poll must + // not re-emit for the same target_tag. + tracker->pollWatchedTargets(&mock_jvmti, &mock_jni); + tracker->pollWatchedTargets(&mock_jvmti, &mock_jni); + EXPECT_EQ(1u, ReferenceChainsTestAccessor::resolvedChainCount()); + EXPECT_TRUE(ReferenceChainsTestAccessor::hasResolvedChainForKlass(1)); + EXPECT_EQ(7, ReferenceChainsTestAccessor::resolvedChainSourceTag(1)); + + tracker->stop(); +} + +TEST_F(PollWatchedTargetsTest, SkipsCandidateWhoseWeakReferenceDied) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int fake_object_storage = 0; + jobject obj = reinterpret_cast(&fake_object_storage); + seedGrowingCandidate(/*klass_id=*/1, /*rep=*/(jweak)obj); + dead_refs.insert(obj); // NewLocalRef(rep) -> NULL, as if GC'd + + ASSERT_TRUE(tracker->frontierTable()->insert( + 7, 0, 1, 0, FrontierEntryState::EDGE)); + tags[obj] = 7; // would resolve to a discovered tag, if it could resolve + + tracker->pollWatchedTargets(&mock_jvmti, &mock_jni); + + EXPECT_EQ(0u, ReferenceChainsTestAccessor::resolvedChainCount()); + + tracker->stop(); +} + +// A cached chain must not re-emit forever: once the klass's representative +// stops resolving (collected, or LRU-evicted from LivenessTracker's +// population table - klassPopulationSetRepresentativeForTest()'s ref is the +// stand-in for either), the very next poll must prune it from +// _resolved_chains rather than leaving a dump keep re-emitting a chain for a +// sample that is gone (see _resolved_chains' own comment, referenceChains.h, +// and pollWatchedTargets()'s "candidate died, or was evicted" branch). +TEST_F(PollWatchedTargetsTest, PruneStopsReemittingAfterRepresentativeDies) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int fake_object_storage = 0; + jobject obj = reinterpret_cast(&fake_object_storage); + seedGrowingCandidate(/*klass_id=*/1, /*rep=*/(jweak)obj); + + ASSERT_TRUE(tracker->frontierTable()->insert( + 7, 0, 1, 0, FrontierEntryState::EDGE)); + tags[obj] = 7; + + tracker->pollWatchedTargets(&mock_jvmti, &mock_jni); + ASSERT_EQ(1u, ReferenceChainsTestAccessor::resolvedChainCount()); + ASSERT_TRUE(ReferenceChainsTestAccessor::hasResolvedChainForKlass(1)); + + // The sample is gone: its representative no longer resolves. + dead_refs.insert(obj); + + tracker->pollWatchedTargets(&mock_jvmti, &mock_jni); + + EXPECT_EQ(0u, ReferenceChainsTestAccessor::resolvedChainCount()) + << "a dead representative's cached chain must be pruned, not kept " + "re-emitting into every later dump"; + EXPECT_FALSE(ReferenceChainsTestAccessor::hasResolvedChainForKlass(1)); + + tracker->stop(); +} + +TEST_F(PollWatchedTargetsTest, NoOpWhenGcGenerationsDisabled) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + // Overrides this fixture's own SetUp() default - exercises the + // pollWatchedTargets() guard covering LivenessTracker's own + // _gc_generations gate (population tracking's own gate), not just this tracker's own _enabled. + LivenessTracker::instance()->setGcGenerationsForTest(false); + + int fake_object_storage = 0; + jobject obj = reinterpret_cast(&fake_object_storage); + seedGrowingCandidate(/*klass_id=*/1, /*rep=*/(jweak)obj); + ASSERT_TRUE(tracker->frontierTable()->insert( + 7, 0, 1, 0, FrontierEntryState::EDGE)); + tags[obj] = 7; + + tracker->pollWatchedTargets(&mock_jvmti, &mock_jni); + + EXPECT_EQ(0u, ReferenceChainsTestAccessor::resolvedChainCount()); + + tracker->stop(); +} + +// --------------------------------------------------------------------------- +// Resolved-chain cache (ReferenceChainTracker::cacheResolvedChain()/ +// drainPendingChainEvents(), referenceChains.cpp) - the mechanism that keeps a +// resolved chain alive across dumps so it re-emits into every JFR chunk the +// sample survives into, and keeps Profiler::writeReferenceChain()'s blocking +// lock-acquisition retry loop off the BFS scheduling thread (see +// _resolved_chains' own comment, referenceChains.h). These tests drive +// cacheResolvedChain()/drainPendingChainEvents() directly via +// ReferenceChainsTestAccessor rather than through the full +// pollWatchedTargets()/selectLeakCandidates() pipeline - the cache/overflow/ +// counter mechanism is independent of how a chain was produced, and driving it +// through hundreds of real LivenessTracker candidates just to reach +// MAX_RESOLVED_CHAINS would test the seeding helper, not this mechanism. +// --------------------------------------------------------------------------- + +class ResolvedChainCacheTest : public ::testing::Test { +protected: + void SetUp() override { + ReferenceChainsTestAccessor::reset(); + } + + void TearDown() override { + ReferenceChainsTestAccessor::reset(); + } + + static ReferenceChainEvent makeEvent(u64 target_tag) { + ReferenceChainEvent event; + event._target_tag = target_tag; + event._depth = 0; + return event; + } +}; + +// The defining property of the "stick around" model: a cached chain is +// re-emitted on every dump, not drained once. Two successive drains with no +// intervening change must BOTH return the cached chain, and the cache must +// stay populated afterwards (unlike the old queue, which emptied on drain). +TEST_F(ResolvedChainCacheTest, SnapshotReEmitsOnEveryDumpWithoutClearing) { + ReferenceChainsTestAccessor::cacheChain(/*klass_id=*/1, makeEvent(7), + /*source_tag=*/7, /*search_ns=*/0); + + std::vector firstDump; + ReferenceChainsTestAccessor::drain(&firstDump); + ASSERT_EQ(1u, firstDump.size()); + EXPECT_EQ(7u, firstDump[0]._target_tag); + EXPECT_EQ(1u, ReferenceChainsTestAccessor::resolvedChainCount()) + << "drain must not clear the cache"; + + // A second dump with nothing changed re-emits the same chain. + std::vector secondDump; + ReferenceChainsTestAccessor::drain(&secondDump); + ASSERT_EQ(1u, secondDump.size()); + EXPECT_EQ(7u, secondDump[0]._target_tag); + EXPECT_EQ(1u, ReferenceChainsTestAccessor::resolvedChainCount()); +} + +// Re-resolving the same klass (a restart re-tags its sample, or a fresh walk +// finds a deeper path) refreshes its single cache slot in place rather than +// accumulating duplicates - so a dump re-emits one current chain per klass, +// not one per resolution. +TEST_F(ResolvedChainCacheTest, RefreshReplacesSameKlassInPlace) { + ReferenceChainsTestAccessor::cacheChain(1, makeEvent(7), 7, 0); + ASSERT_EQ(1u, ReferenceChainsTestAccessor::resolvedChainCount()); + EXPECT_EQ(7, ReferenceChainsTestAccessor::resolvedChainSourceTag(1)); + + // Same klass, rebuilt from a new tag (e.g. after a search restart). + ReferenceChainsTestAccessor::cacheChain(1, makeEvent(9), 9, 0); + EXPECT_EQ(1u, ReferenceChainsTestAccessor::resolvedChainCount()) + << "refresh must overwrite, not append"; + EXPECT_EQ(9, ReferenceChainsTestAccessor::resolvedChainSourceTag(1)); + + std::vector dump; + ReferenceChainsTestAccessor::drain(&dump); + ASSERT_EQ(1u, dump.size()); + EXPECT_EQ(9u, dump[0]._target_tag); +} + +// Distinct klasses each get their own slot and all re-emit together in one +// dump (order is unspecified - the cache is a map keyed by klass_id). +TEST_F(ResolvedChainCacheTest, MultipleKlassesAllSnapshotTogether) { + ReferenceChainsTestAccessor::cacheChain(1, makeEvent(1), 1, 0); + ReferenceChainsTestAccessor::cacheChain(2, makeEvent(2), 2, 0); + ReferenceChainsTestAccessor::cacheChain(3, makeEvent(3), 3, 0); + ASSERT_EQ(3u, ReferenceChainsTestAccessor::resolvedChainCount()); + + std::vector dump; + ReferenceChainsTestAccessor::drain(&dump); + ASSERT_EQ(3u, dump.size()); + std::set tags; + for (const auto &e : dump) { + tags.insert(e._target_tag); + } + EXPECT_EQ((std::set{1, 2, 3}), tags); +} + +// A brand-new klass arriving with the cache already at MAX_RESOLVED_CHAINS is +// dropped (and counted via REFERENCE_CHAIN_EVENTS_DROPPED, this codebase's own +// "dropped-event-without-counter" review lens) rather than evicting some other +// still-live sample's chain - but refreshing a klass that is already cached +// still succeeds even at capacity. +TEST_F(ResolvedChainCacheTest, OverflowDropsNewKlassButAllowsRefresh) { + const int cap = ReferenceChainsTestAccessor::maxResolvedChains(); + long long droppedBefore = Counters::getCounter(REFERENCE_CHAIN_EVENTS_DROPPED); + + for (int i = 0; i < cap; i++) { + ReferenceChainsTestAccessor::cacheChain((u32)i, makeEvent((u64)i), + (jlong)i, 0); + } + ASSERT_EQ((size_t)cap, ReferenceChainsTestAccessor::resolvedChainCount()); + EXPECT_EQ(droppedBefore, Counters::getCounter(REFERENCE_CHAIN_EVENTS_DROPPED)) + << "filling exactly to capacity must not drop anything yet"; + + // A brand-new klass at capacity is dropped and counted. + ReferenceChainsTestAccessor::cacheChain((u32)cap, makeEvent((u64)cap), + (jlong)cap, 0); + EXPECT_EQ((size_t)cap, ReferenceChainsTestAccessor::resolvedChainCount()) + << "cache must stay capped, not grow past MAX_RESOLVED_CHAINS"; + EXPECT_EQ(droppedBefore + 1, Counters::getCounter(REFERENCE_CHAIN_EVENTS_DROPPED)); + EXPECT_FALSE(ReferenceChainsTestAccessor::hasResolvedChainForKlass((u32)cap)); + + // Refreshing an already-cached klass at capacity must still succeed - it + // reuses that klass's existing slot rather than needing a free one. + ReferenceChainsTestAccessor::cacheChain(/*klass_id=*/0, makeEvent(999), + /*source_tag=*/999, 0); + EXPECT_EQ((size_t)cap, ReferenceChainsTestAccessor::resolvedChainCount()); + EXPECT_EQ(999, ReferenceChainsTestAccessor::resolvedChainSourceTag(0)); + EXPECT_EQ(droppedBefore + 1, Counters::getCounter(REFERENCE_CHAIN_EVENTS_DROPPED)) + << "an in-place refresh must not count as a drop"; +} + +// --------------------------------------------------------------------------- +// Pause-time pacing controller: pause-time-SLO feedback loop +// (ReferenceChainTracker::updatePacing(), referenceChains.cpp) - see that +// method's own comment (referenceChains.h) for the full mechanism. These +// tests drive updatePacing() directly with a synthetic sequence of "pass +// took Xms" wall-clock durations via ReferenceChainsTestAccessor (this +// file's existing pattern for reaching a private method/state - see the +// target-selection bridging step's hasResolvedChainForKlass()/resolvedChainCount() above), +// reusing the ReferenceChainsTest fixture since updatePacing() itself makes +// no JVMTI calls. +// --------------------------------------------------------------------------- + +TEST_F(ReferenceChainsTest, PacingHoldsSteadyWhenPassesLandExactlyOnCeiling) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:budget=1000:pausetarget=5")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int startBudget = ReferenceChainsTestAccessor::effectiveBudget(); + u64 startCadence = ReferenceChainsTestAccessor::effectiveCadenceNs(); + ASSERT_EQ(1000, startBudget); // starts pinned at the configured ceiling + + // A pass landing exactly on the pause-time target is a zero error every + // call - the controller should never move away from its starting point, + // regardless of how many such passes are observed in a row. + for (int i = 0; i < 10; i++) { + ReferenceChainsTestAccessor::updatePacing(5 * 1000000ULL); // 5ms + EXPECT_EQ(startBudget, ReferenceChainsTestAccessor::effectiveBudget()); + EXPECT_EQ(startCadence, ReferenceChainsTestAccessor::effectiveCadenceNs()); + } + + tracker->stop(); +} + +TEST_F(ReferenceChainsTest, PacingShrinksBudgetAndWidensCadenceWhenOverCeiling) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:budget=1000:pausetarget=5")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int initialBudget = ReferenceChainsTestAccessor::effectiveBudget(); + u64 initialCadence = ReferenceChainsTestAccessor::effectiveCadenceNs(); + + // A pass taking 10x the pause-time ceiling, fed repeatedly (a constant + // input - the plan's own "does not oscillate indefinitely" scenario). + int lastBudget = initialBudget; + u64 lastCadence = initialCadence; + for (int i = 0; i < 20; i++) { + ReferenceChainsTestAccessor::updatePacing(50 * 1000000ULL); // 50ms + int budget = ReferenceChainsTestAccessor::effectiveBudget(); + u64 cadence = ReferenceChainsTestAccessor::effectiveCadenceNs(); + EXPECT_LE(budget, lastBudget); // never grows while still over ceiling + EXPECT_GE(cadence, lastCadence); // never shrinks while still over ceiling + lastBudget = budget; + lastCadence = cadence; + } + + // Moved in the correct direction... + EXPECT_LT(lastBudget, initialBudget); + EXPECT_GT(lastCadence, initialCadence); + // ...and converged to a fixed point rather than oscillating: one more + // identical input produces no further change. + ReferenceChainsTestAccessor::updatePacing(50 * 1000000ULL); + EXPECT_EQ(lastBudget, ReferenceChainsTestAccessor::effectiveBudget()); + EXPECT_EQ(lastCadence, ReferenceChainsTestAccessor::effectiveCadenceNs()); + + tracker->stop(); +} + +TEST_F(ReferenceChainsTest, PacingGrowsBudgetBackAndRelaxesCadenceWhenUnderCeiling) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:budget=1000:pausetarget=5")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + // Start from a controlled below-ceiling/above-baseline point (as if an + // earlier over-ceiling run had already shrunk/widened them - see the + // previous test) with a freshly reset controller, rather than chaining + // directly off a constant-input sequence like the previous test's own: + // _pause_pid's integral state would otherwise still be recovering from + // that sequence's windup for many iterations after switching to a + // smaller-magnitude error, muddying this test's per-step "moves in the + // correct direction every step" assertions with a transient this test + // is not about. + ReferenceChainsTestAccessor::setEffectiveBudget(200); + ReferenceChainsTestAccessor::setEffectiveCadenceNs( + 2 * ReferenceChainsTestAccessor::baselineCadenceNs()); + ReferenceChainsTestAccessor::resetPacingController(); + int shrunkBudget = ReferenceChainsTestAccessor::effectiveBudget(); + u64 widenedCadence = ReferenceChainsTestAccessor::effectiveCadenceNs(); + + // Now feed passes comfortably under the ceiling, repeatedly (a constant + // input, to check convergence rather than oscillation). 50 iterations - + // more than PacingShrinksBudgetAndWidensCadenceWhenOverCeiling needs - + // because this scenario's error magnitude (pausetarget=5 vs. an + // effectively-instant 0ms pass) is smaller, so the cadence side takes + // more iterations to fully unwind down to MIN_EFFECTIVE_CADENCE_NS. + int lastBudget = shrunkBudget; + u64 lastCadence = widenedCadence; + for (int i = 0; i < 50; i++) { + ReferenceChainsTestAccessor::updatePacing(0); // effectively instant + int budget = ReferenceChainsTestAccessor::effectiveBudget(); + u64 cadence = ReferenceChainsTestAccessor::effectiveCadenceNs(); + EXPECT_GE(budget, lastBudget); // never shrinks while comfortably under + EXPECT_LE(cadence, lastCadence); // never widens while comfortably under + lastBudget = budget; + lastCadence = cadence; + } + + // Moved in the correct direction... and, since 50 identical + // comfortably-under-target passes is well past BORROW_WARMUP_PASSES, + // past the configured ceiling too - budget-borrowing lets it converge at + // the borrowed ceiling (configured budget * multiplier) instead of + // stalling at the plain configured budget. + EXPECT_GT(lastBudget, shrunkBudget); + EXPECT_EQ(1000 * ReferenceChainsTestAccessor::borrowCeilingMultiplier(), lastBudget); + EXPECT_LT(lastCadence, widenedCadence); + // ...and converged: one more identical input produces no further change. + ReferenceChainsTestAccessor::updatePacing(0); + EXPECT_EQ(lastBudget, ReferenceChainsTestAccessor::effectiveBudget()); + EXPECT_EQ(lastCadence, ReferenceChainsTestAccessor::effectiveCadenceNs()); + + tracker->stop(); +} + +TEST_F(ReferenceChainsTest, MaybeRevokeBorrowForRootEnumPassPreservesBorrowAtBoundary) { + Arguments args; + // BORROW_UNDER_TARGET_FRACTION (referenceChains.h) is 0.5, so with + // pausetarget=10 the comfortably-under-target boundary is exactly 5ms. + ASSERT_FALSE(args.parse("referencechains=true:budget=1000:pausetarget=10")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + ReferenceChainsTestAccessor::setBorrowedBudget(500); + ReferenceChainsTestAccessor::setConsecutiveUnderTargetPasses(5); + + // Exactly at the boundary: comfortably_under_target's `<=` check must + // still treat this as comfortably under, so the borrow is preserved. + ReferenceChainsTestAccessor::maybeRevokeBorrowForRootEnumPass(5 * 1000000ULL); + EXPECT_EQ(500, ReferenceChainsTestAccessor::borrowedBudget()); + EXPECT_EQ(5, ReferenceChainsTestAccessor::consecutiveUnderTargetPasses()); + + tracker->stop(); +} + +TEST_F(ReferenceChainsTest, MaybeRevokeBorrowForRootEnumPassRevokesJustPastBoundary) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:budget=1000:pausetarget=10")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + ReferenceChainsTestAccessor::setBorrowedBudget(500); + ReferenceChainsTestAccessor::setConsecutiveUnderTargetPasses(5); + ReferenceChainsTestAccessor::setEffectiveBudget(1500); // as if borrow had raised the ceiling + + // Just past the boundary: no longer comfortably under target, so the + // grant is revoked immediately, including re-clamping _effective_budget + // down to the plain (non-borrowed) budget rather than leaving it + // borrow-inflated until the next ordinary pass's updatePacing() call. + ReferenceChainsTestAccessor::maybeRevokeBorrowForRootEnumPass(6 * 1000000ULL); + EXPECT_EQ(0, ReferenceChainsTestAccessor::borrowedBudget()); + EXPECT_EQ(0, ReferenceChainsTestAccessor::consecutiveUnderTargetPasses()); + EXPECT_EQ(1000, ReferenceChainsTestAccessor::effectiveBudget()); + + tracker->stop(); +} + +// --------------------------------------------------------------------------- +// PainBudget (painBudget.h) - standalone, no ReferenceChainTracker singleton +// involved. A leaky bucket over cost (ms), not an event rate: spend() +// records how much an operation cost, canStartNow() drains the balance by +// elapsed wall-clock time at the configured refill rate and reports whether +// the debt has cleared. +// --------------------------------------------------------------------------- + +TEST(PainBudgetTest, ClearBeforeAnythingIsEverSpent) { + PainBudget budget(0.01); + EXPECT_TRUE(budget.canStartNow(1000)); +} + +TEST(PainBudgetTest, SpendCreatesDebtThatBlocksAnImmediateSecondCall) { + PainBudget budget(0.01); // 1% + ASSERT_TRUE(budget.canStartNow(1000)); // establishes the drain baseline + budget.spend(100); // 100ms of debt + // No time has elapsed since the baseline call above - the debt cannot + // have drained at all yet. + EXPECT_FALSE(budget.canStartNow(1000)); +} + +TEST(PainBudgetTest, DebtDrainsProportionallyToElapsedTimeAndRefillRate) { + PainBudget budget(0.01); // 1% -> 1ms of debt needs 100ms elapsed to clear + ASSERT_TRUE(budget.canStartNow(0)); + budget.spend(10); // 10ms of debt -> needs 1000ms elapsed to fully clear + EXPECT_FALSE(budget.canStartNow(500ULL * 1000000ULL)); // 500ms elapsed - not enough + EXPECT_TRUE(budget.canStartNow(1500ULL * 1000000ULL)); // 1500ms total - enough +} + +TEST(PainBudgetTest, ZeroRefillRateNeverClearsDebt) { + PainBudget budget(0.0); + ASSERT_TRUE(budget.canStartNow(0)); + budget.spend(1); + // An enormous elapsed time still drains nothing at a 0 refill rate. + EXPECT_FALSE(budget.canStartNow(1000000000000ULL)); +} + +// --------------------------------------------------------------------------- +// Search restart (referenceChains.h's own header comment: gating a +// restarted search's first pass on LivenessTracker already reporting a leak +// candidate, plus the PainBudget cooldown above, so a search that already +// walked the whole reachable graph once does not do so again indefinitely +// without a reason). Uses an "empty reachable graph" FollowReferences mock +// (no callback invocations at all) to reach SearchState::COMPLETED in one +// call - the simplest way to drive a search to a terminal state without +// ReferenceChainsBfsTest's full scripted-graph machinery, which exists for +// chain-reconstruction coverage this suite does not need. +// --------------------------------------------------------------------------- + +class SearchRestartTest : public ::testing::Test { +protected: + jvmtiInterface_1_ jvmti_tbl{}; + _jvmtiEnv mock_jvmti{}; + jvmtiEnv *orig_jvmti = nullptr; + + void SetUp() override { + ReferenceChainsTestAccessor::reset(); + LivenessTracker::instance()->klassPopulationResetForTest(); + LivenessTracker::instance()->setGcGenerationsForTest(false); + + jvmti_tbl = jvmtiInterface_1_{}; + jvmti_tbl.SetEventNotificationMode = &mock_SetEventNotificationMode; + jvmti_tbl.GetLoadedClasses = &mock_GetLoadedClasses; + jvmti_tbl.FollowReferences = &mock_FollowReferences; + jvmti_tbl.IterateOverReachableObjects = &mock_IterateOverReachableObjects; + mock_jvmti.functions = &jvmti_tbl; + orig_jvmti = VMTestAccessor::getJvmti(); + VMTestAccessor::setJvmti(&mock_jvmti); + } + + void TearDown() override { + VMTestAccessor::setJvmti(orig_jvmti); + LivenessTracker::instance()->klassPopulationResetForTest(); + LivenessTracker::instance()->setGcGenerationsForTest(false); + } + + // No loaded classes to resolve - resolveLoadedClasses() reports 0 and + // does nothing further. + static jvmtiError JNICALL mock_GetLoadedClasses(jvmtiEnv *, jint *count, + jclass **out) { + *count = 0; + *out = nullptr; + return JVMTI_ERROR_NONE; + } + + // Never invokes the callback - models a heap with nothing reachable from + // any root, so the very first pass completes immediately (0 admitted + // edges, not truncated). + static jvmtiError JNICALL mock_FollowReferences( + jvmtiEnv *, jint, jclass, jobject, const jvmtiHeapCallbacks *, + const void *) { + return JVMTI_ERROR_NONE; + } + + // runPassManualWalk()'s root enumeration - never invokes the root + // callback, same "nothing reachable from any root" heap model as + // mock_FollowReferences() above, so the first pass still completes + // immediately with 0 admitted edges. + static jvmtiError JNICALL mock_IterateOverReachableObjects( + jvmtiEnv *, jvmtiHeapRootCallback, jvmtiStackReferenceCallback, + jvmtiObjectReferenceCallback, const void *) { + return JVMTI_ERROR_NONE; + } + + // Same seeding helper as PollWatchedTargetsTest above (20 strictly- + // increasing samples - satisfies selectLeakCandidates()'s min-fill, + // growth/floor magnitude, and sustained-trend hysteresis requirements). + void seedGrowingCandidate(u32 klass_id, jweak rep) { + int slot; + bool created; + for (u16 i = 1; i <= 20; i++) { + LivenessTracker::instance()->klassPopulationRecordForTest( + klass_id, i, i, &slot, &created); + } + LivenessTracker::instance()->klassPopulationSetRepresentativeForTest(nullptr, klass_id, rep); + } +}; + +TEST_F(SearchRestartTest, WithoutGenerationsSignalRestartStaysUnconditional) { + // gc_generations off (this fixture's SetUp default): canAffordNewSearch() + // has no candidate signal to gate on at all, so a terminal search is + // immediately eligible to restart - preserves this tracker's pre-restart + // behavior for a referencechains-without-generations setup (this class's + // own header comment, last paragraph). + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + ASSERT_TRUE(tracker->runPass(&mock_jvmti, nullptr)); + ASSERT_EQ(SearchState::COMPLETED, tracker->searchState()); + + EXPECT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(1)); + EXPECT_EQ(SearchState::RUNNING, tracker->searchState()); + + tracker->stop(); +} + +TEST_F(SearchRestartTest, GenerationsEnabledButNoCandidateBlocksFirstSearch) { + // A brand-new tracker must not pay for the initial whole-heap + // walk/tagging pass either when there is no leak candidate yet - + // shouldRunPass()'s !_search_started branch now shares + // canAffordNewSearch() with the restart gate below (this class's own + // header comment). + LivenessTracker::instance()->setGcGenerationsForTest(true); + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + EXPECT_FALSE(ReferenceChainsTestAccessor::shouldRunPass(1)); + EXPECT_EQ(SearchState::RUNNING, tracker->searchState()); + EXPECT_EQ(0, tracker->passesRun()); + + int fake_object_storage = 0; + seedGrowingCandidate(/*klass_id=*/1, /*rep=*/(jweak)&fake_object_storage); + + EXPECT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(2)); + + tracker->stop(); +} + +TEST_F(SearchRestartTest, GenerationsEnabledButNoCandidateBlocksRestart) { + LivenessTracker::instance()->setGcGenerationsForTest(true); + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + ASSERT_TRUE(tracker->runPass(&mock_jvmti, nullptr)); + ASSERT_EQ(SearchState::COMPLETED, tracker->searchState()); + + // No leak candidate flagged - nothing to justify the cost of a restart. + EXPECT_FALSE(ReferenceChainsTestAccessor::shouldRunPass(1)); + EXPECT_EQ(SearchState::COMPLETED, tracker->searchState()); + + tracker->stop(); +} + +TEST_F(SearchRestartTest, RestartsOnceACandidateAppearsAndResetsPerSearchState) { + LivenessTracker::instance()->setGcGenerationsForTest(true); + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + ASSERT_TRUE(tracker->runPass(&mock_jvmti, nullptr)); + ASSERT_EQ(SearchState::COMPLETED, tracker->searchState()); + ASSERT_EQ(1, tracker->passesRun()); + + int fake_object_storage = 0; + seedGrowingCandidate(/*klass_id=*/1, /*rep=*/(jweak)&fake_object_storage); + + EXPECT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(1)); // restartSearch() runs inline + EXPECT_EQ(SearchState::RUNNING, tracker->searchState()); + EXPECT_EQ(0, tracker->passesRun()); // restartSearch() zeroed per-search state + + // The next runPass() call takes the "first pass of a search" branch + // again, exactly like a brand-new tracker. + ASSERT_TRUE(tracker->runPass(&mock_jvmti, nullptr)); + EXPECT_EQ(SearchState::COMPLETED, tracker->searchState()); + EXPECT_EQ(1, tracker->passesRun()); + + tracker->stop(); +} + +TEST_F(SearchRestartTest, PainBudgetBlocksARestartUntilItDrains) { + LivenessTracker::instance()->setGcGenerationsForTest(true); + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:painbudget=1")); // 1% + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + int fake_object_storage = 0; + seedGrowingCandidate(/*klass_id=*/1, /*rep=*/(jweak)&fake_object_storage); + + // First-ever search: called via runPass() directly here, bypassing + // shouldRunPass()'s canAffordNewSearch() gate entirely - the candidate + // seeded above would satisfy that gate anyway (this class's own header + // comment). + ASSERT_TRUE(tracker->runPass(&mock_jvmti, nullptr)); + ASSERT_EQ(SearchState::COMPLETED, tracker->searchState()); + + // Restart #1: _pain_budget has never had anything spent into it yet, so + // this is always immediately affordable regardless of this first + // search's own cost - the cost a search incurs only debits the *next* + // restart's affordability (restartSearch()'s own spend-then-reset + // order), not its own. + ASSERT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(1)); + ASSERT_EQ(SearchState::RUNNING, tracker->searchState()); + ASSERT_TRUE(tracker->runPass(&mock_jvmti, nullptr)); + ASSERT_EQ(SearchState::COMPLETED, tracker->searchState()); + + // Pretend this second search cost 1000ms of safepoint time - a mocked + // FollowReferences call in this fixture takes ~0 real wall-clock time, + // so this accessor stands in for what a real, expensive pass would have + // accumulated into _search_pain_ms on its own. + ReferenceChainsTestAccessor::setSearchPainMs(1000); + + // Restart #2: approved (nothing spent into _pain_budget yet), and its + // own spend() call debits 1000ms into the balance for the *next* + // restart to contend with. + ASSERT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(2)); + ASSERT_EQ(SearchState::RUNNING, tracker->searchState()); + ASSERT_TRUE(tracker->runPass(&mock_jvmti, nullptr)); + ASSERT_EQ(SearchState::COMPLETED, tracker->searchState()); + + // Restart #3, attempted immediately after restart #2's drain baseline: + // at 1% refill, 1000ms of debt needs 100000ms (1e11ns) of elapsed + // wall-clock time to clear - 1ns later is nowhere close. + EXPECT_FALSE(ReferenceChainsTestAccessor::shouldRunPass(3)); + EXPECT_EQ(SearchState::COMPLETED, tracker->searchState()); + + // Well past the drain point - the debt has cleared, restart #3 proceeds. + EXPECT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(2ULL + 200000000000ULL)); + EXPECT_EQ(SearchState::RUNNING, tracker->searchState()); + + tracker->stop(); +} + +// --------------------------------------------------------------------------- +// Phase 5 - correctness hardening: durability re-verification. +// +// These tests drive maybeUpgradeRootAttachedRootKind()/ +// collectStaleRootKindEntriesForRotation() directly via +// ReferenceChainsTestAccessor rather than through a full +// IterateOverReachableObjects()-driven runPassManualWalk() pass: neither +// IterateOverReachableObjects nor FollowReferences-as-a-safepoint-pin is +// mocked in this file (see the fixture's own FollowReferences-only mock +// rationale above), and both methods are pure FrontierTable/queue logic with +// no JVMTI dependency of their own - the same rationale +// admitObject()/rootKindDurability() being free of any callback shape +// already established for this subsystem. +// --------------------------------------------------------------------------- + +TEST_F(ReferenceChainsBfsTest, StaleRootAttributionUpgradesOnRediscovery) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=64")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + FrontierTable *frontier = tracker->frontierTable(); + + // Synthetic stack-local root: admitted, root-attached (parent_tag == 0), + // its owning frame has since "gone away" from the design doc's scenario + // (nothing further to model here - the entry simply stays as-is until a + // more durable root is discovered). + jlong tag = 1; + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, tag, /*parent_tag=*/0, /*depth=*/0, + FrontierEntryState::EXPANDED, JVMTI_HEAP_REFERENCE_STACK_LOCAL)); + + // A second, equally-or-less durable root discovery does not overwrite + // the recorded root_kind. + EXPECT_FALSE(ReferenceChainsTestAccessor::maybeUpgradeRootAttachedRootKind( + frontier, tag, JVMTI_HEAP_REFERENCE_JNI_LOCAL)); + FrontierEntry entry{}; + ASSERT_TRUE(frontier->lookup(tag, &entry)); + EXPECT_EQ(JVMTI_HEAP_REFERENCE_STACK_LOCAL, entry.root_kind); + + // A durable root (JNI global) attaching to the same object upgrades it. + EXPECT_TRUE(ReferenceChainsTestAccessor::maybeUpgradeRootAttachedRootKind( + frontier, tag, JVMTI_HEAP_REFERENCE_JNI_GLOBAL)); + ASSERT_TRUE(frontier->lookup(tag, &entry)); + EXPECT_EQ(JVMTI_HEAP_REFERENCE_JNI_GLOBAL, entry.root_kind); + EXPECT_EQ(0, entry.parent_tag); // still root-attached, unchanged + + // An even less durable root discovered afterwards cannot downgrade it. + EXPECT_FALSE(ReferenceChainsTestAccessor::maybeUpgradeRootAttachedRootKind( + frontier, tag, JVMTI_HEAP_REFERENCE_MONITOR)); + ASSERT_TRUE(frontier->lookup(tag, &entry)); + EXPECT_EQ(JVMTI_HEAP_REFERENCE_JNI_GLOBAL, entry.root_kind); + + tracker->stop(); +} + +// Exercises the invariant conflict Phase 5 itself calls out: a non-root +// entry (parent_tag != 0) rediscovered as if via a root context must never +// have its root_kind overwritten - doing so would leave a non-zero root_kind +// on an entry nothing else treats as root-attached (referenceChains.h's +// FrontierEntry::root_kind comment), since this mutator never touches +// parent_tag. This is the edge-based, non-root-Y re-expansion case the +// option (a) resolution above exists for. +TEST_F(ReferenceChainsBfsTest, NonRootAttachedEntryNeverUpgraded) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=64")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + FrontierTable *frontier = tracker->frontierTable(); + + // Parent Y (root-attached) and child X, admitted the way frontier + // re-expansion admits a non-root child: non-root + // (parent_tag == Y's tag), root_kind == 0. + jlong yTag = 1; + jlong xTag = 2; + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, yTag, /*parent_tag=*/0, /*depth=*/0, + FrontierEntryState::EXPANDED, JVMTI_HEAP_REFERENCE_JNI_GLOBAL)); + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, xTag, /*parent_tag=*/yTag, /*depth=*/1, + FrontierEntryState::EXPANDED, /*root_kind=*/0)); + + // Re-expanding Y rediscovers an edge to X (already tracked) - even if + // this rediscovery is (incorrectly) attempted with a durable root_kind, + // it must be rejected because X is not root-attached. + EXPECT_FALSE(ReferenceChainsTestAccessor::maybeUpgradeRootAttachedRootKind( + frontier, xTag, JVMTI_HEAP_REFERENCE_STATIC_FIELD)); + FrontierEntry entry{}; + ASSERT_TRUE(frontier->lookup(xTag, &entry)); + EXPECT_EQ(0, entry.root_kind); + EXPECT_EQ(yTag, entry.parent_tag); + + tracker->stop(); +} + +TEST_F(ReferenceChainsBfsTest, RotationSelectsOnlyTransientExpandedRootAttachedEntries) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=64")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + FrontierTable *frontier = tracker->frontierTable(); + + // Eligible: root-attached, EXPANDED, transient root_kind. + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, 1, 0, 0, FrontierEntryState::EXPANDED, + JVMTI_HEAP_REFERENCE_STACK_LOCAL)); + // Not eligible: durable root_kind. + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, 2, 0, 0, FrontierEntryState::EXPANDED, + JVMTI_HEAP_REFERENCE_JNI_GLOBAL)); + // Not eligible: transient but still FRONTIER, not yet EXPANDED. + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, 3, 0, 0, FrontierEntryState::FRONTIER, + JVMTI_HEAP_REFERENCE_JNI_LOCAL)); + // Not eligible: transient root_kind but not root-attached. + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, 4, /*parent_tag=*/1, 1, FrontierEntryState::EXPANDED, + JVMTI_HEAP_REFERENCE_JNI_LOCAL)); + // Eligible: root-attached, EXPANDED, transient (JNI local this time). + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, 5, 0, 0, FrontierEntryState::EXPANDED, + JVMTI_HEAP_REFERENCE_JNI_LOCAL)); + + std::vector selected = + ReferenceChainsTestAccessor::collectStaleRootKindEntriesForRotation(10); + std::sort(selected.begin(), selected.end()); + EXPECT_EQ((std::vector{1, 5}), selected); + + // Selected tags are queued for re-expansion, exactly like an ordinary + // admission would queue a newly-discovered tag. + EXPECT_EQ(2u, ReferenceChainsTestAccessor::priorityExpandSize()); + + tracker->stop(); +} + +// N transient-root_kind entries, rotation size R: every entry must be +// selected at least once within ceil(N/R) calls, regardless of where the +// cursor happened to start. +TEST_F(ReferenceChainsBfsTest, RotationCoversAllEntriesWithinCeilNOverR) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=64")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + FrontierTable *frontier = tracker->frontierTable(); + + const int N = 10; + const int R = 3; + for (jlong tag = 1; tag <= N; tag++) { + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, tag, 0, 0, FrontierEntryState::EXPANDED, + JVMTI_HEAP_REFERENCE_STACK_LOCAL)); + } + + std::unordered_set covered; + int calls = (N + R - 1) / R; + for (int i = 0; i < calls; i++) { + std::vector selected = + ReferenceChainsTestAccessor::collectStaleRootKindEntriesForRotation(R); + for (jlong tag : selected) { + covered.insert(tag); + } + } + EXPECT_EQ((size_t)N, covered.size()); + + tracker->stop(); +} + +// collectStaleExpandedEntriesForRotation()'s own EXPANDED-only criterion is a +// strict superset of collectStaleRootKindEntriesForRotation()'s (which also +// requires parent_tag == 0 and a transient root_kind), and runPassManualWalk() +// calls the root-kind collector first, into the very same _priority_expand +// deque. Without a dedup check, a tag the root-kind collector already queued +// would be queued a second time by the EXPANDED-only sweep, wasting one of +// expandFrontier()'s per-entry batch slots on an already-EXPANDED tag every +// pass. This drives both collectors back-to-back, the way runPassManualWalk() +// does, and asserts _priority_expand ends up with no duplicate tags. +TEST_F(ReferenceChainsBfsTest, StaleExpandedRotationDoesNotDuplicateRootKindSelection) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=64")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + FrontierTable *frontier = tracker->frontierTable(); + + // Eligible for both collectors: EXPANDED, root-attached, transient + // root_kind - exactly the overlap collectStaleRootKindEntriesForRotation() + // will pick up first. + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, 1, 0, 0, FrontierEntryState::EXPANDED, + JVMTI_HEAP_REFERENCE_STACK_LOCAL)); + // Eligible only for the EXPANDED-only sweep: EXPANDED but not root-attached. + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, 2, /*parent_tag=*/1, 1, FrontierEntryState::EXPANDED, + /*root_kind=*/0)); + + std::vector root_kind_selected = + ReferenceChainsTestAccessor::collectStaleRootKindEntriesForRotation( + ReferenceChainsTestAccessor::rootKindRotationBudget()); + EXPECT_EQ((std::vector{1}), root_kind_selected); + + std::vector stale_expanded_selected = + ReferenceChainsTestAccessor::collectStaleExpandedEntriesForRotation( + ReferenceChainsTestAccessor::staleExpandedRotationBudget()); + // Tag 1 is already queued from the root-kind collector above and must not + // be selected again; tag 2 is newly discovered by this sweep. + EXPECT_EQ((std::vector{2}), stale_expanded_selected); + + std::vector queued = ReferenceChainsTestAccessor::priorityExpandContents(); + EXPECT_EQ((std::vector{1, 2}), queued); + std::unordered_set unique_queued(queued.begin(), queued.end()); + EXPECT_EQ(queued.size(), unique_queued.size()); + + tracker->stop(); +} + +// A tag left over in _priority_expand from a prior pass's truncated +// expandFrontier() batch (see expandFrontier()'s own "leave the batch at the +// front of the source queue for a later pass to retry" comment) must also be +// skipped by collectStaleExpandedEntriesForRotation() - not just tags queued +// by collectStaleRootKindEntriesForRotation() earlier in the same call. +TEST_F(ReferenceChainsBfsTest, StaleExpandedRotationSkipsPreexistingQueueEntries) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=64")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + FrontierTable *frontier = tracker->frontierTable(); + + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, 1, 0, 0, FrontierEntryState::EXPANDED, + JVMTI_HEAP_REFERENCE_STACK_LOCAL)); + + // Simulate a truncated batch from a prior pass still sitting at the front + // of _priority_expand, without going through the root-kind collector at + // all - the leftover entry alone must still be enough to suppress a + // duplicate. + ReferenceChainsTestAccessor::pushPriorityExpand(1); + + std::vector stale_expanded_selected = + ReferenceChainsTestAccessor::collectStaleExpandedEntriesForRotation( + ReferenceChainsTestAccessor::staleExpandedRotationBudget()); + EXPECT_TRUE(stale_expanded_selected.empty()); + + std::vector queued = ReferenceChainsTestAccessor::priorityExpandContents(); + EXPECT_EQ((std::vector{1}), queued); + + tracker->stop(); +} diff --git a/ddprof-lib/src/test/cpp/staleLeaf_ut.cpp b/ddprof-lib/src/test/cpp/staleLeaf_ut.cpp new file mode 100644 index 0000000000..d6fcf344ee --- /dev/null +++ b/ddprof-lib/src/test/cpp/staleLeaf_ut.cpp @@ -0,0 +1,129 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + * + * PROF-15130 investigation: spurious STALE LEAF frames. + * + * Hypothesis under test (from the ticket): convertNativeTrace() leaves a + * counted slot unwritten (depth counts a slot that was never populated), + * leaking stale data from a previous sample into the leaf of a parked + * thread's stack. + * + * These tests drive Profiler::convertNativeTrace() directly and assert the + * counted-slot invariant: every slot in [0, depth) returned by + * convertNativeTrace MUST have been written this call. The output buffer is + * pre-filled with a recognizable sentinel so any surviving sentinel below + * `depth` is, by definition, a counted-but-unwritten (stale) slot. + */ + +#include +#include +#include +#include "../../main/cpp/profiler.h" +#include "../../main/cpp/vmEntry.h" +#include "../../main/cpp/libraries.h" +#include "../../main/cpp/gtest_crash_handler.h" + +static constexpr char STALELEAF_TEST_NAME[] = "StaleLeafTest"; + +// Sentinel value pre-loaded into every output slot. Distinct bci so we can +// tell "never touched by convertNativeTrace" from any value the function +// could legitimately write (BCI_NATIVE_FRAME == -11, BCI_NATIVE_FRAME_REMOTE +// == -19). +static constexpr jint SENTINEL_BCI = 0x5A5A5A5A; +static const void* const SENTINEL_MID = (const void*)(uintptr_t)0xDEADBEEFDEADBEEFULL; + +class StaleLeafTest : public ::testing::Test { +protected: + void SetUp() override { + installGtestCrashHandler(); + // Without this, findNativeMethod() resolves every PC to nullptr and + // convertNativeTrace() never writes a frame, so depth is always 0 and + // the counted-slot invariant below is checked over an empty range. + Libraries::instance()->updateSymbols(false); + } + void TearDown() override { + restoreDefaultSignalHandlers(); + } + + static void fillSentinel(ASGCT_CallFrame* frames, int n) { + for (int i = 0; i < n; i++) { + frames[i].bci = SENTINEL_BCI; + frames[i].method_id = (jmethodID)SENTINEL_MID; + } + } + + // Returns true if every slot in [0, depth) was overwritten (no sentinel + // survives). A surviving sentinel below `depth` is a stale leaf bug. + static bool noCountedSlotIsStale(const ASGCT_CallFrame* frames, int depth) { + for (int i = 0; i < depth; i++) { + if (frames[i].bci == SENTINEL_BCI && + frames[i].method_id == (jmethodID)SENTINEL_MID) { + return false; + } + } + return true; + } +}; + +// Empty callchain: depth must be 0, nothing written. +TEST_F(StaleLeafTest, emptyCallchain_returnsZero_noStaleSlot) { + ASGCT_CallFrame frames[16]; + fillSentinel(frames, 16); + + int depth = Profiler::instance()->convertNativeTrace(0, nullptr, frames, 0, false); + + EXPECT_EQ(0, depth); + EXPECT_TRUE(noCountedSlotIsStale(frames, depth)); +} + +// PCs that resolve to no library: traditional path yields method_name == NULL, +// so the frame is neither written nor counted. depth must equal the number of +// slots actually written (0 here), with no stale slot below depth. +TEST_F(StaleLeafTest, unresolvablePcs_neverLeaveCountedStaleSlot) { + // Deliberately bogus PCs not inside any loaded library. + const void* callchain[] = { + (const void*)0x1000, + (const void*)0x2000, + (const void*)0x3000, + (const void*)0x4000, + }; + const int n = (int)(sizeof(callchain) / sizeof(callchain[0])); + + ASGCT_CallFrame frames[16]; + fillSentinel(frames, 16); + + int depth = Profiler::instance()->convertNativeTrace(n, callchain, frames, 0, false); + + ASSERT_GE(depth, 0); + ASSERT_LE(depth, n); + // INVARIANT: no counted slot may remain a sentinel. + EXPECT_TRUE(noCountedSlotIsStale(frames, depth)) + << "convertNativeTrace counted a slot it never wrote (stale leaf)"; +} + +// Mix of resolvable (test-binary code) and bogus PCs. Whatever resolves, the +// invariant must hold: depth counts only written slots. +TEST_F(StaleLeafTest, realCodePcs_neverLeaveCountedStaleSlot) { + // Use the address of this function and a few library functions as PCs that + // are plausibly inside loaded code segments. Even if symbol resolution + // returns NULL (library not parsed because the profiler is not started), + // the invariant is the same: counted == written. + const void* callchain[] = { + (const void*)&memcpy, + (const void*)&snprintf, + (const void*)0xBADC0DE, // unresolvable + (const void*)&strlen + }; + const int n = (int)(sizeof(callchain) / sizeof(callchain[0])); + + ASGCT_CallFrame frames[16]; + fillSentinel(frames, 16); + + int depth = Profiler::instance()->convertNativeTrace(n, callchain, frames, 0, false); + + ASSERT_GE(depth, 0); + ASSERT_LE(depth, n); + EXPECT_TRUE(noCountedSlotIsStale(frames, depth)) + << "convertNativeTrace counted a slot it never wrote (stale leaf)"; +} From 45db53cc0f7a75861970b2ffbda705d2ac9852e8 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Thu, 6 Aug 2026 09:44:28 +0200 Subject: [PATCH 4/7] Add Java integration tests and chaos/repro harness for reference chains Co-Authored-By: Claude Sonnet 5 --- .../com/datadoghq/profiler/chaos/Main.java | 2 + .../chaos/ReferenceChainLeakAntagonist.java | 106 +++ .../repro/ReferenceChainLeakDemo.java | 282 +++++++ .../profiler/AbstractProcessProfilerTest.java | 13 +- .../profiler/AbstractProfilerTest.java | 7 + .../datadoghq/profiler/ExternalLauncher.java | 36 + .../JMethodIDInvalidationStressTest.java | 11 +- .../ExternalProcessReferenceChainTest.java | 129 +++ .../referencechains/LeakingCacheScenario.java | 316 ++++++++ .../ReferenceChainAssertions.java | 112 +++ .../ReferenceChainJfrParserTest.java | 169 ++++ .../ReferenceChainTestSeamsTest.java | 147 ++++ .../ReferenceChainTrackingTest.java | 745 ++++++++++++++++++ 13 files changed, 2069 insertions(+), 6 deletions(-) create mode 100644 ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/ReferenceChainLeakAntagonist.java create mode 100644 ddprof-stresstest/src/repro/java/com/datadoghq/profiler/repro/ReferenceChainLeakDemo.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ExternalProcessReferenceChainTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/LeakingCacheScenario.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainJfrParserTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTestSeamsTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java diff --git a/ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/Main.java b/ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/Main.java index 7e846c02d1..a8e51e8666 100644 --- a/ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/Main.java +++ b/ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/Main.java @@ -92,6 +92,8 @@ private static Antagonist create(String name) { return new WeakRefWaveAntagonist(); case "dump-storm": return new DumpStormAntagonist(); + case "reference-chain-leak": + return new ReferenceChainLeakAntagonist(); case "reapply-context-value": return new ReapplyContextValueAntagonist(); // Deferred: dlopen-churn (needs per-arch dummy .so built in CI prep). diff --git a/ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/ReferenceChainLeakAntagonist.java b/ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/ReferenceChainLeakAntagonist.java new file mode 100644 index 0000000000..a63c613752 --- /dev/null +++ b/ddprof-stresstest/src/chaos/java/com/datadoghq/profiler/chaos/ReferenceChainLeakAntagonist.java @@ -0,0 +1,106 @@ +/* + * Copyright 2026, Datadog, Inc + * + * Licensed 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 + */ +package com.datadoghq.profiler.chaos; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Grows an ever-referenced, never-evicted cache to give the reference-chains + * feature (LivenessTracker/ReferenceChainTracker) a real, steadily growing + * population to detect and walk. Growth is capped, not open-ended: once + * {@link #CAP_ENTRIES} is reached the cache stops growing and the antagonist + * just holds it there for the rest of the run, so a long chaos duration + * (hours) still produces the same bounded leak instead of eventually OOMing + * the JVM out from under the other antagonists sharing its heap. + * + *

Same object shape as {@code LeakingCacheScenario.CachedPayload} + * (ddprof-test's reference-chain integration test) — plain long fields + * rather than a nested array, so a single instance clears the allocation + * sampler's real size floor without a second, competing heap allocation per + * entry. + */ +public final class ReferenceChainLeakAntagonist implements Antagonist { + + // ~64MiB of CachedEntry payloads at steady state (entries are a little + // over 300 bytes with object header/field overhead) — enough for the + // liveness tracker's population-growth trend detection to have a real, + // sustained trend to lock onto, without meaningfully competing with the + // other antagonists' own memory budget for the run's whole duration. + private static final int CAP_ENTRIES = 200_000; + private static final int BATCH_SIZE = 200; + private static final long BATCH_INTERVAL_MS = 50L; + + private final Map cache = new ConcurrentHashMap<>(); + private volatile boolean running; + private Thread growthDriver; + private long nextKey; + + @Override + public String name() { + return "reference-chain-leak"; + } + + @Override + public void start() { + running = true; + growthDriver = new Thread(this::growthLoop, "chaos-refchain-leak"); + growthDriver.setDaemon(true); + growthDriver.start(); + } + + @Override + public void stopGracefully(Duration timeout) { + running = false; + try { + growthDriver.join(timeout.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void growthLoop() { + while (running && cache.size() < CAP_ENTRIES) { + for (int i = 0; i < BATCH_SIZE && cache.size() < CAP_ENTRIES; i++) { + String key = "refchain-leak-" + (nextKey++); + cache.put(key, new CachedEntry(key)); + MemoryGovernor.pace(); + } + try { + Thread.sleep(BATCH_INTERVAL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + // Cap reached: keep the reference alive (do nothing) for the rest of + // the run instead of exiting the thread, so `cache` stays reachable + // via this antagonist's own field for as long as the harness runs. + while (running) { + try { + Thread.sleep(1_000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + static final class CachedEntry { + final String key; + long p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15; + long p16, p17, p18, p19, p20, p21, p22, p23, p24, p25, p26, p27, p28, p29, p30, p31; + + CachedEntry(String key) { + this.key = key; + } + } +} diff --git a/ddprof-stresstest/src/repro/java/com/datadoghq/profiler/repro/ReferenceChainLeakDemo.java b/ddprof-stresstest/src/repro/java/com/datadoghq/profiler/repro/ReferenceChainLeakDemo.java new file mode 100644 index 0000000000..0416a1fea1 --- /dev/null +++ b/ddprof-stresstest/src/repro/java/com/datadoghq/profiler/repro/ReferenceChainLeakDemo.java @@ -0,0 +1,282 @@ +/* + * Copyright 2026, Datadog, Inc + * + * Licensed 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 + */ +package com.datadoghq.profiler.repro; + +import com.datadoghq.profiler.JavaProfiler; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryUsage; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; + +/** + * Standalone demo app for manually reproducing the reference-chains feature end-to-end. + * Meant to be launched directly with the native agent loaded at JVM startup, e.g. + * + *

+ * java -agentpath:/path/to/libjavaProfiler.so=start,memory=64:l,generations=true,\
+ *   referencechains=true:hops=64:budget=4000:ttl=120000:framecap=2000000:\
+ *   pausetarget=5000:painbudget=100,jfr,file=repro.jfr \
+ *   -jar refchains_repro.jar repro.jfr /path/to/libjavaProfiler.so
+ * 
+ * + *

Takes the JFR output path as {@code args[0]} (must match {@code file=} above) and the + * agent's own {@code .so} path as {@code args[1]} (must match the {@code -agentpath} value + * above), and periodically calls {@link JavaProfiler#dump}. Both are load-bearing: + * + *

An optional {@code args[2]} duration in seconds switches from the default "run forever" + * manual mode to a bounded session that dumps once more and prints a single {@code [metrics]} + * line at exit (throughput, round latency, heap growth) - see + * {@code utils/compare-refchains-repro.sh}, which runs this app twice (referencechains on vs. + * off) over the same duration and diffs those lines against each run's safepoint/GC logs. + *

    + *
  • The dump call itself: {@code Profiler::dump()} (profiler.cpp) is the only code path + * that drains {@code ReferenceChainTracker}'s pending chain-event queue and actually writes + * {@code datadog.ReferenceChain} into the JFR file - nothing drains that queue on a timer, + * and in production it's dd-trace-java's own periodic recording-chunk rotation that calls + * {@code dump()}. Without it, chain events get built and enqueued (visible via {@code + * TEST_LOG}) but are silently discarded when the process exits. + *
  • Passing the {@code .so} path explicitly to {@link JavaProfiler#getInstance(String, + * String)}: with no {@code libLocation}, {@code getInstance()} extracts the jar's own + * bundled copy of the library to a fresh temp file and {@code System.load()}s that + * instead of attaching to the one {@code -agentpath} already loaded - a second, never-{@code + * start()}-ed {@code Profiler} singleton whose {@code dump()} calls are silent no-ops. + * Passing the same path lets the dynamic linker dedup the load onto the already-running + * singleton, the same way dd-trace-java attaches to its own {@code -agentpath} agent. + *
+ * + *

Deliberately omits {@code firstpassbudget}: the tracker auto-scales its + * first pass's budget from the plain {@code budget} value + * ({@code ReferenceChainTracker::AUTO_FIRST_PASS_BUDGET_MULTIPLIER}, capped at + * {@code AUTO_FIRST_PASS_BUDGET_CAP}, referenceChains.h) rather than reusing + * {@code budget} as-is for that first, cold root-seeded walk - reusing it directly + * truncates every pass before it gets anywhere near this app's {@code CachedEntry} + * population, and the target's JVMTI tag stays 0 forever. Pass + * {@code firstpassbudget=N} explicitly only to override that auto-scaled default. + * + *

Grows an ever-referenced, never-evicted {@code HashMap}-backed cache (same shape as + * ddprof-test's {@code LeakingCacheScenario}/the chaos harness's {@code + * ReferenceChainLeakAntagonist}). Two things this app does deliberately, both found the hard + * way by running it without them and never seeing a chain get discovered: + * + *

    + *
  • Explicit {@code System.gc()} every round. {@code LivenessTracker}'s + * population-growth trend ({@code computeKlassPopulationSlope}, livenessTracker.cpp) needs at + * least {@code KLASS_POPULATION_MIN_FILL_FOR_TREND} (10) distinct GC epochs of population + * samples before it will even attempt a slope, and {@code _gc_epoch} only advances on a + * {@code GarbageCollectionFinish} JVMTI callback - i.e. on an actual GC. Left to whatever GCs + * the JVM decides to run on its own, a slow, modest allocation rate against a large default + * heap can go many minutes without a single GC, so the epoch count - and therefore the trend - + * never moves. Forcing a GC every round (same pattern {@code LeakingCacheScenario}'s round + * loop and {@code ReferenceChainTrackingTest} already rely on) turns epoch progress into + * something this app controls directly instead of leaving it to chance. + *
  • No hard stop. A cache that grows to a fixed cap and then sits flat stops being a + * growing population the instant it stops growing - {@code computeKlassPopulationSlope} + * compares the earliest vs. most recent third of its 30-epoch ring, so a long enough flat + * period after the cap pushes the slope back to zero (or the real growth epochs age out of the + * ring before the search ever gets scheduled/its own pain-budget cooldown clears). This app + * keeps growing forever instead, self-throttling its own pace against a heap-usage watermark + * (see {@link #run}) so it stays a real, positive, continuously-observable trend without ever + * growing fast/large enough to actually exhaust the heap. + *
+ * + *

Seeds the cache with an initial batch on the very first line of {@code main} - before + * printing anything or sleeping - so the agent's one-shot, root-seeded first pass (which fires + * roughly a second after the agent's own background thread starts, i.e. before {@code + * Agent_OnLoad} even returns control to this class's {@code main}) has a real chance of finding + * {@code cache} already non-empty. See {@code LeakingCacheScenario}'s own comment for the full + * story of why that ordering is load-bearing. + */ +public final class ReferenceChainLeakDemo { + + // Heap-usage watermarks gating growth pace, mirroring the chaos harness's own + // MemoryGovernor - a much simpler version since this app has only one grower to pace, + // not several antagonists sharing a budget. + private static final double HIGH_WATERMARK = 0.70; + private static final double CRITICAL_WATERMARK = 0.85; + + private static final int NORMAL_BATCH_SIZE = 2_000; + private static final int THROTTLED_BATCH_SIZE = 100; + private static final long ROUND_INTERVAL_MS = 300L; + private static final long THROTTLED_ROUND_INTERVAL_MS = 2_000L; + + // How often to call JavaProfiler.dump() to drain any pending reference-chain + // events into the JFR file - see this class's own header comment for why this + // call, not just this app's own memory growth, is load-bearing for the repro. + private static final int DUMP_EVERY_N_ROUNDS = 10; + + public static void main(String[] args) throws Exception { + if (args.length < 2) { + System.err.println("usage: ReferenceChainLeakDemo [duration-seconds] " + + "(both required args must match the -agentpath:=...jfr,file= values; " + + "duration-seconds runs a bounded, metrics-reporting session instead of forever)"); + System.exit(1); + } + long durationSeconds = args.length >= 3 ? Long.parseLong(args[2]) : -1; + run(Paths.get(args[0]), args[1], durationSeconds); + } + + // FlightRecorder::dump() (flightRecorder.cpp) rejects dumping the continuous recording to + // its own file= path ("Can not dump recording to itself"), so periodic drains must target + // a different path; this is that path's suffix. + private static final String SNAPSHOT_SUFFIX = ".snapshot"; + + // Tracked so a bounded run (durationSeconds >= 0) can report throughput/latency/memory + // deltas at exit for A/B comparison against a referencechains=false baseline - see + // utils/compare-refchains-repro.sh. + private static long roundCount = 0; + private static long totalEntriesAdded = 0; + private static long totalRoundNanos = 0; + private static long maxRoundNanos = 0; + + private static void run(Path recordingPath, String agentSoPath, long durationSeconds) throws InterruptedException { + // Must differ from recordingPath itself - see SNAPSHOT_SUFFIX's comment. + Path snapshotPath = recordingPath.resolveSibling(recordingPath.getFileName() + SNAPSHOT_SUFFIX); + // Must pass the exact .so path -agentpath already loaded: JavaProfiler.getInstance() + // with no libLocation extracts the jar's own bundled copy to a fresh temp file and + // System.load()s *that* instead, producing a second, never-started Profiler singleton + // completely disconnected from the real profiling session - dump() calls against it + // are silent no-ops. Passing the same path here lets the dynamic linker dedup the + // load to the already-mapped library, i.e. the real, already-running singleton. + JavaProfiler profiler; + try { + profiler = JavaProfiler.getInstance(agentSoPath, null); + } catch (Exception e) { + throw new IllegalStateException( + "JavaProfiler.getInstance() failed - was this launched with " + + "-agentpath:libjavaProfiler.so=start,...?", e); + } + + Map cache = new HashMap<>(); + seed(cache, 300); + + System.out.println("[repro] pid=" + pid() + + " - growing cache " + (durationSeconds >= 0 ? ("for " + durationSeconds + "s") : "forever") + + ", forcing a GC every round so the profiler's " + + "population-growth trend detection has real epochs to work with; watch " + + "heapUsedMb/heapMaxMb and attach/dump once a chain shows up"); + + long startNanos = System.nanoTime(); + long deadlineNanos = durationSeconds >= 0 ? startNanos + durationSeconds * 1_000_000_000L : Long.MAX_VALUE; + long startHeapUsedMb = heapUsedMb(); + + long nextKey = 300; + int round = 0; + while (System.nanoTime() < deadlineNanos) { + long roundStartNanos = System.nanoTime(); + round++; + double heapFraction = heapUsedFraction(); + int batchSize = heapFraction >= HIGH_WATERMARK ? THROTTLED_BATCH_SIZE : NORMAL_BATCH_SIZE; + long roundIntervalMs = heapFraction >= HIGH_WATERMARK ? THROTTLED_ROUND_INTERVAL_MS : ROUND_INTERVAL_MS; + + if (heapFraction < CRITICAL_WATERMARK) { + for (int i = 0; i < batchSize; i++) { + String key = "leak-" + (nextKey++); + cache.put(key, new CachedEntry(key)); + } + totalEntriesAdded += batchSize; + } else { + System.out.println("[repro] heap usage critical (" + pct(heapFraction) + + ") - pausing growth this round, attach/dump now if you haven't"); + } + + // Forces a GC every round regardless of watermark state, so _gc_epoch (and + // therefore the population-growth ring) keeps advancing even while paused/ + // throttled - see this class's own header comment for why that's load-bearing. + System.gc(); + + if (round % 10 == 0 || heapFraction >= HIGH_WATERMARK) { + System.out.println("[repro] round=" + round + " cache.size()=" + cache.size() + + " heapUsedMb=" + heapUsedMb() + " heapFraction=" + pct(heapFraction)); + } + + if (round % DUMP_EVERY_N_ROUNDS == 0) { + profiler.dump(snapshotPath); + } + + long roundNanos = System.nanoTime() - roundStartNanos; + roundCount++; + totalRoundNanos += roundNanos; + maxRoundNanos = Math.max(maxRoundNanos, roundNanos); + + Thread.sleep(roundIntervalMs); + } + + if (durationSeconds >= 0) { + profiler.dump(snapshotPath); + reportMetrics(startNanos, startHeapUsedMb); + } + } + + // Printed with a distinct "[metrics]" prefix so utils/compare-refchains-repro.sh can grep + // it out of the two runs' stdout (referencechains=true vs. false) and diff throughput/ + // latency/memory-growth directly, without needing to touch the safepoint/GC logs for those. + private static void reportMetrics(long startNanos, long startHeapUsedMb) { + long wallNanos = System.nanoTime() - startNanos; + double wallSeconds = wallNanos / 1_000_000_000.0; + double avgRoundMs = roundCount == 0 ? 0 : (totalRoundNanos / (double) roundCount) / 1_000_000.0; + double maxRoundMs = maxRoundNanos / 1_000_000.0; + double entriesPerSec = wallSeconds > 0 ? totalEntriesAdded / wallSeconds : 0; + long heapGrowthMb = heapUsedMb() - startHeapUsedMb; + + System.out.println("[metrics] wallSeconds=" + String.format("%.1f", wallSeconds) + + " rounds=" + roundCount + + " entriesAdded=" + totalEntriesAdded + + " entriesPerSec=" + String.format("%.1f", entriesPerSec) + + " avgRoundMs=" + String.format("%.3f", avgRoundMs) + + " maxRoundMs=" + String.format("%.3f", maxRoundMs) + + " heapUsedMbStart=" + startHeapUsedMb + + " heapUsedMbEnd=" + heapUsedMb() + + " heapGrowthMb=" + heapGrowthMb); + } + + private static void seed(Map cache, int count) { + for (int i = 0; i < count; i++) { + String key = "seed-" + i; + cache.put(key, new CachedEntry(key)); + } + } + + private static String pid() { + String name = ManagementFactory.getRuntimeMXBean().getName(); + int at = name.indexOf('@'); + return at >= 0 ? name.substring(0, at) : name; + } + + private static long heapUsedMb() { + return ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed() / (1024 * 1024); + } + + private static double heapUsedFraction() { + MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage(); + return heap.getMax() > 0 ? (double) heap.getUsed() / (double) heap.getMax() : 0.0; + } + + private static String pct(double fraction) { + return String.format("%.1f%%", fraction * 100.0); + } + + /** + * Same shape as {@code LeakingCacheScenario.CachedPayload}/{@code + * ReferenceChainLeakAntagonist.CachedEntry}: plain long fields, not a nested array, so a + * single instance clears the allocation sampler's real size floor without a second, + * competing heap allocation per entry. + */ + static final class CachedEntry { + final String key; + long p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15; + long p16, p17, p18, p19, p20, p21, p22, p23, p24, p25, p26, p27, p28, p29, p30, p31; + + CachedEntry(String key) { + this.key = key; + } + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java index 6c0bc0685f..e213b9a005 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java @@ -36,6 +36,17 @@ protected final LaunchResult launch(String target, List jvmArgs, String } protected final LaunchResult launch(String target, List jvmArgs, String commands, Map env, Function onStdoutLine, Function onStderrLine) throws Exception { + return launch(target, jvmArgs, commands, env, 10, onStdoutLine, onStderrLine); + } + + /** + * Same as the 6-arg overload above, but with a caller-supplied wait timeout instead of the + * hardcoded 10 seconds - needed by scenarios that run substantially longer than a plain + * attach/init check, e.g. {@code ExternalProcessReferenceChainTest}'s population-growth loop + * (up to 25 rounds plus a grace period, ~20s+ observed in-process, plus a whole separate + * JVM's own startup/classloading cost on top). + */ + protected final LaunchResult launch(String target, List jvmArgs, String commands, Map env, long timeoutSeconds, Function onStdoutLine, Function onStderrLine) throws Exception { String javaHome = System.getenv("JAVA_TEST_HOME"); if (javaHome == null) { javaHome = System.getenv("JAVA_HOME"); @@ -130,7 +141,7 @@ protected final LaunchResult launch(String target, List jvmArgs, String stdoutReader.start(); stderrReader.start(); - boolean val = p.waitFor(10, TimeUnit.SECONDS); + boolean val = p.waitFor(timeoutSeconds, TimeUnit.SECONDS); if (!val) { p.destroyForcibly(); p.waitFor(5, TimeUnit.SECONDS); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java index b4e988c582..923d03d031 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java @@ -73,6 +73,12 @@ public static double scaledSize(JfrEvent item) { protected JavaProfiler profiler; private Path jfrDump; + // Set at the very start of setupProfiler(), before getProfilerCommand() is + // consulted, so a subclass can branch its command on the current test + // method (e.g. distinct hop/budget/frontier-cap values per @Test) without + // needing separate test classes per configuration. + protected TestInfo testInfo; + private Duration cpuInterval; private Duration wallInterval; @@ -192,6 +198,7 @@ protected void withTestAssumptions() {} @BeforeEach public void setupProfiler(TestInfo testInfo) throws Exception { + this.testInfo = testInfo; Assumptions.assumeTrue(isPlatformSupported()); withTestAssumptions(); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java index 695412dcb0..eabbe81f0b 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java @@ -5,10 +5,13 @@ package com.datadoghq.profiler; +import com.datadoghq.profiler.referencechains.LeakingCacheScenario; + import java.io.IOException; import java.lang.management.ManagementFactory; import java.lang.management.ThreadMXBean; import java.lang.reflect.Method; +import java.nio.file.Paths; import java.util.Random; import java.util.concurrent.atomic.LongAdder; @@ -23,6 +26,15 @@ *

  • profiler [comma delimited profiler command list] - starts the profiler
  • *
  • profiler-work: [comma delimited profiler command list] - starts the profiler and runs a CPU-intensive task
  • *
  • profiler-virtual-thread - calls {@link JavaProfiler#getInstance()} for the first time from a virtual thread
  • + *
  • leak-cache "<start command>|||<scratch dump path>" - starts the profiler with + * the given start command, then runs {@link LeakingCacheScenario} in this process and exits + * directly (no readiness/stdin-signal handshake, unlike the other modes above) once it + * prints its one result line. Used by {@code ExternalProcessReferenceChainTest} + * (referencechains package) to prove the reference-chains mechanism end-to-end against a + * genuinely separate JVM, not the in-process dynamic-attach lifecycle every other test in + * this module uses - see that scenario's own class comment for why a *separate* process is + * load-bearing here (the one-shot root-seeded BFS walk is a process-wide, once-ever + * resource).
  • * */ public class ExternalLauncher { @@ -95,6 +107,30 @@ public static void main(String[] args) throws Exception { worker.start(); } } + } else if (args[0].equals("leak-cache")) { + // "|||" packed into one args[1] string rather + // than extending AbstractProcessProfilerTest.launch()'s generic 2-arg + // (target, commands) contract with a 3rd argument every other mode would have to + // ignore. + String packed = args.length == 2 ? args[1] : ""; + int sep = packed.indexOf("|||"); + if (sep < 0) { + throw new IllegalArgumentException( + "leak-cache requires \"|||\", got: " + packed); + } + String commands = packed.substring(0, sep); + String scratchPath = packed.substring(sep + "|||".length()); + JavaProfiler instance = JavaProfiler.getInstance(); + // LeakingCacheScenario.run() itself calls instance.execute(commands), not here - + // it needs to seed its cache fixture *before* starting the profiler (see that + // method's own comment for why). + LeakingCacheScenario.run(instance, commands, Paths.get(scratchPath)); + System.out.flush(); + // Deliberately exits here rather than falling through to the shared + // "[ready]" + stdin-signal handshake below: this mode runs to completion in one + // shot (no live back-and-forth with the parent needed) and its own result line + // has already been printed by LeakingCacheScenario.run(). + System.exit(0); } } finally { System.out.println("[ready]"); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/memleak/JMethodIDInvalidationStressTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/memleak/JMethodIDInvalidationStressTest.java index b098b46070..93724332d0 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/memleak/JMethodIDInvalidationStressTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/memleak/JMethodIDInvalidationStressTest.java @@ -39,7 +39,7 @@ /** * Exploratory stress test for jmethodID invalidation, motivated by PROF-15385 (SIGSEGV in * {@code Lookup::fillJavaMethodInfo} copying a JVMTI line-number table for a stale jmethodID, - * fixed by guarding that copy with {@code SafeAccess::isReadableRange}). + * fixed by guarding that copy with {@code SafeAccess::safeCopy}). * *

    That fix addressed one call site. This test does not target a specific call site; it tries * to manufacture the same underlying condition — jmethodIDs whose declaring class is unloaded @@ -66,10 +66,11 @@ * But that alone would pass vacuously if the churn never actually raced class unload against * {@code Lookup::resolveMethod}/{@code fillJavaMethodInfo}. To rule that out, this test reads the * native whitebox counters ({@code JavaProfiler#getDebugCounters()}) for {@code - * jmethodid_skipped_count} and {@code line_number_table_unreadable} -- both are incremented in - * {@code fillJavaMethodInfo} exactly when {@code SafeAccess::isReadableRange} rejects a stale - * jmethodID's class/method metadata or line-number table (see flightRecorder.cpp) -- and checks - * that at least one of them increased during the churn window. Whether the race is actually hit + * jmethodid_skipped_count} and {@code line_number_table_unreadable} -- incremented in + * {@code fillJavaMethodInfo} when a stale jmethodID's class/method-name probe is rejected by + * {@code SafeAccess::isReadableRange}, or when its line-number table copy is rejected by + * {@code SafeAccess::safeCopy} (see flightRecorder.cpp) -- and checks that at least one of them + * increased during the churn window. Whether the race is actually hit * within the window is JVM/host-discretionary, so that check is a JUnit assumption rather than an * assertion: if neither counter moved, the test is reported as skipped (not failed), since a * healthy host that simply didn't race tightly enough this run is not evidence of a regression. A diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ExternalProcessReferenceChainTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ExternalProcessReferenceChainTest.java new file mode 100644 index 0000000000..368f37cc9b --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ExternalProcessReferenceChainTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.AbstractProcessProfilerTest; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +/** + * Genuinely separate-process end-to-end coverage for the reference-chains target-selection + * mechanism, complementing {@link ReferenceChainTrackingTest}'s in-process coverage: runs a real + * "leaking Java app" (an ever-growing, never-evicted {@code HashMap}-backed cache - see + * {@link LeakingCacheScenario}) in a genuinely separate child JVM, launched the same way + * {@code JavaProfilerTest}/{@code JVMAccessTest} already launch child processes, and asserts on + * that child's own reported result rather than reading a JFR file back out of this test's own + * process. + * + *

    Why a separate process, not another {@code @Test} in {@code ReferenceChainTrackingTest}: + * an earlier attempt added a second success-path {@code @Test} method there, using this exact + * same {@code HashMap}-based leak shape, sharing that class's in-process {@code AbstractProfilerTest} + * dynamic-attach lifecycle. It failed reliably whenever it ran after + * {@code ReferenceChainTrackingTest}'s own {@code ChainLink} test in the same JVM - not a + * test-ordering bug, but a genuine, non-obvious property of {@code ReferenceChainTracker}: + * {@code runPass()} (referenceChains.cpp) performs exactly one root-seeded {@code FollowReferences} + * walk per search's *entire lifetime* ({@code _search_started}); every later pass only expands + * frontier entries that walk already discovered ({@code expandFrontier()}) - it never + * re-examines GC roots or revisits an already-{@code EXPANDED} entry for newly-added children. + * Since {@code ReferenceChainTracker} is a process-wide singleton, only the *first* test to ever + * call {@code runPass()} in a given JVM gets a real root-seeded walk; every subsequent test's own, + * independently-rooted local variables are structurally invisible to the mechanism afterward, no + * matter how they are built. A genuinely separate child JVM per scenario sidesteps this + * entirely: each process gets its own fresh {@code ReferenceChainTracker} singleton, and + * therefore its own guaranteed first-ever root walk. + */ +@Tag("slow") +public class ExternalProcessReferenceChainTest extends AbstractProcessProfilerTest { + + @Test + void shouldReconstructReferrerChainInSeparateProcess() throws Exception { + // Mirrors ReferenceChainTrackingTest.isPlatformSupported()'s own guard - FollowReferences/ + // tag-based frontier walking assumes a HotSpot-shaped JVMTI heap implementation. + assumeFalse(Platform.isJavaVersion(8)); + assumeFalse(Platform.isJ9()); + assumeFalse(Platform.isZing()); + + Path scratchDumpPath = Files.createTempFile("referencechains-external-process", ".jfr"); + // LeakingCacheScenario.run() (inside the child) creates this file on its own first dump() - + // an empty placeholder here would make that first dump() attempt fail confusingly. + Files.deleteIfExists(scratchDumpPath); + // "start" requires a "jfr,file=..." clause (found the hard way: JavaProfiler.execute() + // throws "Flight Recorder output file is not specified" without one) even though this + // scenario never reads its content - only the explicit dump() calls + // LeakingCacheScenario.run() makes to scratchDumpPath matter. + Path continuousJfrPath = Files.createTempFile("referencechains-external-process-continuous", ".jfr"); + try { + // budget=200000 (up from the in-process test's 4000, found via TEST_LOG instrumentation + // in referenceChains.cpp/livenessTracker.cpp): a genuinely fresh external JVM's + // reachable-from-roots graph (full JUnit/JMC/Gradle-worker classpath, bootstrapped from + // scratch - no benefit from a warm, already-running shared worker JVM) is far larger than + // the in-process test's, and each pass's own JVMTI walk cost scales with cumulative + // frontier size, not with this scenario's own allocation rate - budget=4000 stayed + // truncated=1 at 76000+ admitted edges after 19 real passes and never got close to this + // scenario's own cache before the test's own timeout. _budget is the pacing controller's + // hard ceiling (see updatePacing()'s own comment, referenceChains.cpp) - pausetarget alone + // cannot compensate for a ceiling set too low, only pacing *within* it. + String startCommand = "start,memory=64:l,generations=true," + + "referencechains=true:hops=64:budget=200000:ttl=120000:framecap=2000000:pausetarget=60000" + + ",jfr,file=" + continuousJfrPath.toAbsolutePath(); + // Packed into one args[1] string - see ExternalLauncher's own "leak-cache" mode comment + // for why (avoids extending AbstractProcessProfilerTest.launch()'s generic (target, + // commands) contract with a 3rd argument every other mode would have to ignore). + String packedCommand = startCommand + "|||" + scratchDumpPath.toAbsolutePath(); + + // Propagates this JVM's own ddprof_test.config (set by ProfilerTestPlugin.kt on the + // ddprof-test Test task itself, not inherited by a ProcessBuilder-launched child on its + // own) so LeakingCacheScenario can use the same debug-only seeded-representative fallback + // ReferenceChainTrackingTest already relies on instead of depending purely on real + // allocation-sampling timing. + List jvmArgs = Collections.singletonList( + "-Dddprof_test.config=" + System.getProperty("ddprof_test.config")); + + AtomicReference resultLine = new AtomicReference<>(); + LaunchResult result = launch("leak-cache", jvmArgs, packedCommand, + Collections.emptyMap(), + // Generous: a whole separate JVM's startup/classloading cost, on top of the same + // up-to-25-round population-growth loop plus grace period that takes ~20s in-process + // (ReferenceChainTrackingTest's own history). + 90, + line -> { + if (line.startsWith(LeakingCacheScenario.FOUND_MARKER) + || line.equals(LeakingCacheScenario.NOT_FOUND_MARKER) + || line.startsWith(LeakingCacheScenario.NO_HASHMAP_INTERNALS_MARKER) + || line.startsWith(LeakingCacheScenario.CHAIN_NOT_PERSISTED_MARKER)) { + resultLine.set(line); + } + return LineConsumerResult.CONTINUE; + }, + null); + + assertTrue(result.inTime, "Child process did not exit within the wait timeout"); + assertEquals(0, result.exitCode, "Child process exited with a non-zero code"); + assertNotNull(resultLine.get(), + "Child process never printed a recognizable result marker on stdout"); + assertEquals( + LeakingCacheScenario.FOUND_MARKER + LeakingCacheScenario.CachedPayload.class.getName(), + resultLine.get(), + "Expected a successfully reconstructed chain whose leaf is CachedPayload, threading " + + "through java.util.HashMap's own internal storage - got: " + resultLine.get()); + } finally { + Files.deleteIfExists(scratchDumpPath); + Files.deleteIfExists(continuousJfrPath); + } + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/LeakingCacheScenario.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/LeakingCacheScenario.java new file mode 100644 index 0000000000..173b4d1f1e --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/LeakingCacheScenario.java @@ -0,0 +1,316 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.JavaProfiler; +import org.openjdk.jmc.common.IMCType; +import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.ItemFilters; +import org.openjdk.jmc.flightrecorder.JfrLoaderToolkit; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +/** + * The actual "leaking Java app" body run inside a genuinely separate child JVM by + * {@code ExternalLauncher}'s {@code leak-cache} mode, driven by + * {@code ExternalProcessReferenceChainTest}. Deliberately not a JUnit test itself - no + * {@code AbstractProfilerTest}/dynamic-attach lifecycle, no shared-JVM {@code ReferenceChainTracker} + * singleton with any other test - this class's whole point is to run alone in its own process, so + * the one-shot root-seeded BFS walk (see this class's own comment on why a previous attempt at + * folding this scenario into {@code ReferenceChainTrackingTest} as a second {@code @Test} method + * failed) always belongs to this scenario and only this scenario. + * + *

    Same realistic-leak shape as {@code ReferenceChainTrackingTest}'s own (now-removed) in-process + * attempt: an ever-growing, never-evicted {@code HashMap}-backed cache, held by a local variable. + * + *

    Why {@code cache} is seeded and populated before the profiler is even started + * (found the hard way, against a real separate-process run): {@code threadLoop()} + * (referenceChains.cpp) calls {@code OS::sleep(_effective_cadence_ns)} - 1 second, initially - + * *before* its very first {@code shouldRunPass()} check, and {@code shouldRunPass()} always + * returns {@code true} on that first-ever check ({@code !_search_started}). So the process's + * one-and-only root-seeded walk fires roughly one second after {@code startThread()}, or earlier + * if woken by a GC-finish signal - a race {@code ReferenceChainTrackingTest}'s own + * {@code shouldReportAbandonedSearchOnTinyFrontierCap()} comment already documents ("that wakeup + * can race the thread's own startup ... and be missed"). An initial in-process attempt at this + * exact scenario created {@code cache} and started allocating only *after* starting the profiler + * (mirroring {@code ReferenceChainTrackingTest}'s {@code gcRootHolder}, which happened to work by + * luck of scheduling in a warm, already-running JVM) - against a genuinely fresh, cold JVM this + * lost the race essentially every time: the one-shot walk caught {@code cache} still empty, + * permanently blocking discovery of everything added to it afterward (a frontier entry once + * marked {@code EXPANDED} is never revisited for new children - see {@code expandFrontier()}'s + * own comment). Seeding {@code cache} with real data *before* {@code profiler.execute()} is even + * called removes the race entirely: no matter how fast the walk fires, {@code cache} is never + * empty when it does. + */ +public final class LeakingCacheScenario { + private LeakingCacheScenario() {} + + // Arbitrary, scenario-chosen klass id - this process's own LivenessTracker population table + // treats it as an opaque key (see KlassPopulationEntry's own comment) and this process never + // runs any other reference-chains scenario, so no collision risk with e.g. + // ReferenceChainTrackingTest's/ReferenceChainTestSeamsTest's own klass ids in their own, + // separate JVMs. + private static final int CACHED_PAYLOAD_TEST_KLASS_ID = 987301; + + /** Printed to stdout, followed by the matched leaf class's name, on success. */ + public static final String FOUND_MARKER = "[chain-found] "; + + /** Printed to stdout (with no class name suffix) if no match was ever observed. */ + public static final String NOT_FOUND_MARKER = "[chain-not-found]"; + + /** + * Printed to stdout (with the offending class name appended) if a match was found but its + * chain did not pass through {@code java.util.HashMap}'s own internal storage - see + * {@code ExternalProcessReferenceChainTest} for why this is asserted in the parent process + * rather than here (keeping this scenario's own stdout contract to "found/not-found" and + * leaving richer chain-shape assertions to the JUnit side, the same separation of concerns + * {@code ReferenceChainJfrParserTest} already uses for the lower-level JFR-format proof). + */ + public static final String NO_HASHMAP_INTERNALS_MARKER = "[chain-found-no-hashmap-internals] "; + + /** + * Printed to stdout (with an "N/M" re-emitted-dump count appended) if the chain was + * successfully reconstructed but did not re-emit into one or more subsequent, + * independently written JFR dumps - i.e. the "snapshot-and-keep" contract of + * {@code ReferenceChainTracker::drainPendingChainEvents()} (referenceChains.cpp), which + * {@code Profiler::dump()} invokes on every dump without clearing the resolved-chain cache, + * failed to hold across dumps. Asserted by {@code ExternalProcessReferenceChainTest}. + */ + public static final String CHAIN_NOT_PERSISTED_MARKER = "[chain-not-persisted] "; + + /** + * Seeds {@code cache} with an initial batch, *then* starts the profiler (see this class's own + * comment for why that order is load-bearing), then runs the same population-growth loop + * {@code ReferenceChainTrackingTest} pioneered - dumping to {@code scratchDumpPath} after every + * round (and a short grace period beyond the last round) until a {@code datadog.ReferenceChain} + * event whose {@code chain[0]} is {@link CachedPayload} appears - then prints exactly one + * result line to stdout. Called from {@code ExternalLauncher} (a different package - hence + * {@code public}), which owns the generic child-process bootstrap (JVMTI/library init, process + * exit) this scenario deliberately stays agnostic of; starting the profiler itself happens + * here, not in {@code ExternalLauncher}, specifically so {@code cache} can be seeded first. + */ + public static void run(JavaProfiler profiler, String startCommand, Path scratchDumpPath) throws Exception { + Map cache = new HashMap<>(); + seedInitialBatch(cache, 300); + System.out.println("[debug] startCommand=" + startCommand); + System.out.println("[debug] scratchDumpPath=" + scratchDumpPath); + if (startCommand != null && !startCommand.isEmpty()) { + profiler.execute(startCommand); + } + System.out.println("[debug] profiler.execute() returned"); + + boolean debugBuild = "debug".equals(System.getProperty("ddprof_test.config")); + boolean seededTestKlassTrend = false; + ReferenceChainAssertions.ChainMatch match = null; + int totalRounds = 25; + for (int round = 1; round <= totalRounds && match == null; round++) { + int newEntries = round * 300; + int roundNumber = round; + Thread allocator = new Thread(() -> { + for (int i = 0; i < newEntries; i++) { + String key = "leak-" + roundNumber + "-" + i; + cache.put(key, new CachedPayload(key)); + } + }); + allocator.start(); + allocator.join(); + System.gc(); + profiler.dump(scratchDumpPath); + match = findMatch(scratchDumpPath); + + if (match == null && debugBuild) { + // Same debug-only, seeded-representative short-circuit as ReferenceChainTrackingTest's + // own shouldReconstructReferrerChainThroughUnboundedCacheLeak() (see that method's own + // comment): decouples this scenario's assertion from whether the real, probabilistic + // allocation-sampling-driven slope detection happens to notice CachedPayload's own + // population trend within this scenario's fixed round/timeout budget. "seed-0" (from + // seedInitialBatch()) is reachable via cache for the scenario's entire lifetime, so it + // is a safe, always-valid representative regardless of which round this fires on. + if (!seededTestKlassTrend) { + for (int epoch = 1; epoch <= 10; epoch++) { + JavaProfiler.seedKlassPopulationSample0(CACHED_PAYLOAD_TEST_KLASS_ID, epoch * 10, epoch); + } + seededTestKlassTrend = true; + } + JavaProfiler.setKlassPopulationRepresentativeForTest0(CACHED_PAYLOAD_TEST_KLASS_ID, cache.get("seed-0")); + JavaProfiler.pollReferenceChainTargets0(); + profiler.dump(scratchDumpPath); + match = findMatch(scratchDumpPath); + } + + if (match == null) { + Thread.sleep(300); + profiler.dump(scratchDumpPath); + match = findMatch(scratchDumpPath); + } + if (round == 1 || round == 5 || round == 10 || round == 25) { + System.out.println("[debug] round=" + round + " cache.size()=" + cache.size()); + debugDumpAllChainLeaves(scratchDumpPath); + } + } + + for (int attempt = 0; match == null && attempt < 5; attempt++) { + Thread.sleep(1000); + profiler.dump(scratchDumpPath); + match = findMatch(scratchDumpPath); + } + + if (match == null) { + debugDumpAllChainLeaves(scratchDumpPath); + debugDumpCounters(profiler); + System.out.println(NOT_FOUND_MARKER); + return; + } + boolean sawHashMapInternals = false; + for (IMCType type : match.chain) { + if (type.getFullName().startsWith("java.util.HashMap")) { + sawHashMapInternals = true; + break; + } + } + if (!sawHashMapInternals) { + System.out.println(NO_HASHMAP_INTERNALS_MARKER + match.chain); + return; + } + + // Across-dumps persistence: a resolved chain must re-emit into EVERY subsequent dump the + // sample survives into, not only the one dump that first observed it. Profiler::dump() + // re-snapshots the whole resolved-chain cache without clearing it on every call + // (drainPendingChainEvents(), referenceChains.cpp - "snapshot-and-keep, not a drain"), so a + // still-live sample's chain re-emits into every chunk. `cache` still holds every + // CachedPayload here, so the sample stays live across these extra dumps; each dump targets a + // brand-new file, so a chain found there can only be present because it was re-emitted into + // that dump, not left over from the round-loop's own scratchDumpPath. + int persistChecks = 3; + int persisted = 0; + for (int i = 1; i <= persistChecks; i++) { + Path reDumpPath = Files.createTempFile("referencechains-persist-" + i + "-", ".jfr"); + Files.deleteIfExists(reDumpPath); + try { + profiler.dump(reDumpPath); + if (findMatch(reDumpPath) != null) { + persisted++; + } else { + System.out.println("[debug] across-dumps: CachedPayload chain missing from fresh dump #" + i); + } + } finally { + Files.deleteIfExists(reDumpPath); + } + } + if (persisted != persistChecks) { + System.out.println(CHAIN_NOT_PERSISTED_MARKER + persisted + "/" + persistChecks); + return; + } + System.out.println("[debug] across-dumps: CachedPayload chain re-emitted in all " + + persistChecks + " fresh dumps"); + System.out.println(FOUND_MARKER + match.chain.get(0).getFullName()); + } + + /** + * Populates {@code cache} with {@code count} entries under a key namespace ("seed-") disjoint + * from the round loop's own ("leak-") - called before the profiler (and therefore + * {@code ReferenceChainTracker}'s BFS thread) even starts, so the process's one-shot + * root-seeded walk can never catch {@code cache} empty (see this class's own comment on why + * that race is otherwise real). + */ + private static void seedInitialBatch(Map cache, int count) { + for (int i = 0; i < count; i++) { + String key = "seed-" + i; + cache.put(key, new CachedPayload(key)); + } + } + + /** Temporary diagnostic: print all native debug counters. */ + private static void debugDumpCounters(JavaProfiler profiler) { + try { + Map counters = profiler.getDebugCounters(); + System.out.println("[debug] counters (" + counters.size() + " total):"); + counters.entrySet().stream() + .filter(e -> e.getValue() != 0) + .sorted(Map.Entry.comparingByKey()) + .forEach(e -> System.out.println("[debug] " + e.getKey() + " = " + e.getValue())); + } catch (Exception e) { + System.out.println("[debug] exception while dumping counters: " + e); + } + } + + /** Temporary diagnostic: print every datadog.ReferenceChain event's chain[0] class, if any. */ + private static void debugDumpAllChainLeaves(Path scratchDumpPath) { + try { + if (!Files.exists(scratchDumpPath)) { + System.out.println("[debug] scratch dump path does not exist: " + scratchDumpPath); + return; + } + IItemCollection events; + try (InputStream in = Files.newInputStream(scratchDumpPath)) { + events = JfrLoaderToolkit.loadEvents(in); + } + IItemCollection chains = events.apply(ItemFilters.type("datadog.ReferenceChain")); + long total = chains.stream().mapToLong(it -> it.getItemCount()).sum(); + System.out.println("[debug] datadog.ReferenceChain total events: " + total); + IItemCollection liveObjects = events.apply(ItemFilters.type("datadog.HeapLiveObject")); + long totalLive = liveObjects.stream().mapToLong(it -> it.getItemCount()).sum(); + System.out.println("[debug] datadog.HeapLiveObject total events: " + totalLive); + IItemCollection abandoned = events.apply(ItemFilters.type("datadog.ReferenceChainAbandoned")); + long totalAbandoned = abandoned.stream().mapToLong(it -> it.getItemCount()).sum(); + System.out.println("[debug] datadog.ReferenceChainAbandoned total events: " + totalAbandoned); + for (org.openjdk.jmc.common.item.IItemIterable iterable : chains) { + org.openjdk.jmc.common.item.IType type = iterable.getType(); + org.openjdk.jmc.common.item.IMemberAccessor chainAccessor = + ReferenceChainAssertions.findAccessor(type, "chain"); + if (chainAccessor == null) { + System.out.println("[debug] no chain accessor found"); + continue; + } + for (org.openjdk.jmc.common.item.IItem item : iterable) { + Object chainValue = chainAccessor.getMember(item); + if (chainValue instanceof Object[]) { + Object[] raw = (Object[]) chainValue; + String leaf = raw.length > 0 && raw[0] instanceof IMCType + ? ((IMCType) raw[0]).getFullName() : ""; + System.out.println("[debug] chain leaf: " + leaf); + } + } + } + } catch (Exception e) { + System.out.println("[debug] exception while dumping chain leaves: " + e); + } + } + + private static ReferenceChainAssertions.ChainMatch findMatch(Path scratchDumpPath) throws Exception { + if (!Files.exists(scratchDumpPath)) { + return null; + } + IItemCollection events; + try (InputStream in = Files.newInputStream(scratchDumpPath)) { + events = JfrLoaderToolkit.loadEvents(in); + } + return ReferenceChainAssertions.findMatchForClass( + events.apply(ItemFilters.type("datadog.ReferenceChain")), CachedPayload.class); + } + + /** + * Realistic-leak fixture - identical shape to {@code ReferenceChainTrackingTest}'s own + * (now-removed) in-process attempt. The 32 {@code long} fields exist purely so a given number + * of megabytes needs far fewer entries to reach {@code ObjectSampler}'s real 256KiB sampling + * floor - deliberately plain fields, not a nested array (a same-instance companion array would + * be a *second*, separate heap allocation the size-weighted allocation sampler would compete + * for, taking sampling attention away from {@code CachedPayload} itself). + */ + static final class CachedPayload { + final String key; + long p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15; + long p16, p17, p18, p19, p20, p21, p22, p23, p24, p25, p26, p27, p28, p29, p30, p31; + + CachedPayload(String key) { + this.key = key; + } + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java new file mode 100644 index 0000000000..87a923ab93 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java @@ -0,0 +1,112 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import org.openjdk.jmc.common.IMCType; +import org.openjdk.jmc.common.item.IAccessorKey; +import org.openjdk.jmc.common.item.IItem; +import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.IItemIterable; +import org.openjdk.jmc.common.item.IMemberAccessor; +import org.openjdk.jmc.common.item.IType; +import org.openjdk.jmc.common.unit.IQuantity; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shared {@code datadog.ReferenceChain} JFR-parsing helpers, extracted out of + * {@code ReferenceChainTrackingTest} so both that in-process JUnit test and + * {@link LeakingCacheScenario} (run inside a genuinely separate child JVM by + * {@code ExternalProcessReferenceChainTest}) can reuse the exact same JMC-accessor logic + * rather than maintaining two copies. + */ +public final class ReferenceChainAssertions { + private ReferenceChainAssertions() {} + + /** Result of {@link #findMatchForClass(IItemCollection, Class)}: one resolved chain event's fields. */ + public static final class ChainMatch { + public final List chain; + public final long targetTag; + public final int depth; + + ChainMatch(List chain, long targetTag, int depth) { + this.chain = chain; + this.targetTag = targetTag; + this.depth = depth; + } + } + + /** + * Scans {@code events} for a {@code datadog.ReferenceChain} item whose {@code chain[0]} is + * {@code targetClass} specifically, ignoring any events for other klasses this same + * leak-candidate mechanism may have legitimately flagged (e.g. "[B"/byte[] - see each caller's + * own comment). Returns {@code null} if {@code events} is empty or none match. + */ + public static ChainMatch findMatchForClass(IItemCollection events, Class targetClass) { + if (events == null || !events.hasItems()) { + return null; + } + for (IItemIterable iterable : events) { + IType type = iterable.getType(); + IMemberAccessor chainAccessor = findAccessor(type, "chain"); + IMemberAccessor targetTagAccessor = findAccessor(type, "targetTag"); + IMemberAccessor depthAccessor = findAccessor(type, "depth"); + if (chainAccessor == null) { + throw new IllegalStateException("No accessor for 'chain' field on datadog.ReferenceChain"); + } + + for (IItem item : iterable) { + Object chainValue = chainAccessor.getMember(item); + if (!(chainValue instanceof Object[])) { + throw new IllegalStateException( + "'chain' field resolved to " + chainValue + ", expected an array"); + } + Object[] rawChain = (Object[]) chainValue; + if (rawChain.length == 0 || !(rawChain[0] instanceof IMCType) + || !targetClass.getName().equals(((IMCType) rawChain[0]).getFullName())) { + continue; + } + List chain = new ArrayList<>(rawChain.length); + for (Object element : rawChain) { + chain.add((IMCType) element); + } + long targetTag = targetTagAccessor != null ? numberValue(targetTagAccessor.getMember(item)) : -1; + int depth = depthAccessor != null ? (int) numberValue(depthAccessor.getMember(item)) : -1; + return new ChainMatch(chain, targetTag, depth); + } + } + return null; + } + + /** + * Looks up a field's accessor by identifier rather than via {@code Attribute.attr(...)}: JMC's + * v1 chunk parser (internal.parser.v1.ValueReaders.ArrayReader#getContentType()) registers + * {@code UnitLookup.UNKNOWN} as the declared content type for every array field regardless of + * what its element reader resolves to, so an {@code F_ARRAY} field like {@code chain} + * (T_CLASS, F_CPOOL|F_ARRAY, jfrMetadata.cpp) cannot be bound via a compile-time-typed + * {@code Attribute}. Mirrors {@code ReferenceChainJfrParserTest}'s identical lookup, which + * already proves this resolves {@code chain}'s array elements to real {@link IMCType}s. + */ + public static IMemberAccessor findAccessor(IType type, String identifier) { + for (IAccessorKey key : type.getAccessorKeys().keySet()) { + if (identifier.equals(key.getIdentifier())) { + return type.getAccessor(key); + } + } + return null; + } + + public static long numberValue(Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + if (value instanceof IQuantity) { + return ((IQuantity) value).longValue(); + } + return -1; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainJfrParserTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainJfrParserTest.java new file mode 100644 index 0000000000..7e811b71ad --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainJfrParserTest.java @@ -0,0 +1,169 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import org.junit.jupiter.api.Test; +import org.openjdk.jmc.common.IMCType; +import org.openjdk.jmc.common.item.IAccessorKey; +import org.openjdk.jmc.common.item.IItem; +import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.IItemIterable; +import org.openjdk.jmc.common.item.IMemberAccessor; +import org.openjdk.jmc.common.item.IType; +import org.openjdk.jmc.common.item.ItemFilters; +import org.openjdk.jmc.flightrecorder.CouldNotLoadRecordingException; +import org.openjdk.jmc.flightrecorder.JfrLoaderToolkit; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * PROF-15341 design doc, Open Question: does JMC's parser actually resolve the {@code + * datadog.ReferenceChain} event's {@code chain} field - declared in jfrMetadata.cpp as {@code + * field("chain", T_CLASS, ..., F_CPOOL | F_ARRAY)}, i.e. an array of scalar + * constant-pool-index {@code T_CLASS} values - the same way it resolves a plain scalar + * F_CPOOL field (e.g. {@code objectClass}, already exercised by {@code AbstractProfilerTest}) + * or a plain F_ARRAY-of-composite-struct field (e.g. {@code jdk.StackTrace#frames})? Neither + * already-exercised shape proves this exact combination. + * + *

    This test loads the standalone .jfr file produced by the companion gtest ({@code + * ddprof-lib/src/test/cpp/referenceChainJfrRoundtrip_ut.cpp}) directly via {@link + * JfrLoaderToolkit} - no live profiler attach, no {@code AbstractProfilerTest} lifecycle - and + * asserts the {@code chain} field resolves to the exact class names that gtest seeded, in the + * exact leaf-to-root order {@code ReferenceChainTracker::buildChainEvent()} (referenceChains.h) + * produces. + * + *

    Why a generic accessor, not {@code Attribute.attr(...)}: JMC's v1 chunk parser + * (internal.parser.v1.ValueReaders.ArrayReader#getContentType()) registers {@code + * UnitLookup.UNKNOWN} as the declared content type for every array field, regardless of + * what its element reader resolves to - this is generic to all array fields, not specific to the + * cpool case. A field registered as UNKNOWN cannot be bound via {@code + * Attribute.attr(id, name, desc, CLASS).getAccessor(type)} (that requires the field's registered + * content type to match). This test instead looks the field up by identifier via {@code + * IType#getAccessorKeys()} and calls {@code IType#getAccessor(IAccessorKey)} directly - the same + * lower-level lookup JMC's own UI uses for fields it has no compile-time-known attribute for. + * This is not a workaround for a missing capability; it is the correct API for an unregistered + * custom field, and it still calls through the very code + * ({@code ArrayReader.read()}/{@code resolve()} delegating per-element to the field's element + * reader, which for {@code chain} is a {@code PoolReader}) that resolves each array element's + * constant-pool index to its class - the actual thing this test exists to prove. + */ +public class ReferenceChainJfrParserTest { + + private static final String EVENT_TYPE = "datadog.ReferenceChain"; + + /** + * Same path {@code chainRoundtripJfrPath()} in the companion gtest resolves to: the OS temp + * dir (TMPDIR, falling back to /tmp), agreed by both sides rather than by a shared build + * directory - the gtest (ddprof-lib) and this test (ddprof-test) are different Gradle + * modules/tasks with no other filesystem contract between them. + */ + private static Path roundtripJfrPath() { + String tmp = System.getenv("TMPDIR"); + String dir = (tmp != null && !tmp.isEmpty()) ? tmp : "/tmp"; + return Paths.get(dir, "datadog_reference_chain_roundtrip.jfr"); + } + + @Test + public void chainFieldResolvesToSeededClassNamesInLeafToRootOrder() + throws IOException, CouldNotLoadRecordingException { + Path jfrPath = roundtripJfrPath(); + assertTrue(Files.exists(jfrPath), + "Expected " + jfrPath + " to exist - run " + + ":ddprof-lib:gtestDebug_referenceChainJfrRoundtrip_ut first " + + "(referenceChainJfrRoundtrip_ut.cpp produces this file)."); + + IItemCollection events; + try (InputStream in = Files.newInputStream(jfrPath)) { + events = JfrLoaderToolkit.loadEvents(in); + } + IItemCollection chainEvents = events.apply(ItemFilters.type(EVENT_TYPE)); + assertTrue(chainEvents.hasItems(), "Expected at least one " + EVENT_TYPE + " event"); + + List resolvedChain = null; + long targetTag = -1; + int depth = -1; + for (IItemIterable iterable : chainEvents) { + IType type = iterable.getType(); + IMemberAccessor chainAccessor = findAccessor(type, "chain"); + IMemberAccessor targetTagAccessor = findAccessor(type, "targetTag"); + IMemberAccessor depthAccessor = findAccessor(type, "depth"); + assertNotNull(chainAccessor, "No accessor for 'chain' field on " + EVENT_TYPE); + + for (IItem item : iterable) { + Object chainValue = chainAccessor.getMember(item); + assertNotNull(chainValue, "'chain' field resolved to null"); + assertTrue(chainValue instanceof Object[], + "'chain' field resolved to " + chainValue.getClass() + ", expected an array"); + + Object[] rawChain = (Object[]) chainValue; + List chain = new ArrayList<>(rawChain.length); + for (Object element : rawChain) { + assertNotNull(element, + "chain[] element resolved to null - the constant-pool reference for this " + + "T_CLASS array entry did not resolve to a class"); + assertTrue(element instanceof IMCType, + "chain[] element resolved to " + element.getClass() + + ", expected " + IMCType.class + " (a resolved class, not a raw cpool index)"); + chain.add((IMCType) element); + } + resolvedChain = chain; + if (targetTagAccessor != null) { + Object v = targetTagAccessor.getMember(item); + if (v instanceof Number) { + targetTag = ((Number) v).longValue(); + } else if (v instanceof org.openjdk.jmc.common.unit.IQuantity) { + targetTag = ((org.openjdk.jmc.common.unit.IQuantity) v).longValue(); + } + } + if (depthAccessor != null) { + Object v = depthAccessor.getMember(item); + if (v instanceof Number) { + depth = ((Number) v).intValue(); + } else if (v instanceof org.openjdk.jmc.common.unit.IQuantity) { + depth = (int) ((org.openjdk.jmc.common.unit.IQuantity) v).longValue(); + } + } + break; // referenceChainJfrRoundtrip_ut.cpp writes exactly one event + } + if (resolvedChain != null) { + break; + } + } + + assertNotNull(resolvedChain, "Never iterated a " + EVENT_TYPE + " item"); + assertEquals(3, resolvedChain.size(), "Expected leaf/middle/root - 3 entries"); + // Leaf-to-root order, matching ReferenceChainTracker::buildChainEvent()'s + // FrontierTable::reconstructChain() contract and the gtest's insert() calls + // (tag=3 leaf -> tag=2 middle -> tag=1 root). + assertEquals("com.test.ChainLeaf", resolvedChain.get(0).getFullName()); + assertEquals("com.test.ChainMiddle", resolvedChain.get(1).getFullName()); + assertEquals("com.test.ChainRoot", resolvedChain.get(2).getFullName()); + assertEquals(3L, targetTag, "targetTag should be the leaf's frontier tag (3)"); + assertEquals(2, depth, "depth should be the leaf's own depth (2 hops from the root)"); + } + + private static IMemberAccessor findAccessor(IType type, String identifier) { + Map, ?> keys = type.getAccessorKeys(); + for (IAccessorKey key : keys.keySet()) { + if (identifier.equals(key.getIdentifier())) { + return type.getAccessor(key); + } + } + return null; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTestSeamsTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTestSeamsTest.java new file mode 100644 index 0000000000..c08de01a4e --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTestSeamsTest.java @@ -0,0 +1,147 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.JavaProfiler; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * PROF-15341 follow-up: {@code ReferenceChainTrackingTest} exercises {@code LivenessTracker}'s + * probabilistic allocation-sampling-driven slope detection and {@code ReferenceChainTracker}'s + * root-seeded BFS discovery together, in one real-JVM run - reliable only when both mechanisms + * happen to line up within the same bounded retry window. This class decouples them via the + * debug-build-only test seams on {@link JavaProfiler} (backed by {@code LivenessTracker}'s + * existing {@code *ForTest} seams and a new {@code ReferenceChainTracker::tagAsRootForTest()}, + * see javaApi.cpp), so each mechanism can be verified end-to-end in isolation: + *

      + *
    • {@link #shouldSelectSeededKlassAsLeakCandidateOnPositiveSlope()} - asserts a slope signal + * would fire from directly-seeded population history, with no allocation sampling involved.
    • + *
    • {@link #shouldReconstructChainForDirectlyTaggedRoot()} - asserts the BFS/chain- + * reconstruction/event-emission path fires for a directly-tagged, known live object, with no + * dependency on {@code selectLeakCandidates()} organically picking the right klass.
    • + *
    + * + *

    Only runs under the debug native build ({@code testdebug}) - the backing native methods do + * not exist in a release build (see javaApi.cpp's {@code #ifdef DEBUG} guard), mirroring + * {@code JVMAccessTest}'s own {@code "debug".equals(System.getProperty("ddprof_test.config"))} + * pattern for the same reason. + */ +public class ReferenceChainTestSeamsTest extends AbstractProfilerTest { + + // Arbitrary, test-chosen klass ids - LivenessTracker's population table treats them as opaque + // keys (see KlassPopulationEntry's own comment), so these need not resolve to any real class. + private static final int SLOPE_TEST_KLASS_ID = 987001; + private static final int CHAIN_TEST_KLASS_ID = 987002; + + @Override + protected String getProfilerCommand() { + // generations=true: gates LivenessTracker's population tracking (gcGenerationsEnabled()) - + // required for selectLeakCandidates() to return anything at all, real or seeded. + // referencechains=true: constructs ReferenceChainTracker's FrontierTable so + // tagAsReferenceChainRoot0()/runReferenceChainPass0() have a table to insert into. + // framecap=2000000 (not 1024): shouldReconstructChainForDirectlyTaggedRoot()'s first pass + // runs IterateOverReachableObjects, which admits every GC root in the whole JVM - not just + // this test's directly-tagged target - into the same frontier. A small framecap fills with + // that ambient root count before the tagged root ever gets to expandFrontier(), hitting + // SearchAbandonReason::FRONTIER_CAP with no chain event queued. Same value/rationale as + // ReferenceChainTrackingTest's own success-path tests (see that class's header comment). + return "generations=true,referencechains=true:hops=32:budget=500:ttl=60000:framecap=2000000"; + } + + @Override + protected boolean isPlatformSupported() { + return !(Platform.isJavaVersion(8) || Platform.isJ9() || Platform.isZing()); + } + + private static void assumeDebugBuild() { + assumeTrue("debug".equals(System.getProperty("ddprof_test.config"))); + } + + /** + * Seeds twenty epochs of strictly increasing population counts for {@link #SLOPE_TEST_KLASS_ID} + * directly into LivenessTracker's ring buffer - bypassing real allocation sampling entirely - + * then asserts {@code selectLeakCandidates()} ranks it as a leak candidate. This is the "assert + * a slope signal would be generated" seam: it proves the ranking logic itself works without + * depending on the real JVMTI heap sampler ever surfacing this specific klass. + */ + @Test + public void shouldSelectSeededKlassAsLeakCandidateOnPositiveSlope() { + assumeDebugBuild(); + JavaProfiler.resetKlassPopulationForTest0(); + JavaProfiler.setGcGenerationsEnabled0(true); + + // KLASS_POPULATION_MIN_FILL_FOR_TREND = 10 (livenessTracker.h) is only the floor at which a + // trend becomes eligible at all - selectLeakCandidates() also requires consecutive_positive + // to reach LEAK_TREND_HYSTERESIS_BASE = 5 consecutive qualifying epochs before trusting it + // (the sustained-trend hysteresis gate), so at least 10 + (5 - 1) = 14 strictly increasing + // samples are needed; 20 gives headroom. + for (int epoch = 1; epoch <= 20; epoch++) { + JavaProfiler.seedKlassPopulationSample0(SLOPE_TEST_KLASS_ID, epoch * 10, epoch); + } + + int[] candidates = JavaProfiler.selectLeakCandidateKlassIds0(); + boolean found = false; + for (int klassId : candidates) { + if (klassId == SLOPE_TEST_KLASS_ID) { + found = true; + break; + } + } + assertTrue(found, "Expected klass id " + SLOPE_TEST_KLASS_ID + + " to be selected as a leak candidate after a seeded positive-slope population history"); + } + + /** + * Tags a real, live, caller-chosen object directly as a reference-chain frontier root + * (bypassing ReferenceChainTracker's normal root-seeded discovery walk), wires it in as a + * seeded leak candidate's representative, then drives one BFS pass and one poll cycle + * synchronously. This is the "trigger the refchain on a known live heap sample" seam: it + * proves {@code runPass()}/{@code pollWatchedTargets()}/{@code buildChainEvent()} correctly + * produce a chain event for a target this test controls directly, decoupled from whether + * LivenessTracker's probabilistic sampler would have picked the same object on its own. + */ + @Test + public void shouldReconstructChainForDirectlyTaggedRoot() { + assumeDebugBuild(); + JavaProfiler.resetKlassPopulationForTest0(); + JavaProfiler.setGcGenerationsEnabled0(true); + // Guards against inheriting a FrontierTable an earlier test in this same, no-forkEvery + // JVM left permanently full/tiny - e.g. ReferenceChainTrackingTest's own + // shouldReportAbandonedSearchOnTinyFrontierCap deliberately drives the shared table to + // framecap=1 and leaves it that way. Same defensive pattern that class's own tests already + // use (see its header comment); without it, tagAsReferenceChainRoot0()'s insert() below can + // fail against a table this test never sized itself. + JavaProfiler.resetReferenceChainSearchForTest0(); + + Object target = new Object(); + + long tag = JavaProfiler.tagAsReferenceChainRoot0(target); + assertTrue(tag > 0, "Expected tagAsReferenceChainRoot0 to assign a valid frontier tag"); + + // See shouldSelectSeededKlassAsLeakCandidateOnPositiveSlope()'s comment above for why 20 + // (not just KLASS_POPULATION_MIN_FILL_FOR_TREND = 10) is needed to clear the hysteresis gate. + for (int epoch = 1; epoch <= 20; epoch++) { + JavaProfiler.seedKlassPopulationSample0(CHAIN_TEST_KLASS_ID, epoch * 10, epoch); + } + JavaProfiler.setKlassPopulationRepresentativeForTest0(CHAIN_TEST_KLASS_ID, target); + + boolean sawPassRun = JavaProfiler.runReferenceChainPass0(); + assertTrue(sawPassRun, "Expected runReferenceChainPass0 to run (reference chains enabled)"); + + JavaProfiler.pollReferenceChainTargets0(); + + int eventCount = JavaProfiler.drainReferenceChainEventCount0(); + assertTrue(eventCount > 0, + "Expected pollWatchedTargets() to have queued at least one chain event for the " + + "directly-tagged, seeded-representative target"); + assertTrue(!target.equals(null)); // keeps target reachable until here + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java new file mode 100644 index 0000000000..8289eb9094 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java @@ -0,0 +1,745 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.JavaProfiler; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junitpioneer.jupiter.RetryingTest; +import org.openjdk.jmc.common.IMCType; +import org.openjdk.jmc.common.item.IAttribute; +import org.openjdk.jmc.common.item.IItem; +import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.IItemIterable; +import org.openjdk.jmc.common.item.IMemberAccessor; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.openjdk.jmc.common.item.Attribute.attr; +import static org.openjdk.jmc.common.unit.UnitLookup.PLAIN_TEXT; + +/** + * PROF-15341 (+ lifecycle-wiring follow-up, + the Remaining Work Plan's target-selection bridging, + * pause-time pacing, and reporting work): end-to-end + * coverage for {@code ReferenceChainTracker} (ddprof-lib/src/main/cpp/referenceChains.h/.cpp). + * + *

    Scope note: {@code ReferenceChainTracker::start()} is now called from + * {@code Profiler::start()} (profiler.cpp), gated on {@code args._reference_chains}, followed by + * {@code ReferenceChainTracker::startThread()} which spawns its BFS thread; {@code Profiler::stop()} + * calls the matching {@code stopThread()}/{@code stop()} pair. A {@code datadog.ReferenceChainAbandoned} + * event now reaches a live {@code Recording}: {@code Profiler::dump()} calls + * {@code buildAbandonedEvent()} and writes its output via + * {@code Profiler::writeReferenceChainAbandoned()}/{@code FlightRecorder::recordReferenceChainAbandoned()} + * whenever the search has ended in {@code SearchState::ABANDONED} - mirroring the + * {@code LivenessTracker::flush()} call site there. {@link #shouldReportAbandonedSearchOnTinyFrontierCap()} + * exercises that path end-to-end below. + * + *

    {@code buildChainEvent()}'s target-selection feed (Remaining Work Plan's target-selection + * bridging step): + * {@code ReferenceChainTracker::pollWatchedTargets()} (referenceChains.cpp), called from + * {@code threadLoop()} once per scheduling cycle after {@code runPass()}, closes the gap this class's + * header comment used to describe as unclosed. It polls + * {@code LivenessTracker::selectLeakCandidates()} (positive population-slope ranking over a rolling + * per-klass survivor-count window, gated on {@code _gc_generations} - design doc's Open Question 3) + * and, for each ranked klass whose representative instance an ordinary {@code runPass()} walk has + * *already* tagged ({@code getTag() > 0} - a read, never a {@code SetTag} seed), reconstructs and + * emits its chain via {@code Profiler::writeReferenceChain()}. {@link #shouldReconstructReferrerChainToGcRoot()} + * below exercises this end to end against a real JVM, real GCs, and a real JVMTI heap walk - not a + * synthetic frontier fixture (see {@code referenceChainJfrRoundtrip_ut.cpp}/{@code ReferenceChainJfrParserTest} + * for that already-covered, lower-level reconstruction-correctness proof). + * + *

    Why {@code @TestMethodOrder}/{@code @Order(1)}: {@code ReferenceChainTracker} is a + * process-wide singleton whose single, singleton-owned search (design doc's Open Question 3 + * "Shipped" note) never restarts once it leaves {@code SearchState::RUNNING} - + * {@code shouldRunPass()} (referenceChains.cpp) returns {@code false} forever after that point, and + * the transition itself releases every tag the search ever assigned + * ({@code releaseSearchTags()}). Its {@code FrontierTable} is sized once, the first time any test + * in this JVM calls {@code ReferenceChainTracker::start()}, and never resized on later + * start()/stop() cycles (referenceChains.cpp's {@code if (_frontier == nullptr)} guard) - so + * whichever test runs first also fixes that capacity for every test that runs after it in the same + * JVM (no {@code forkEvery} configured, see {@code ProfilerTestPlugin.kt}, so this whole class runs + * in one). {@link #shouldReportAbandonedSearchOnTinyFrontierCap()} below deliberately drives that + * shared search to {@code SearchState::ABANDONED}, via an artificially tiny frontier cap when it + * gets to build the table itself, or its own {@code ttl} fallback otherwise (see that method's own + * comment). Both {@link #shouldReconstructReferrerChainToGcRoot()} and + * {@link #shouldReconstructReferrerChainThroughUnboundedCacheLeak()} need the search still + * {@code RUNNING} to find their own candidates' tags non-zero, so they are pinned to run first, + * via {@code @Order(1)}/{@code @Order(2)} respectively - without that ordering all three tests + * would race for which ones get to observe a still-{@code RUNNING} search, and neither + * success-path test has a fallback for losing that race the way the abandonment test does. The + * relative order between the two success-path tests does not itself matter - both target + * different klasses ({@link ChainLink} vs {@link CachedPayload}) within the same shared search, + * and neither exhausts it - only their both running before the abandonment test does. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@Tag("slow") +public class ReferenceChainTrackingTest extends AbstractProfilerTest { + + private static final IAttribute SETTING_NAME = attr("name", "", "", PLAIN_TEXT); + private static final IAttribute SETTING_VALUE = attr("value", "", "", PLAIN_TEXT); + + // Arbitrary, test-chosen klass ids for the debug-only population-seeding seams below (see + // ReferenceChainTestSeamsTest's own comment: LivenessTracker's population table treats these as + // opaque keys, so they need not resolve to any real class). Distinct per test/from + // ReferenceChainTestSeamsTest's own ids purely as cheap insurance against collision in a shared, + // no-forkEvery test JVM. + private static final int CHAIN_LINK_TEST_KLASS_ID = 987201; + private static final int CACHED_PAYLOAD_TEST_KLASS_ID = 987202; + + @Override + protected String getProfilerCommand() { + String testName = testInfo != null + ? testInfo.getTestMethod().map(java.lang.reflect.Method::getName).orElse("") + : ""; + if ("shouldReconstructReferrerChainToGcRoot".equals(testName) + || "shouldReconstructReferrerChainThroughUnboundedCacheLeak".equals(testName)) { + // memory=...:l + generations=true: LivenessTracker::gcGenerationsEnabled() (Remaining + // Work Plan's population tracking / target-selection bridging, livenessTracker.h) must be true for pollWatchedTargets() + // (referenceChains.cpp) to do anything at all - referencechains=... alone stays + // whole-graph-only (design doc's Open Question 3 "still undecided" fallback). A small + // sampling interval maximizes the chance the many ChainLink instances this test + // allocates below actually get liveness-tracked (LivenessTracker::track(), invoked from + // the allocation-sampling path whenever _gc_generations || _record_liveness is set, + // objectSampler.cpp). A large framecap avoids an early + // SearchAbandonReason::FRONTIER_CAP abandonment against this JVM's real, + // not-controlled-by-this-test root-reachable graph size, and a long ttl avoids a + // TTL abandonment mid-test - either would call releaseSearchTags() and permanently + // zero every tag this test depends on (see this class's header comment on why this + // test is pinned to @Order(1)). + // + // pausetarget=5000: the pause-time pacing controller's updatePacing() (referenceChains.cpp) measures this JVM's real + // FollowReferences/GetObjectsWithTags call latency at ~150ms early on, climbing past a full + // second as this test's own allocation keeps handing the search more to discover (each pass + // re-scans the whole already-discovered/tagged set, so cost grows with cumulative progress, + // not with this test's own allocation *rate*) - i.e. that latency is dominated by per-call + // overhead, not by the requested edge budget. Against the default pausetarget=5ms ceiling + // (DEFAULT_REFERENCE_CHAINS_PAUSE_TARGET_MS, arguments.h) every observed pass is "over + // ceiling even at the floor", so the controller settles at MIN_EFFECTIVE_BUDGET=50 edges/pass + // and MAX_EFFECTIVE_CADENCE_NS=4s between passes - a real but glacial discovery rate this + // test's own wall-clock budget cannot outwait, and one a merely-generous ceiling (e.g. 500ms) + // only postpones: once cumulative progress pushes the real per-pass cost back past *that* + // ceiling too, the same throttling recurs. A ceiling comfortably above the highest per-call + // cost this test's own scale of population growth is expected to reach instead lets the + // controller keep _effective_budget at this test's own configured budget=4000 ceiling for + // the method's entire run, the same convergence behavior referenceChains_ut.cpp's own pacing + // tests already exercise synthetically. + // + // painbudget=100: shouldReconstructReferrerChainThroughUnboundedCacheLeak shares this + // singleton search with shouldReconstructReferrerChainToGcRoot (@Order(1), runs first) and + // needs a restart (canAffordNewSearch(), referenceChains.cpp) once that first search + // reaches a terminal state. PainBudget (painBudget.h) gates that restart on + // pain_spent_ms / refill_rate milliseconds of cooldown; the default + // DEFAULT_REFERENCE_CHAINS_PAIN_BUDGET_PERCENT=1 (1% refill) can make that cooldown run to + // tens of seconds to minutes for a search this large - far past this test's own bounded + // retry loop (16 rounds plus a short grace period). painbudget=100 keeps the restart gate + // affordable immediately, which is fine here since nothing else in this JVM is competing + // for the pain budget. + // + // firstpassbudget=200000: root/stack-ref enumeration (runPassManualWalk()'s + // IterateOverReachableObjects call) shares budget=4000 with expandFrontier()'s steady-state + // expansion unless overridden - in this shared, forked test JVM's real (not controlled by + // this test) root-reachable graph, that leaves this method's own CachedPayload-holding + // cache HashMap (a direct stack-local root on this test's own thread) at the mercy of + // whatever order JVMTI's enumeration happens to visit roots in: budget=4000 was observed + // exhausting itself on other roots' subtrees every single attempt, never once admitting a + // single edge from this thread's own stack. Overriding just root enumeration's own budget + // (not budget=4000, which the pacing controller has already been tuned around - raising it + // instead throttles every expansion pass down to + // MIN_EFFECTIVE_BUDGET/MAX_EFFECTIVE_CADENCE_NS, see updatePacing()) gives every root + // enumeration attempt (the first pass, and any later one ROOT_ENUM_MIN_INTERVAL_NS gates + // back in - see that constant's own comment) enough headroom to reach this thread's stack + // without slowing down the steady-state expansion passes that follow. + return "memory=64:l,generations=true," + + "referencechains=true:hops=64:budget=4000:ttl=120000:framecap=2000000:pausetarget=5000:painbudget=100:firstpassbudget=200000"; + } + // shouldReportAbandonedSearchOnTinyFrontierCap needs the frontier table (not the + // per-pass budget) to be what runs out first: heapReferenceCallback() (referenceChains.cpp) + // checks the budget before ever calling FrontierTable::insert(), so a budget as small as + // the frontier cap itself (e.g. budget=1:framecap=1) only ever exhausts the budget after + // admitting exactly one object - insert() never gets a chance to fail, and the search + // completes normally instead of abandoning. A budget comfortably larger than framecap=1 + // lets a *second* root-referenced object reach insert() and hit the actually-full table, + // which is what runPass() treats as grounds to abandon (Termination section priority 1). + // + // framecap=1 only actually controls the table's capacity the *first* time + // ReferenceChainTracker::start() ever constructs its FrontierTable in this JVM + // (referenceChains.cpp's `if (_frontier == nullptr)` guard - the table, like + // LivenessTracker's own, survives every later start()/stop() cycle at whatever size it was + // first built with). This method's own @Order(1) pinning on + // shouldReconstructReferrerChainToGcRoot means *that* test's own, much larger framecap=2000000 + // wins that race instead - so this method also supplies a small ttl as a second, independent + // abandonment trigger. runPass()'s Termination-section priority still checks frontier-cap + // first, so a fresh, genuinely-tiny table (this method running first, e.g. in isolation via + // `-Ptests=ReferenceChainTrackingTest.shouldReportAbandonedSearchOnTinyFrontierCap`) still + // abandons via SearchAbandonReason::FRONTIER_CAP as originally designed; inheriting an + // already-oversized table instead falls through to the ttl check, which fires almost + // immediately regardless of table size because _search_start_ns (referenceChains.h) is set + // once, the first time the shared search ever started - by the time this method's own + // start() call re-parses ttl, that clock already reads however long + // shouldReconstructReferrerChainToGcRoot's own run took. Either path produces a + // datadog.ReferenceChainAbandoned event, which is all this method actually asserts on. + if ("shouldReportAbandonedSearchOnTinyFrontierCap".equals(testName)) { + return "referencechains=true:hops=32:budget=500:framecap=1:ttl=100"; + } + // Deliberately does not request cpu/wall/memory/nativemem: those categories also + // write an "enabled" ActiveSetting (flightRecorder.cpp:1141-1144) and all default to + // false when not requested, so "enabled"="true" is unambiguous evidence of the + // datadog.ReferenceChain setting specifically without needing to disambiguate by the + // ActiveSetting "id" field (JMC's generic accessor lookup does not resolve that field + // for this custom event type - not worth a bespoke accessor for one assertion). + return "referencechains=true:hops=32:budget=500:ttl=2000:framecap=256"; + } + + @Override + protected boolean isPlatformSupported() { + // FollowReferences/tag-based frontier walking (referenceChains.cpp) assumes a + // HotSpot-shaped JVMTI heap implementation; excluded platforms mirror + // LivenessTrackingTest's own guard (memleak/LivenessTrackingTest.java). + return !(Platform.isJavaVersion(8) || Platform.isJ9() || Platform.isZing()); + } + + /** + * Verifies the {@code referencechains=...} flag round-trips through + * {@code Arguments} parsing (arguments.cpp's {@code CASE("referencechains")}) into the + * {@code datadog.ReferenceChain} JFR setting (flightRecorder.cpp:1143's + * {@code writeBoolSetting(buf, T_REFERENCE_CHAIN, "enabled", args._reference_chains)}). + */ + @RetryingTest(5) + public void shouldExposeReferenceChainsSettingWhenEnabled() { + stopProfiler(); + IItemCollection settings = verifyEvents("jdk.ActiveSetting"); + boolean sawEnabledSetting = false; + for (IItemIterable iterable : settings) { + IMemberAccessor nameAccessor = SETTING_NAME.getAccessor(iterable.getType()); + IMemberAccessor valueAccessor = SETTING_VALUE.getAccessor(iterable.getType()); + if (nameAccessor == null || valueAccessor == null) { + continue; + } + for (IItem item : iterable) { + if ("enabled".equals(nameAccessor.getMember(item)) + && "true".equals(valueAccessor.getMember(item))) { + sawEnabledSetting = true; + } + } + } + assertTrue(sawEnabledSetting, "datadog.ReferenceChain#enabled setting was not found"); + } + + /** + * This test's stated success-path scenario, now exercising the Remaining Work Plan's + * target-selection bridging feed instead of staying disabled: allocates a growing population of + * {@link ChainLink} instances, forcing GCs between allocation rounds so + * {@code LivenessTracker::cleanup_table()}'s epoch-advance pass (livenessTracker.cpp) observes a + * rising per-klass survivor count each time, until {@code selectLeakCandidates()} trusts the + * resulting trend (needs {@code KLASS_POPULATION_MIN_FILL_FOR_TREND = 10} ring-buffer samples). + * Then waits for {@code ReferenceChainTracker::pollWatchedTargets()} to notice that ranked + * candidate has already been tagged by an ordinary {@code runPass()} walk and reconstruct + + * emit its chain, and asserts on the resulting {@code datadog.ReferenceChain} event. + * + *

    Each population-growth round pairs with an explicit {@link #dump(Path)}: per + * livenessTracker.cpp, {@code cleanup_table()}'s per-klass population accounting only runs + * (unforced) from {@code flush_table()}, which only runs from {@code LivenessTracker::flush()}, + * which is called *only* from {@code Profiler::dump()} - there is no timer-driven flush. + * {@code System.gc()} bumps {@code LivenessTracker::_gc_epoch} synchronously inside the + * {@code GarbageCollectionFinish} callback ({@code onGC()}), so by the time {@code System.gc()} + * returns to Java the epoch bump is already visible - no extra sleep is needed between the + * {@code gc()} and the {@code dump()} that observes it. + */ + @Test + @Order(1) + public void shouldReconstructReferrerChainToGcRoot() throws Exception { + // Seed gcRootHolder with one live element *before* resetting the search below: ArrayList's + // backing array starts out as the shared empty-array sentinel, and only gets replaced with a + // real array on its first grow() (addAll()). ReferenceChainTracker::expandFrontier() + // (referenceChains.cpp) freezes whatever children it observes for a node the moment that + // node is expanded - it never re-examines an already-EXPANDED node for a later field mutation, + // regardless of how many times root/stack-ref enumeration itself reruns over the search's + // lifetime (runPassManualWalk()'s own comment). If gcRootHolder's node gets expanded while + // elementData still pointed at the empty sentinel (nothing stops some *unrelated* GC in this + // shared, no-forkEvery JVM from waking the freshly (re)started BFS thread in the gap between + // resetting the search below and round 1's own addAll()), the frozen children set would be + // permanently empty, and no ChainLink added in any of the 16 rounds that follow would ever + // become reachable - matching a real CI failure where pollWatchedTargets() reported tag=0 for + // this klass on every single poll across the whole test. Seeding one element first means the + // backing array is never empty at any point after the search (re)starts: every later + // addAll()/grow() copies all prior elements (including this one) into the new array, so + // whichever array the walk happens to snapshot when it expands the node, index 0 is always + // present in it. + List gcRootHolder = new ArrayList<>(); + gcRootHolder.add(new ChainLink("gc-root-seed")); + if ("debug".equals(System.getProperty("ddprof_test.config"))) { + // Being pinned to run first *within this class* (this class's own header comment, "Why + // @TestMethodOrder/@Order(1)") does not guarantee this is the first reference-chain test + // to ever call into the shared, process-wide singleton ReferenceChainTracker in this whole + // test JVM - no forkEvery is configured (same header comment), so another test class can + // run first and leave the singleton search already non-RUNNING/already-tagged, in which + // case runPass() never takes its real root-seeded-walk branch again. Force a genuine fresh + // search so this test's own ChainLink population is guaranteed reachable by a real walk, + // regardless of what ran earlier in this JVM. Debug-only: this native seam does not exist + // in a release build. + JavaProfiler.resetReferenceChainSearchForTest0(); + } + Path scratchDumpPath = Paths.get("referencechains-population-scratch.jfr"); + try { + // Up to 16 rounds: selectLeakCandidates()'s KLASS_POPULATION_MIN_FILL_FOR_TREND = 10 + // (livenessTracker.h) needs 10 *epochs that actually observe a surviving ChainLink sample*, + // not just 10 dump() calls - allocation sampling is probabilistic (see below), so some + // early, smaller rounds may not land a single ChainLink sample. Growing the per-round count + // compensates, and this loop keeps going (checking for the event every round) rather than + // committing to a fixed round count up front, so it self-adjusts to whatever this JVM's + // actual sampling behavior turns out to be. Staying under KLASS_POPULATION_RING_SIZE = 30 + // keeps every round's sample within the trend computeKlassPopulationSlope() reads back out. + // Capped at 16 rather than a larger margin above the 10-round minimum: this loop's own + // worst case (every round retained, no early match) runs inside the same forked test JVM + // every other ddprof-test class shares (ProfilerTestPlugin.kt's shared -Xmx512m default, + // no forkEvery) - a prior CI run hit "Java heap space" in that shared fork with this loop + // capped at 25, so the cap trades some of the original margin above the 10-round minimum + // for staying inside that shared heap. + // + // Per-round size growth itself is clamped to round 10 (Math.min(round, 10) below): rounds + // past 10 exist only to give pollWatchedTargets() more retries against the lock-contention + // race described below, not to keep building the population trend (already eligible by + // round 10) - letting round*600 keep scaling unclamped through round 16 made rounds 11-16 + // each add strictly more retained garbage than the last for no trend benefit, which is what + // drove the "Java heap space" failure this comment's own history refers to. + // + // Instance count is sized against ObjectSampler::check() (objectSampler.cpp), not this + // test's own "memory=64" request: "do not allow shorter interval than 256KiB" means the + // *actual* allocation-sampling interval is 262144 bytes regardless of the small value + // requested above (that request only affects LivenessTracker's own table-capacity formula, + // livenessTracker.cpp's initialize_table()). ChainLink's own padding fields (see that + // class's comment) get enough total megabytes sampled from far fewer instances than plain, + // unpadded ~40-byte instances would need - keeping this test's own contribution to the real + // JVM's root-reachable graph (which ReferenceChainTracker's BFS walk must also traverse, + // Triggering section) from ballooning to the point the walk can't practically catch up + // within this test's own wall-clock budget. ChainLink competes on equal footing against + // other incidental klasses (e.g. "[B"/byte[]) that this same mechanism may also legitimately + // flag as leak candidates - this test's own assertions below look for ChainLink specifically + // among however many datadog.ReferenceChain events actually appear, rather than assuming it + // is the only one. + ReferenceChainAssertions.ChainMatch match = null; + boolean seededTestKlassTrend = false; + int totalRounds = 16; + for (int round = 1; round <= totalRounds && match == null; round++) { + int newInstances = Math.min(round, 10) * 600; + List newLinks = new ArrayList<>(newInstances); + // Allocates on a freshly spawned thread, joined before continuing, matching + // GCGenerationsTest.MemLeakTarget's own pattern (memleak/GCGenerationsTest.java): + // JVMTI's SampledObjectAlloc callback never fired for any ChainLink allocated directly on + // this JUnit worker thread during this test's own development, even at many megabytes of + // total ChainLink allocation, but reliably fires for allocations on a thread created after + // Profiler::start() already ran. + int roundNumber = round; + Thread allocator = new Thread(() -> { + for (int i = 0; i < newInstances; i++) { + newLinks.add(new ChainLink("leak-" + roundNumber + "-" + i)); + } + }); + allocator.start(); + allocator.join(); + gcRootHolder.addAll(newLinks); + System.gc(); + dump(scratchDumpPath); + match = ReferenceChainAssertions.findMatchForClass(verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false), ChainLink.class); + + if (match == null && "debug".equals(System.getProperty("ddprof_test.config"))) { + // Deterministic short-circuit (debug-only native seams - a no-op in release builds, so + // this leaves release-build coverage of the organic path below completely unchanged): + // the dump() just above already drove a real BFS pass (Profiler::dump() -> + // ReferenceChainTracker::runPass(), the same background-thread-cadence path production + // uses), which, given this method's own generous firstpassbudget=200000, has almost + // certainly already tagged gcRootHolder's very first ChainLink (getTag() > 0) - that's + // pollWatchedTargets()'s only precondition for reconstructing a chain from it. Only + // *which klass gets ranked* as a leak candidate was ever left to chance + // (ObjectSampler's own probabilistic allocation sampling into LivenessTracker's ring, + // KLASS_POPULATION_MIN_FILL_FOR_TREND = 10 real epochs needed - observed to flake in + // practice). Seeding that ranking directly for an arbitrary klass id wired to that same + // real, already-tagged instance, then polling synchronously instead of waiting on the + // background thread's own cadence, removes exactly that flakiness without touching the + // real walk/reconstruction this test exists to prove. + // + // Seed exactly once (recordKlassPopulationSampleLocked(), livenessTracker.cpp, always + // *appends* a fresh ring slot rather than overwriting one for a repeated epoch - calling + // this whole block again on a later round would append a second 10..100 ramp right after + // the first, turning the ring into a non-monotonic sawtooth and destroying the very + // positive-slope signal selectLeakCandidates() needs). Only the poll+dump recheck below + // needs to repeat across rounds - not the seeding - to give a still-untagged + // representative or a lock-contended dump() more rounds to resolve. + if (!seededTestKlassTrend) { + for (int epoch = 1; epoch <= 10; epoch++) { + JavaProfiler.seedKlassPopulationSample0(CHAIN_LINK_TEST_KLASS_ID, epoch * 10, epoch); + } + seededTestKlassTrend = true; + } + JavaProfiler.setKlassPopulationRepresentativeForTest0(CHAIN_LINK_TEST_KLASS_ID, gcRootHolder.get(0)); + JavaProfiler.pollReferenceChainTargets0(); + dump(scratchDumpPath); + match = ReferenceChainAssertions.findMatchForClass(verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false), ChainLink.class); + } + + if (match == null) { + // A quiet window with no dump() in flight: Profiler::dump() (profiler.cpp) holds every + // entry of _locks[] exclusively for the duration of its own _jfr.dump() call + // (rotateDictsAndRun()'s lockAll()/unlockAll() pair) - the same array + // Profiler::writeReferenceChain() (profiler.cpp) needs a slot from to record an event at + // all. Back-to-back rounds with essentially no gap between one dump() and the next leave + // pollWatchedTargets() (referenceChains.cpp) little to no window to ever win that race; + // sleeping briefly here, then dumping again with no intervening dump()/gc() in between + // (so cleanup_table()'s epoch-advance pass, livenessTracker.cpp, stays a no-op and the + // population trend built up so far is undisturbed), gives it one. + Thread.sleep(300); + dump(scratchDumpPath); + match = ReferenceChainAssertions.findMatchForClass(verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false), ChainLink.class); + } + } + + // Grace period: the population trend only became eligible (ring_fill >= 10) partway through + // the loop above, and the same lock contention the loop's own comment describes can still + // delay the resulting write past this method's very last dump() - retry a few more times, + // well past that contention window, before concluding the mechanism genuinely did not fire. + for (int attempt = 0; match == null && attempt < 5; attempt++) { + Thread.sleep(1000); + dump(scratchDumpPath); + match = ReferenceChainAssertions.findMatchForClass(verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false), ChainLink.class); + } + + assertNotNull(match, + "Never observed a datadog.ReferenceChain event whose chain[0] is " + ChainLink.class + + " after " + gcRootHolder.size() + " retained ChainLink instances across up to " + + totalRounds + " population-growth rounds plus a grace period"); + // chain[0] is the *target* object's own class, not its holder's: + // ReferenceChainTracker::buildChainEvent()'s reconstructChain() (referenceChains.h) + // appends each visited FrontierEntry's referrer_klass leaf-to-root, and referrer_klass is + // populated from the *discovered object's own* GetClassSignature at insertion time (see + // referenceChainJfrRoundtrip_ut.cpp's insert(tag, parent_tag, referrer_klass, depth) calls, + // where tag 3's own referrer_klass is leafKlass, not middleKlass). The target here is + // always some currently-live ChainLink instance - the only class this test's leak-candidate + // population trend was built from - regardless of exactly which of the many instances + // allocated above LivenessTracker::foldKlassCountsLocked() (livenessTracker.cpp) happened to + // keep as its representative. Everything above chain[0] reflects real JDK-internal + // collection representation (e.g. ArrayList's backing array) rather than anything this test + // controls, so it is deliberately not asserted beyond "at least one hop was reconstructed". + assertEquals(ChainLink.class.getName(), match.chain.get(0).getFullName()); + assertTrue(match.targetTag > 0, "targetTag should be a valid, non-zero JVMTI tag"); + assertTrue(match.depth >= 0, "depth should be a non-negative hop count"); + assertTrue(!gcRootHolder.isEmpty()); // keeps every allocated ChainLink reachable until here + } finally { + Files.deleteIfExists(scratchDumpPath); + } + } + + /** + * A second, independent end-to-end scenario for the same target-selection mechanism as + * {@link #shouldReconstructReferrerChainToGcRoot()}, using a shape much closer to a real-world + * leak than that method's hand-rolled {@link ChainLink} list: an ever-growing, never-evicted + * {@link java.util.HashMap}-backed cache - a very common real leak pattern - holding + * {@link CachedPayload} values. Beyond confirming a chain fires for the leaking value type, + * this also asserts the reconstructed chain actually threads back through + * {@code java.util.HashMap}'s own internal storage ({@code HashMap$Node}), i.e. that the + * mechanism correctly walks a real JDK collection's internals rather than only ever having + * been proven against a single purpose-built linked fixture. + * + *

    Why {@code cache} is a local variable, not a {@code static} field (found the hard + * way): {@code ReferenceChainTracker::expandFrontier()} (referenceChains.cpp) freezes + * whatever children it observes for a node the moment that node is expanded - it never + * re-examines an already-{@code EXPANDED} entry for newly-added children, regardless of how + * many times root/stack-ref enumeration itself reruns over the search's lifetime + * ({@code runPassManualWalk()}'s own comment). A {@code static} field becomes a root at + * class-load time, essentially guaranteeing some early pass catches it (and marks it + * {@code EXPANDED}) while still empty - permanently blocking discovery of anything added to it + * afterward, no matter how many rounds run. A local variable created fresh at the top of this + * method, immediately followed by round-1 allocation, gives root enumeration a real chance to + * catch it only after it already holds live data - mirroring + * {@link #shouldReconstructReferrerChainToGcRoot()}'s {@code gcRootHolder}, which works for + * exactly this reason (a local {@code ArrayList}, not a {@code static} one). + * + *

    See this class's own header comment for why this runs as {@code @Order(2)}, before + * {@link #shouldReportAbandonedSearchOnTinyFrontierCap()}, and for why its relative order + * against {@link #shouldReconstructReferrerChainToGcRoot()} does not itself matter. + */ + @Test + @Order(2) + public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exception { + // Seed cache with one live entry *before* the resets below, mirroring + // shouldReconstructReferrerChainToGcRoot()'s own gcRootHolder seeding (see that method's + // comment): HashMap's backing table also starts out as a shared, lazily-replaced empty + // sentinel (only allocated on the first put()), so the same one-shot-BFS-freeze hazard + // documented there - a concurrent GC-triggered walk expanding this node's children while the + // backing table is still the empty sentinel, permanently freezing an empty children set - + // applies here too. Seeding one entry first means the backing table is never empty at any + // point after the search (re)starts. + Map cache = new HashMap<>(); + cache.put("cache-leak-seed", new CachedPayload("cache-leak-seed")); + if ("debug".equals(System.getProperty("ddprof_test.config"))) { + // Mirrors shouldReconstructReferrerChainToGcRoot()'s own reset (see that method's comment): + // the natural restartSearch() cycle (shouldRunPass(), referenceChains.cpp) that would + // otherwise give this method its own fresh root walk once shouldReconstructReferrerChainToGcRoot()'s + // own ChainLink candidate is found and its tags released is gated on cadence/pacing budget + // (canAffordNewSearch()) - not guaranteed to fire again before this method's own round/retry + // budget runs out. Force a genuine fresh search here too, rather than depend on that timing, + // so cache (below) is guaranteed reachable by a real walk regardless of it. Debug-only: this + // native seam does not exist in a release build. + // + // resetReferenceChainSearchForTest0() only resets ReferenceChainTracker's own search state + // (frontier/tags/search-progress fields); it does not touch LivenessTracker's per-klass + // population-history rings, which selectLeakCandidates() consults independently to decide + // which klass is "trending". Without also resetting those, a klass ChainLink already + // accumulated a positive slope for during shouldReconstructReferrerChainToGcRoot() can still + // outrank CachedPayload as the leak candidate here, so the fresh search below ends up + // reconstructing ChainLink's chain again instead of CachedPayload's. + JavaProfiler.resetKlassPopulationForTest0(); + JavaProfiler.resetReferenceChainSearchForTest0(); + } + Path scratchDumpPath = Paths.get("referencechains-cache-leak-scratch.jfr"); + try { + // Same round-growth/retry shape as shouldReconstructReferrerChainToGcRoot() - see that + // method's own comment for why the loop self-adjusts rather than committing to a fixed + // round count, why per-round scale is sized against ObjectSampler's real 256KiB sampling + // floor rather than this test's own "memory=64" request, why totalRounds is capped at + // 16 rather than a larger margin above the 10-round minimum (shared-fork heap headroom), + // and why per-round growth itself is clamped to round 10 (Math.min(round, 10) below). + ReferenceChainAssertions.ChainMatch match = null; + boolean seededTestKlassTrend = false; + int totalRounds = 16; + + // Pre-generate every key this loop will ever need, up front, rather than concatenating a + // fresh String on every put() below. CachedPayload's own class comment already documents + // why this fixture uses plain long fields instead of a byte[] (an earlier version's byte[] + // field stole every allocation-sampling hit from CachedPayload itself) - the key String + // (plus its own backing byte[]) is the same kind of same-instant, size-weighted-sampler + // competitor, just a *separate* object instead of a field. Generating all keys in one + // batch before the round loop starts lets their own klass_population trend flatten out + // (selectLeakCandidates() needs KLASS_POPULATION_MIN_FILL_FOR_TREND=10 *new* samples, not + // just a nonzero ring) well before CachedPayload's own ring starts growing, so the sampler + // has nothing else in flight to compete with once round 1 begins. + int maxEntries = 0; + for (int round = 1; round <= totalRounds; round++) { + maxEntries += Math.min(round, 10) * 600; + } + String[] keys = new String[maxEntries]; + for (int i = 0; i < keys.length; i++) { + keys[i] = "leak-" + i; + } + + int nextKey = 0; + for (int round = 1; round <= totalRounds && match == null; round++) { + // Matches shouldReconstructReferrerChainToGcRoot()'s own *600 growth rate (not *300, this + // method's previous value) - CachedPayload and ChainLink are near-identical in size (same + // padding fields; CachedPayload is actually slightly smaller, missing ChainLink's `next` + // reference), so halving the growth rate here bought no headroom and instead just let + // CachedPayload's own population ring stall short of KLASS_POPULATION_MIN_FILL_FOR_TREND + // within the same totalRounds budget both methods share. + int newEntries = Math.min(round, 10) * 600; + int keyOffset = nextKey; + Thread allocator = new Thread(() -> { + for (int i = 0; i < newEntries; i++) { + String key = keys[keyOffset + i]; + cache.put(key, new CachedPayload(key)); + } + }); + allocator.start(); + allocator.join(); + nextKey += newEntries; + System.gc(); + dump(scratchDumpPath); + IItemCollection events1 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); + match = ReferenceChainAssertions.findMatchForClass(events1, CachedPayload.class); + + if (match == null && "debug".equals(System.getProperty("ddprof_test.config"))) { + // Same deterministic short-circuit as shouldReconstructReferrerChainToGcRoot() (see that + // method's own comment) - debug-only native seams, a no-op in release builds, so the + // organic path below still fully covers release builds unchanged. keys[0]'s own + // CachedPayload is reachable from round 1 onward and, given this method's own + // firstpassbudget=200000, almost certainly already tagged by the real BFS pass the + // dump() just above triggered. Seed exactly once - see + // shouldReconstructReferrerChainToGcRoot()'s own comment for why reseeding the same + // epoch 1..10 ramp on a later round would corrupt the ring into a non-monotonic + // sawtooth and destroy the positive-slope signal instead of just re-establishing it. + if (!seededTestKlassTrend) { + for (int epoch = 1; epoch <= 10; epoch++) { + JavaProfiler.seedKlassPopulationSample0(CACHED_PAYLOAD_TEST_KLASS_ID, epoch * 10, epoch); + } + seededTestKlassTrend = true; + } + JavaProfiler.setKlassPopulationRepresentativeForTest0(CACHED_PAYLOAD_TEST_KLASS_ID, cache.get(keys[0])); + JavaProfiler.pollReferenceChainTargets0(); + dump(scratchDumpPath); + IItemCollection events2 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); + match = ReferenceChainAssertions.findMatchForClass(events2, CachedPayload.class); + } + + if (match == null) { + // Same lock-contention race shouldReconstructReferrerChainToGcRoot()'s own comment + // describes - a quiet retry gives pollWatchedTargets() a window to win a _locks[] + // slot against Profiler::dump()'s own exclusive hold. + Thread.sleep(300); + dump(scratchDumpPath); + IItemCollection events3 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); + match = ReferenceChainAssertions.findMatchForClass(events3, CachedPayload.class); + } + } + + for (int attempt = 0; match == null && attempt < 5; attempt++) { + Thread.sleep(1000); + dump(scratchDumpPath); + IItemCollection events4 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); + match = ReferenceChainAssertions.findMatchForClass(events4, CachedPayload.class); + } + + assertNotNull(match, + "Never observed a datadog.ReferenceChain event whose chain[0] is " + CachedPayload.class + + " after " + cache.size() + " cached entries across up to " + totalRounds + + " population-growth rounds plus a grace period"); + assertEquals(CachedPayload.class.getName(), match.chain.get(0).getFullName()); + assertTrue(match.targetTag > 0, "targetTag should be a valid, non-zero JVMTI tag"); + assertTrue(match.depth >= 0, "depth should be a non-negative hop count"); + + // The point of this test over shouldReconstructReferrerChainToGcRoot(): confirm the walk + // actually passed through the cache's own internal storage, not some other, coincidental + // retainer - cache is the only thing keeping any CachedPayload instance reachable. + boolean sawHashMapInternals = false; + for (IMCType type : match.chain) { + if (type.getFullName().startsWith("java.util.HashMap")) { + sawHashMapInternals = true; + break; + } + } + assertTrue(sawHashMapInternals, + "Expected the reconstructed chain to pass through java.util.HashMap's own internal " + + "storage (cache is a HashMap) - chain was: " + match.chain); + assertTrue(!cache.isEmpty()); // keeps every cached CachedPayload reachable until here + } finally { + Files.deleteIfExists(scratchDumpPath); + } + } + + /** + * This test's stated abandonment-path scenario: an artificially tiny frontier cap + * ({@code getProfilerCommand()}'s {@code framecap=1} for this test method, see that method's own + * comment for why {@code budget} must stay larger than {@code framecap}) makes the very first + * BFS pass hit the frontier cap once a second root-referenced object is discovered, so + * {@code runPass()} (referenceChains.cpp) abandons the search rather than silently truncating it + * (Termination section, doc/architecture/LiveHeapReferenceChains.md) - or, if this method's own + * {@code framecap=1} lost the race to size the shared {@code FrontierTable} (see + * {@code getProfilerCommand()}'s own comment on this method), its {@code ttl=100} fallback + * abandons the search instead once enough wall-clock time has passed, which by the time this + * method runs it always has. Either path exercises the same {@code buildAbandonedEvent()} / + * {@code Profiler::writeReferenceChainAbandoned()} / {@code FlightRecorder::recordReferenceChainAbandoned()} + * write into the recording that {@code Profiler::dump()} triggers - this method only asserts that + * a {@code datadog.ReferenceChainAbandoned} event exists, not which reason produced it. + * + *

    {@code @Order(3)}: this permanently exhausts the process-wide {@code ReferenceChainTracker} + * singleton's one-and-only search (see this class's header comment), so it must run after + * {@link #shouldReconstructReferrerChainToGcRoot()} and + * {@link #shouldReconstructReferrerChainThroughUnboundedCacheLeak()}. It used to rely on being + * the last method in source order plus JUnit's default (unannotated methods sort after + * {@code @Order}-annotated ones) for that - which happened to guarantee run order, but not the + * search *state* this method's own comment above presumes: if either earlier test's own + * organic GC/allocation-sampling trend never fired in time (observed in practice - real + * flakiness, not this test's fault), the shared search can still be {@code RUNNING} with a + * large, non-fresh frontier when this method starts, instead of the small, just-abandoned one + * its own {@code framecap=1}/{@code ttl=100} scenario assumes. Calling + * {@code resetReferenceChainSearchForTest0()} here, exactly as both earlier tests already do for + * their own scenarios, makes this method's own precondition (a fresh search) something it + * establishes itself rather than something it presumes a prior test left behind. Debug-only: + * this native seam does not exist in a release build. + */ + @Test + @Order(3) + public void shouldReportAbandonedSearchOnTinyFrontierCap() throws Exception { + if ("debug".equals(System.getProperty("ddprof_test.config"))) { + JavaProfiler.resetReferenceChainSearchForTest0(); + } + List gcRootHolder = new ArrayList<>(); + gcRootHolder.add(new ChainLink("middle", new ChainLink("leaf"))); + + // GarbageCollectionFinish (onGCFinish(), referenceChains.cpp) wakes the BFS thread + // early, but that wakeup can race the thread's own startup (VM::attachThread() + // completing before its first OS::sleep() call) and be missed. Don't rely on the + // signal alone: sleep comfortably past ReferenceChainTracker::PASS_CADENCE_NS (1s, + // referenceChains.h) too, so the thread's fixed-cadence fallback trigger + // (shouldRunPass()) guarantees at least one pass runs regardless of that race. + for (int i = 0; i < 3; i++) { + System.gc(); + Thread.sleep(100); + } + Thread.sleep(1500); + + Path dumpPath = Paths.get("referencechains-abandoned-test.jfr"); + try { + dump(dumpPath); + IItemCollection abandoned = verifyEvents(dumpPath, "datadog.ReferenceChainAbandoned", true); + assertTrue(abandoned.hasItems(), "Expected at least one datadog.ReferenceChainAbandoned event"); + } finally { + Files.deleteIfExists(dumpPath); + } + assertTrue(!gcRootHolder.isEmpty()); // keeps gcRootHolder reachable until the dump above + } + + /** + * Referrer-type fixture shared by both the success-path and abandonment-path tests. The 32 + * {@code long} fields below exist purely so {@link #shouldReconstructReferrerChainToGcRoot()} + * needs far fewer instances to allocate a given number of megabytes of ChainLink - deliberately + * plain fields, not a nested array: an array field would be a *second*, separate heap + * allocation, and the allocation-sampling interval that method's comment describes picks + * whichever allocation happens to cross its byte threshold size-weighted, so a same-instance + * companion array would take sampling attention away from ChainLink itself rather than adding + * to it. Plays no role in {@link #shouldReportAbandonedSearchOnTinyFrontierCap()}'s tiny, + * two-object fixture beyond trivially increasing its size. + */ + private static final class ChainLink { + final String name; + final Object next; + long p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15; + long p16, p17, p18, p19, p20, p21, p22, p23, p24, p25, p26, p27, p28, p29, p30, p31; + + ChainLink(String name) { + this(name, null); + } + + ChainLink(String name, Object next) { + this.name = name; + this.next = next; + } + } + + /** + * Realistic-leak fixture for {@link #shouldReconstructReferrerChainThroughUnboundedCacheLeak()}: + * models the single most common real-world leak shape this mechanism is meant to catch - an + * unbounded, never-evicted cache - as opposed to {@link ChainLink}'s hand-rolled linked list. + * The 32 {@code long} fields exist purely so a given number of megabytes needs far fewer + * entries to reach {@code ObjectSampler}'s real 256KiB sampling floor - deliberately plain + * fields, not a nested array, for exactly the reason {@link ChainLink}'s own comment already + * documents: a same-instance companion array would be a *second*, separate heap allocation + * that the size-weighted allocation sampler would compete for, taking sampling attention away + * from {@code CachedPayload} itself (an earlier version of this fixture used a {@code byte[]} + * field and never observed a single {@code CachedPayload} sample as a result - the sampler was + * catching the array instead). + */ + private static final class CachedPayload { + final String key; + long p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15; + long p16, p17, p18, p19, p20, p21, p22, p23, p24, p25, p26, p27, p28, p29, p30, p31; + + CachedPayload(String key) { + this.key = key; + } + } +} From d2eaf55f95c8d5614367727c2a847a4eac87b933 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Thu, 6 Aug 2026 09:44:35 +0200 Subject: [PATCH 5/7] Add reference-chains architecture and design docs Co-Authored-By: Claude Sonnet 5 --- doc/architecture/LiveHeapReferenceChains.md | 610 ++++++++++++++++++++ doc/reference-chains-collection-summary.md | 66 +++ doc/reference-chains-design.md | 259 +++++++++ 3 files changed, 935 insertions(+) create mode 100644 doc/architecture/LiveHeapReferenceChains.md create mode 100644 doc/reference-chains-collection-summary.md create mode 100644 doc/reference-chains-design.md diff --git a/doc/architecture/LiveHeapReferenceChains.md b/doc/architecture/LiveHeapReferenceChains.md new file mode 100644 index 0000000000..16611b22c6 --- /dev/null +++ b/doc/architecture/LiveHeapReferenceChains.md @@ -0,0 +1,610 @@ +# Reference Chains for Surviving Live Heap Samples + +**Status:** Implemented (see "Implementation status" below) +**Date:** 2026-07-07 +**Jira:** [PROF-15341](https://datadoghq.atlassian.net/browse/PROF-15341) + +## Implementation status + +The "Chosen design" section below has been implemented following +`LiveHeapReferenceChains-ImplementationPlan.md` (kept locally, not committed) +(Phases 0-7). It is off by default; the shipping switch is the `referencechains` argument +parsed by `Arguments` (`arguments.cpp`'s `CASE("referencechains")`), e.g. +`referencechains=true:hops=64:budget=2000:ttl=60000:framecap=65536`. + +Read this status note alongside the actual code before relying on it, not instead of it: + +- **The BFS engine (frontier table, tag lifecycle, incremental resumption, termination, + JFR event shapes) is implemented and unit-tested** (`ddprof-lib/src/main/cpp/referenceChains.h`/ + `.cpp`, `ddprof-lib/src/test/cpp/referenceChains_ut.cpp`). +- **The lifecycle gap is closed: it now runs inside a live profiling session.** + `Profiler::start()` (`profiler.cpp`) calls `ReferenceChainTracker::instance()->start(args)` + (gated on `args._reference_chains`, independent of the CPU/wall/alloc engine mask, the + same way `malloc_tracer`/`NativeSocketSampler` are gated on their own flags) followed by + the new `ReferenceChainTracker::startThread()`, which spawns the BFS thread + (`threadLoop()`) - safe there because the JVM/JVMTI environment is already fully up by + that point in the lifecycle, unlike inside `start()` itself, which must stay callable + with no live JVM for `referenceChains_ut.cpp`'s tests. `Profiler::stop()` calls the + matching `stopThread()`/`stop()` pair. Because `start()` runs, `SetEventNotificationMode` + for the GC callbacks is now actually invoked, so `onGCStart()`/`onGCFinish()` fire and the + BFS thread's `shouldRunPass()` scheduling loop (GC-epoch signal or the fixed cadence) is + live. A `datadog.ReferenceChainAbandoned` event now reaches a real `Recording`: when a + dump is requested (`Profiler::dump()`, the same call site that already flushes + `LivenessTracker`) and the search's state is `SearchState::ABANDONED`, + `buildAbandonedEvent()`'s output is written via the new + `Profiler::writeReferenceChainAbandoned()` / `FlightRecorder::recordReferenceChainAbandoned()` + wrappers (mirroring `writeHeapUsage()`'s exact shape). +- **`buildChainEvent()` now has a call site: the target-selection feed is closed.** + `ReferenceChainTracker::pollWatchedTargets()` (referenceChains.cpp), called from + `threadLoop()` once per scheduling cycle after `runPass()`, is that feed. It polls + `LivenessTracker::selectLeakCandidates()` (the positive population-slope ranking, Open + Question 3 below) and, for each ranked klass's live representative instance that an + ordinary `runPass()` walk has *already* tagged (`getTag() > 0` - a read, never a `SetTag` + seed; see Open Question 3 for why the design's original seeding proposal was replaced), + calls `buildChainEvent(tag, ...)`, which is cached (`cacheResolvedChain()`) rather than + written immediately. The actual JFR write happens later and on a different thread: enqueued + via `enqueueChainEvent()` and drained by `Profiler::dump()` -> `drainPendingChainEvents()` -> + `Profiler::writeReferenceChain()` / `FlightRecorder::recordReferenceChain()` (`profiler.cpp` + lines ~1960-1974), decoupling the walk from JFR I/O. Deduplication is `_resolved_chains` + (an `unordered_map` keyed by klass_id, `referenceChains.h`), not a + per-search tag set, so a klass flagged across consecutive polls emits its chain only once. + This is gated on `_gc_generations` *and* + liveness tracking both being enabled - `referencechains=...` alone still gets the + whole-graph-only behavior (no target seeding), resolving Open Question 3's "still + undecided" fallback. See + `ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java`: + `shouldReportAbandonedSearchOnTinyFrontierCap` exercises the abandonment path. + `shouldReconstructReferrerChainToGcRoot` (Phase E's own exit criterion) is no longer + `@Disabled`: it allocates a growing, real population of a fixture class, drives GCs and + `Profiler::dump()` calls until `LivenessTracker::selectLeakCandidates()` trusts the resulting + trend, then asserts on the `datadog.ReferenceChain` event `pollWatchedTargets()` produces - + a real end-to-end exercise of this whole mechanism against a live JVM, not a synthetic + frontier fixture. +- **Phase 5's tuning defaults are provisional, not empirically finalized.** The hop cap, + per-pass budget, TTL, and frontier-size cap (`arguments.h`'s `DEFAULT_REFERENCE_CHAINS_*` + constants) are explicitly-labeled placeholders; no benchmark against this codebase has + run yet (see Open Question 2 below and the implementation plan's Phase 5). + +## Goal + +For a subset of live-heap samples that survive past their allocation window, produce +a **reference chain** — a sequence of referrer *types* (not full field-level paths, not +necessarily to *all* GC roots) connecting the sampled instance back to *a* GC root. This +is diagnostic information ("what kind of object chain is keeping this alive"), not a +heap-dump-grade exact retainer analysis. + +## Constraints + +- Must run cheaply, with as short a safepoint / STW contribution as possible. +- Must work on stock vendor JDKs the agent attaches to — no forked/patched JVM builds. +- Exhaustive (all-roots, full-path) chains are explicitly **not** required; referrer-type-only, + bounded-depth, best-effort chains are acceptable. + +## Approaches considered + +Three approaches were evaluated; two are ruled out as launch requirements for concrete, +evidence-backed reasons. One sub-idea (Approach C's `ParallelObjectIterator` variant) is +explicitly kept open as a conditional future option; see its discussion below. + +| # | Approach | Completeness | Complexity | Feasibility | Status | +|---|---|---|---|---|---| +| A | Full JVMTI `FollowReferences` reverse-graph walk, piggybacked on an already-scheduled major GC | 4/5 | 4/5 | 2/5 | Rejected | +| B | Bounded BFS-from-roots with frontier pruning (JFR "leak profiler" technique, adapted) | 3/5 | 3/5\* | 4/5 | **Chosen** | +| C | Hook GC mark/copy closures (G1, ZGC) to record parent pointers inline during marking | 2/5 | 5/5 | 1/5 | Rejected | + +Scale (1-5 for each column): Completeness — higher is more complete (5 = closest to exhaustive all-roots/full-path); Complexity — higher is more complex to implement/maintain (5 = most complex, lower is better); Feasibility — higher is more feasible to ship on stock vendor JDKs (5 = most feasible). No single column dominates the decision; see the per-approach rationale below for why B was chosen despite not scoring highest on every column. + +\* This 3/5 reflects only the single-pass BFS sketched at selection time. The "Chosen +design" section below replaces that sketch with an incremental, resumable BFS +(JVMTI-tag-based frontier persistence across GC cycles, an agent-thread-driven pass that +gets its safepoint transparently from the JVMTI heap-walk call it makes, GC-callback +signaling, and explicit termination/tag-cleanup bookkeeping), which is materially more +complex than this score suggests — closer to 4/5 in implementation and maintenance +effort. The score is left unchanged above (it documents the state of the comparison at +decision time) rather than retroactively edited. + +### A — Full reverse-reachability walk (rejected) + +Safepoint length scales with live-set size regardless of how the walk is triggered. +Modern regionalized collectors (G1, Shenandoah) rarely perform a true full-heap walk +during ordinary major GCs, so "ride an already-paid pause" is not a reliable amortization +strategy. Cost is fundamentally at odds with the "short safepoint" constraint. + +### B — Bounded BFS-from-roots (chosen) + +Mirrors OpenJDK's own `jdk.OldObjectSample` leak-profiler implementation +(`src/hotspot/share/jfr/leakprofiler/chains/{edgeStore,bfsClosure,dfsClosure}.cpp`): +a `VM_Operation`-driven BFS from GC roots, retaining only edges on the frontier toward a +small, fixed sample set, with a hard hop cap (HotSpot itself caps chains at ~200 hops, +split 100/100 from leaf and from root). We can go cheaper than JFR because only the +**referrer class**, not object identity or field name, is needed — the `EdgeStore` +degenerates to `(referrer_klass, parent_tag, depth)` records, where `parent_tag` links +each record back to the record that discovered it, enabling chain reconstruction. + +Adopting this pattern is a re-scoping of proven, shipping HotSpot code, not a novel +algorithm design. + +### C — GC mark/copy closure piggyback (rejected) + +Investigated specifically for G1 and ZGC on the premise that per-edge referrer +information is already available inside the collector's own marking/evacuation closures +(`G1ParCopyClosure::do_oop_work`, ZGC's `ZMarkConcurrentRootsIteratorClosure` / +load-barrier closures), so recording it would cost nothing beyond what the GC already +pays. + +Rejected because there is no stable, externally reachable hook into these closures: + +- They are internal, template-instantiated C++ classes compiled into `libjvm.so` at + HotSpot build time — not a registrable/pluggable extension point. +- This differs categorically from `VMStructs`-style introspection already used in this + codebase (`ddprof-lib/src/main/cpp/hotspot/vmStructs.cpp`), which reads VM state + passively via an officially exported offset table. Intercepting a GC closure's + *behavior* would require either shipping a patched OpenJDK build (a fork/maintenance + commitment far beyond anything in this codebase) or binary-patching unversioned, + per-build-mangled function addresses — not shippable across JDK point releases. + +A related idea — using HotSpot's internal `ParallelObjectIterator` +(landed via [JDK-8322043](https://www.mail-archive.com/serviceability-dev@openjdk.org/msg12977.html), +used by `VM_HeapDumper` to partition heap regions across GC worker threads for parallel +heap dumping) to shrink Approach B's safepoint by parallelizing the walk — was also +investigated. Same verdict: it is an internal C++ class, not exposed via JVMTI, with no +stable ABI for an attached agent to call. Symbol-sniffing internal HotSpot functions *is* +an established pattern in this codebase (`VMStructs::findHeapUsageFunc`, +`vmStructs.cpp:489-509`), but that precedent covers a single leaf virtual method with a +value/POD-ish return; `ParallelObjectIterator` is a multi-class subsystem that coordinates +the VM's own GC worker threads under safepoint control — an order of magnitude larger +fragility surface, with a much higher blast radius if a layout assumption is wrong (GC +worker-thread coordination corruption vs. a bad JMX stat). Not pursued as a launch +requirement; revisit only if Approach B's single-threaded pause proves to be a measured +bottleneck, and treat it as an isolated, heavily version/flag-gated fast path with +automatic fallback — never a dependency. + +## Chosen design: incremental, resumable bounded BFS + +A single-pass bounded BFS still means one pause sized to whatever budget is configured. +The refinement below spreads that budget across multiple short passes instead of one +contiguous one, trading a possibly-higher *aggregate* STW total for a much better +*latency distribution* — no single long tail pause. + +### Why the frontier can survive across passes: JVMTI object tags + +The obstacle to pausing and resuming a BFS is that the frontier (the worklist of +not-yet-expanded objects) is normally a set of raw addresses, and a moving/compacting GC +between passes can relocate or collect any of them. + +JVMTI object tags solve this: + +- Tags are identity-based and GC-move-transparent — a tagged object can be re-resolved + after a GC regardless of where it moved. +- Tags are **non-retaining** — tagging does not keep an object alive. This is a *new* + subsystem dependency, not a reuse of one: the existing live-object sampler + (`LivenessTracker`, `livenessTracker.cpp`) does not use JVMTI tags at all — it + correlates sampled objects via JNI weak global references (`NewWeakGlobalRef`) held in + its own index table with its own locking and GC-triggered cleanup + (`livenessTracker.cpp:327-357`, `:53-70`). Non-retention is a property both mechanisms + happen to share, not evidence that this reuses proven infrastructure. +- **Investigated replacing tags outright with `LivenessTracker`'s weak-ref + index-table + pattern — resolved as "adopt the table pattern, keep the tags."** The pattern cannot + fully substitute for tags: `FollowReferences`/`IterateThroughHeap` (the calls that + actually discover a frontier object's referrers) can filter/report against a *tagged* + object set natively; a JNI weak-ref table has no hook into that machinery, so the + frontier-discovery step would still need tagged objects regardless of what stores the + metadata. What the investigation *does* carry over: `LivenessTracker`'s proven + `TrackingEntry`-style slot table — CAS-based index allocation, a signal-safe `SpinLock` + (`spinLock.h`), doubling-resize, and GC-epoch-triggered cleanup + (`livenessTracker.cpp:152-176`, `:213-278`, `:369-409`) — is a better-precedented design + for the frontier's *metadata* storage than inventing one from scratch, since a JVMTI tag + is a single `jlong` with no room for `(parent_tag, referrer_klass, depth)` on its own. + Recommendation: use the tag as an index into a `TrackingEntry`-style table (fields: + `parent_tag`, `referrer_klass`, `depth`) rather than encoding all three into the tag + value or a from-scratch hashmap. See "Frontier metadata storage" below and Open + Question 4. +- Non-retention gives incremental resumption a useful side effect for free: if a frontier + object dies between passes, it simply fails to re-resolve on the next pass. That branch + of the search is pruned automatically, with no extra liveness bookkeeping required. + +### Data structures + +- **Frontier**: a set of `(tag, parent_tag, referrer_klass, depth)` records. `tag` is the + JVMTI tag assigned to a not-yet-expanded object; `parent_tag` links back for chain + reconstruction; `depth` supports the hop cap. +- **EdgeStore**: accumulates `(referrer_klass, parent_tag, depth)` per discovered edge for + objects that are on a path toward a target sample. Keyed by tag, not address — + degenerate relative to JFR's `EdgeStore` since object identity/field names are not + required, but it retains the same `parent_tag` linkage field as the Frontier so a chain + can be walked back from a target sample to a root by following `parent_tag` across + EdgeStore records. + +### Frontier metadata storage: reusing `LivenessTracker`'s table pattern + +A JVMTI tag is one `jlong` — it can identify a frontier object and make it visible to +`FollowReferences`/`IterateThroughHeap`, but it cannot itself hold the three fields +(`parent_tag`, `referrer_klass`, `depth`) each Frontier/EdgeStore record needs. Two ways +to close that gap were considered: + +1. Build a bespoke hashmap keyed by tag value, from scratch. +2. Reuse `LivenessTracker`'s existing slot-table design (`livenessTracker.h:21-30` + `TrackingEntry`, `livenessTracker.cpp:152-176` sizing, `:213-278`/`:369-409` CAS slot + allocation and doubling resize, `spinLock.h`'s signal-safe `SpinLock`), using the tag + value as the slot index instead of a `jweak` as the identity handle. + +(2) is the better-precedented choice — it's shipping code, already exercises the exact +"per-slot payload, GC-cycle-driven cleanup, contention-safe locking" shape this needs — +provided the sizing formula is **not** copied as-is. `LivenessTracker` sizes its table +from `max_heap / sampling_interval` (a flat allocation-sample rate, +`livenessTracker.cpp:152-176`, capped at `MAX_TRACKING_TABLE_SIZE = 262144`, +`livenessTracker.h:39`); a BFS frontier's width is driven by per-hop fan-out in the object +graph, not by an allocation rate, and multiple concurrent searches (one per live-heap +sample being chased, see Open Question 3) each need their own capacity — the existing +formula does not transfer and a new one is an open question (folded into Open Question 2). + +### Algorithm + +1. Seed the frontier from GC roots (first pass) or from the persisted frontier + (resumed pass). +2. Resolve currently-live tagged frontier objects. Objects that fail to resolve are + dropped (dead — free pruning). +3. Expand the frontier up to a fixed per-pass budget (edge count or time slice). +4. Newly discovered objects are tagged and added to the frontier for the next pass. +5. Persist the frontier (native memory owned by the agent, not thread-local scratch) and + return control to the VM. +6. Repeat until: a target sample is reached, the hop cap is hit, or a per-search + abandonment limit (see Termination) is exceeded. + +### Triggering passes: resolved — the profiler never schedules its own safepoint + +Investigated whether pass-continuation work could ride the JVMTI +`GarbageCollectionStart`/`GarbageCollectionFinish` callbacks — the same callback this +codebase already uses to call `_heap_usage_func` (`vmStructs.cpp`) — instead of each pass +paying for its own safepoint. + +**Correction to an earlier framing in this doc**: a pass does not run "inside a dedicated +`VM_Operation::doit()`" that the profiler constructs — HotSpot's `VM_Operation`/ +`VMThread::execute()` machinery is internal, unexported C++ with no agent-facing entry +point; nothing outside HotSpot can submit one. What actually happens, confirmed against +`src/hotspot/share/prims/jvmtiTagMap.cpp` and `jvmtiEnv.cpp`: +`SetTag`/`GetTag` need no safepoint at all — they take only a `MutexLocker` over a +JVM-internal "hot lock" on the tag map. `FollowReferences`/`IterateThroughHeap` **do** +bring the VM to a safepoint, but the JVM does this internally and transparently +(`VM_HeapWalkOperation`/`VM_HeapIterateOperation`, dispatched via +`VMThread::execute()` *inside* HotSpot's own implementation of those calls) the moment an +ordinary attached agent thread calls them — the calling thread simply blocks until the +walk finishes. A pass is therefore: an agent-owned, already-attached thread (the same kind +`LivenessTracker` already runs on, see `livenessTracker.cpp:303-409`) calling +`FollowReferences`/`IterateThroughHeap` directly; the safepoint is a side effect of that +call, not something the profiler builds or schedules. + +**Confirmed the VM is genuinely at a safepoint (all mutators stopped) for the full +duration of both callbacks**, on every collector: + +- JVMTI spec: *"This event is sent while the VM is still stopped... the event handler + must not use JNI functions and must not use JVM TI functions except those which + specifically allow such use (see the raw monitor, memory management, and environment + local storage functions)."* +- openjdk/jdk source: delivery is synchronous on the VMThread + (`src/hotspot/share/prims/jvmtiExport.cpp:2752-2790`, comment *"this event is posted + from VM-Thread"*); every call site is inside a safepoint-executing `VM_Operation::doit()`, + backed by explicit asserts — e.g. Parallel GC's + `assert(SafepointSynchronize::is_at_safepoint())` (`gc/parallel/psScavenge.cpp:305-306`), + G1's `assert_at_safepoint_on_vm_thread()` (`gc/g1/g1VMOperations.cpp:141-157`), + Shenandoah and ZGC wrapping the same `SvcGCMarker` only inside their respective + `VM_Operation`/`VM_ZOperation::doit()` paths. Stable JDK 11 → mainline, across + Serial/Parallel/G1/Shenandoah/ZGC. + +**But this does not make the GC-triggered callback itself usable as the execution vehicle +for a pass.** The "functions which specifically allow such use" are exactly two: +`Allocate` and `Deallocate` (the entire **Memory Management** category). `SetTag`, +`GetTag`, `GetObjectsWithTags`, `FollowReferences`, and `IterateThroughHeap` are all in +the **Heap** category, which is *not* on that allowlist — calling any of them from inside +`GarbageCollectionStart`/`Finish` is exactly what the restriction forbids. The spec's own +prescribed escape hatch — notify a raw monitor from the callback, do the real work on a +separate agent thread — doesn't preserve "the pass rides the GC's own pause" property +either: the woken agent thread runs after the GC's collection pause has already ended +(mutators resumed), so calling `FollowReferences`/`IterateThroughHeap` there triggers a +**new**, separate safepoint of its own (per the corrected mechanism above) rather than +reusing the GC's. + +The only way to fold the tag/walk work into the GC's own STW window would be to bypass +the official JVMTI entry points and reach into HotSpot's internal `JvmtiTagMap` directly +via symbol-sniffing — reintroducing exactly the fragility class already rejected for +Approach C (unversioned internal C++ state, no stable ABI). Doing that here would undo +the reason C was rejected. + +**Conclusion: "no new marginal safepoints" is not achievable while staying within +official JVMTI usage.** Each pass still triggers its own safepoint — transparently, via +whichever agent thread calls `FollowReferences`/`IterateThroughHeap` for that pass, not +via anything the profiler schedules itself. The GC callbacks remain useful only as a +low-cost *signal* ("a GC just happened, a pass may be worth running soon") — not as the +execution vehicle for the pass itself. This does not change the core incremental design +(frontier persistence via JVMTI tags, self-pruning of dead branches, per-pass budget) — +it only removes the "zero marginal safepoints" claim from the cost/benefit case. The +design's actual value remains what it was framed as: trading one long pause for several +short, independently-triggered ones — a latency-distribution improvement, not a +total-STW reduction. + +### Termination and abandonment + +Because passes are spread across a mutating heap, a search that never reaches a root or +the hop cap could otherwise persist indefinitely, accumulating abandoned frontier state +across GC cycles. Required cutoffs: + +- Hop cap (as in Approach B's single-pass form). +- A hard cap on passes-per-search or wall-clock TTL from first observation (value TBD — + see Open Question 2). +- Explicit reporting of abandoned searches (no silent truncation) so this shows up as a + measurable "chain not found within budget" outcome rather than being indistinguishable + from "no chain exists." +- Tag release: on abandonment or completion, every JVMTI tag this search assigned to + frontier/`EdgeStore` objects (`SetTag(obj, 0)`) must be cleared before the search's + state is discarded. Without this, an abandoned search leaves its tags in place + indefinitely, directly aggravating the tag-table sizing/contention risk raised in + Open Question 4. +- A hard cap on frontier size (record count or native-memory footprint). The hop cap and + pass/TTL cap bound how *long* a search runs, but not how *wide* the frontier can grow + within that time — a wide fan-out graph could accumulate an unbounded number of + `(tag, parent_tag, referrer_klass, depth)` records before either cutoff is hit. When + the cap is reached, stop admitting new frontier entries for that search and report it + as an abandoned/truncated search (value TBD — see Open Question 2). + +### Correctness note: chains are historical, not a single consistent snapshot + +A chain built across multiple passes stitches together `"A referenced B"` facts observed +at different points in time, not one frozen graph. For the stated purpose — explaining, +by referrer type, what typically retains this class of surviving object — this is +sufficient, and is not meaningfully weaker than a single-pass walk: GC roots (e.g. thread +stack frames) are themselves a live-changing set across a single pause's boundary, so +"one true snapshot" is already an approximation in the single-pass case. Any +documentation or output surface built on this must describe results as an **observed** +retaining path, not a claim about the object's current exact retention state. + +### Cost/benefit summary + +- **Does not reduce total STW time.** Each safepoint/callback entry pays fixed + synchronization overhead; K short increments likely sum to equal or *more* aggregate + pause time than one contiguous walk covering the same work. +- **Improves latency distribution.** No single long tail pause — the thing most likely to + actually affect deployed application health (p99 latency, heartbeat timeouts), even + when total accumulated pause-ms is flat or slightly worse. + +## Non-goals + +- Exhaustive paths to all GC roots. +- Field-level or object-identity-level chains (referrer *type* only). +- Any GC-internal-closure hook (Approach C) or internal parallel-iteration API use as a + launch dependency. + +## Open questions before implementation + +1. ~~Confirm `GarbageCollectionStart`/`GarbageCollectionFinish` callback timing relative to + safepoint release.~~ **Resolved** (see Triggering section): the callback is genuinely + at a safepoint, but the JVMTI Heap-category functions needed to do frontier work + (`SetTag`/`GetTag`/`FollowReferences`/`IterateThroughHeap`) are not in the callback's + allowed function set. Each pass instead triggers its own safepoint transparently, via + whichever agent thread calls `FollowReferences`/`IterateThroughHeap` for that pass — + the profiler never constructs a `VM_Operation` itself (see correction in Triggering + section). The "no new marginal safepoints" framing is dropped; the design's value is + latency distribution, not total-STW reduction. +2. Choose per-pass budget defaults (edge count vs. time slice), hop cap, the + passes-per-search/wall-clock TTL abandonment cutoff, and the frontier-size/memory cap + (see Termination and "Frontier metadata storage") — needs measurement against + representative heap shapes and per-hop fan-out, not a guess; `LivenessTracker`'s + flat-sample-rate sizing formula does not transfer to a graph-search frontier. + **Not resolved — provisional defaults only, no measurement has occurred.** The + implementation currently ships explicitly-labeled "provisional default pending Phase 5 + empirical tuning" constants (`arguments.h`: `DEFAULT_REFERENCE_CHAINS_HOP_CAP = 200`, + citing this doc's own JFR ~200-hop/100-100 precedent; `DEFAULT_REFERENCE_CHAINS_BUDGET + = 1000`; `DEFAULT_REFERENCE_CHAINS_TTL_MS = 60000`; `DEFAULT_REFERENCE_CHAINS_FRONTIER_CAP + = 65536`, sized as a fraction of `LivenessTracker::MAX_TRACKING_TABLE_SIZE` rather than + derived from any BFS-specific measurement; plus `referenceChains.h`'s + `FrontierTable::INITIAL_TABLE_CAPACITY = 1024` and + `ReferenceChainTracker::PASS_CADENCE_NS` = 1 s). These let the subsystem run and be + tested end-to-end, but none are backed by a benchmark against this codebase — do not + describe them as measured. The real resolution path is + `LiveHeapReferenceChains-BenchmarkPlan.md` (kept locally, not committed), + which specifies the JMH/async-profiler matrix and decision rule Phase 5 still needs to + execute; this question stays open until that plan is actually run. + + **Pause-time-SLO feedback loop — SHIPPED, reusing the existing `PidController`.** + Implemented in + `LiveHeapReferenceChains-RemainingWorkPlan.md` (kept locally, not committed)'s + Phase D (`ReferenceChainTracker::updatePacing()`, `referenceChains.cpp`). This does not + replace the hop/TTL/frontier-cap constants raised in the first half of this question — only + the per-pass edge-count budget and the pass cadence, per the shipped mechanism below. + - New config sub-option `referencechains=...:pausetarget=` (`arguments.cpp`'s + `CASE("referencechains")` parser, field `Arguments::_reference_chains_pause_target_ms`), + defaulting to `arguments.h`'s `DEFAULT_REFERENCE_CHAINS_PAUSE_TARGET_MS = 5` — explicitly + labeled provisional/un-benchmarked, the same way the other + `DEFAULT_REFERENCE_CHAINS_*` constants are. Choosing its real value is still a Phase-5-style + empirical question, not resolved by this mechanism landing. + - `ReferenceChainTracker::start()` (re)constructs its own `PidController` instance + (`_pause_pid`) targeting `_pause_target_ms`, with its own gain triple — + **not** `ObjectSampler`/`MallocTracer`'s shared, uncited P=31/I=511/D=3/cutoff=15s + (`NativeSocketSampler` uses its own `RateLimiter`, not this `PidController` triple, and + was never part of the shared instance). The caveat this question raised about that triple + being copy-pasted, not independently derived, still stands for those two; it was not + "resolved," just not repeated here. The new instance uses P=10/I=1/D=2/window=1/cutoff=5s + — smaller proportional gain than the shared triple because a pass-duration-ms error is + single/low-double-digit in magnitude, unlike the shared triple's event-count scale + (`referenceChains.cpp`'s `start()`, inline comment on each gain). Gain *convergence* is + verified by gtest (three `ReferenceChainsTest` cases: steady-state at the ceiling, over- + ceiling, under-ceiling — see Phase D's exit criteria below), not by a live benchmark + against representative heap shapes; that remains a + `LiveHeapReferenceChains-BenchmarkPlan.md` (kept locally, not committed) item, + not fully closed by this mechanism landing. + - Measurement point: `runPass()` (`referenceChains.cpp`) times its own root + `FollowReferences` call (first pass) or `expandFrontier()`'s + `GetObjectsWithTags`+`FollowReferences` pair (resumed pass) — already the thread blocked + inside the safepoint those calls trigger (Triggering section) — and converts to whole + milliseconds before feeding `_pause_pid.compute()` (matching every other `PidController` + caller in this codebase, which all feed integer counts). + - `updatePacing(u64 pass_wall_ns)` folds the budget-scaling and cadence-widening/relaxing + decisions into that one `compute()` call per pass: the signal is added to + `_effective_budget` (the *value* `runPass()` now passes to `expandFrontier()` instead of + the fixed `_budget`) and clamped to `[MIN_EFFECTIVE_BUDGET = 50, _budget + _borrowed_budget]` + — a later addition lets the ceiling temporarily borrow above `_budget` (up to + `BORROW_CEILING_MULTIPLIER`x) rather than capping hard at the config value; see the + rotation-budget-starvation fix in git history for why. Whatever the clamp could not + absorb (`overflow`) drives `_effective_cadence_ns` (see Open + Question 5 below for why cadence is folded into this same output rather than a second + controller): `CADENCE_NS_PER_EDGE_OVERFLOW = 1ms/edge` scales the unabsorbed overflow into + a nanosecond adjustment, widening `_effective_cadence_ns` toward + `MAX_EFFECTIVE_CADENCE_NS = 4s` when still over-ceiling even at the budget floor, relaxing + it toward `MIN_EFFECTIVE_CADENCE_NS = 10ms` when comfortably under-ceiling even at the + budget ceiling. + - The hop cap and the frontier-size hard cap (Termination section) are untouched by this + mechanism — they stay fixed correctness/memory-safety bounds, not controller-tuned, exactly + as this question originally specified. + - One known, deliberate scope limit carried over from the plan: `buildAbandonedEvent()`'s + `datadog.ReferenceChainAbandoned` event still reports the static config ceiling `_budget`, + not the adaptive `_effective_budget` — changing that event's semantics was out of Phase D's + stated scope. +3. Decide the sample-batching policy: one incremental search per live-heap sample, or + batched multi-target BFS sharing a single frontier walk (batching amortizes better but + couples unrelated samples' termination conditions together). + **Shipped, but not in either form this question anticipated.** The implemented + `ReferenceChainTracker::runPass()` (referenceChains.cpp) does not target any sample at + all: it runs a single, singleton-owned search that walks the whole root-reachable graph + (bounded by the hop/budget/frontier caps) with no per-sample seeding. Reconstructing a + chain for a specific tag is a separate, read-only step (`buildChainEvent(target_tag, ...)`) + applied after (or during) that one shared search - closer in spirit to "batched" (one + frontier walk can answer for many targets) than "one search per sample", but arrived at + by omission (the target-sample feed did not exist at that time, see the implementation + plan's Phase 7 report) rather than a deliberate batching design. + Whether this generalizes to true multi-target batching (explicit seeding from multiple + samples, coordinated termination) is still open and deferred, consistent with this + question's original framing. + + **Target-selection policy — SHIPPED (positive population-slope ranking).** Implemented in + `LiveHeapReferenceChains-RemainingWorkPlan.md` (kept locally, not committed)'s + Phases A-C. The missing piece above was *which* tag(s) `buildChainEvent()` should + reconstruct for. As shipped: per klass, `LivenessTracker` tracks a rolling window of its + live tracked-instance population count, sampled once per `LivenessTracker::cleanup_table()` + epoch advance (the same GC-epoch cadence that already recomputes survivor status, + `livenessTracker.cpp` — a *different*, slower cadence than `ReferenceChainTracker`'s own BFS pass cadence, Open + Question 5; "past N passes" below means GC epochs observed by `LivenessTracker`, not BFS + passes). A klass whose population trend over that window is positive — new instances + arriving faster than old ones are dying — is a leak candidate; a bounded cache/pool also + holds old objects but its population stabilizes or shrinks. Of all klasses with a + positive trend, seed only the top 3–5 by trend magnitude for the next BFS pass — this + doubles as the per-pass seeding cap this question's last bullet asks for, so no separate + budget constant is needed. + + Mechanics (as shipped): + - `LivenessTracker` resolves the class lazily at JFR-flush time (`flush_table()`) to keep + the allocation-sampling path free of a `GetObjectClass` call. Per-klass population is + computed by resolving the klass once per *surviving* entry inside the existing + `cleanup_table()` epoch-advance pass instead (`resolveKlassId()`, the same + `GetObjectClass` + `Class.getName()` + `Profiler::lookupClass()` sequence + `flush_table()` uses) — cost scales with live-table size × GC frequency, not with + allocation rate, so it does not touch the hot sampling path. This whole step is gated on + `_gc_generations` (`cleanup_table()`), so a plain liveness session pays none of it. + - A fixed-capacity table (`KlassPopulationEntry _klass_population[]`, + `MAX_KLASS_POPULATION_ENTRIES = 256`) keyed by klass `StringDictionary` id holds a + `KLASS_POPULATION_RING_SIZE = 30`-slot ring of per-epoch population counts plus one + `jweak` of a currently-live representative instance (minted fresh in + `foldKlassCountsLocked()`, deliberately *not* aliasing the source `TrackingEntry::ref`, + which `cleanup_table()` can reap out from under it). When full, the + least-recently-updated entry is evicted (`recordKlassPopulationSampleLocked()`), the + same fixed-capacity/LRU shape every other table in this design uses. Counts are + accumulated into a reused scratch array (`accumulateKlassCount()`) during the survivor + loop, then folded into the ring at the end of the pass. + - Trend/slope: computed by the shared `ringThirdsStats()` helper (`livenessTracker.cpp`) — + average (and minimum) of the earliest third of the filled window vs. the most recent third + (cheap, allocation-free, avoids full least-squares regression). Trend is trusted only once + the ring reaches a minimum fill (`KLASS_POPULATION_MIN_FILL_FOR_TREND = 10` samples) to + avoid noise right after a klass starts being tracked. A klass only qualifies as a leak + candidate once its growth (`hasQualifyingGrowth()`, which also checks a floor-rise bar to + reject oscillations whose peak passes a magnitude test but whose baseline never rises) has + held for `consecutive_positive` consecutive epochs at or above a hysteresis threshold — + `LEAK_TREND_HYSTERESIS_BASE = 5`, lowered to `LEAK_TREND_HYSTERESIS_CORROBORATED = 3` when + the aggregate post-GC heap floor is itself rising (`heapFloorRising()`, fed by a + lock-free, single-writer `_heap_floor_ring`). This closes the false-positive gap a + single-epoch positive-slope test had: a see-saw/oscillating population could trigger a + search without any real longer-term growth. The exact window size (up to 30) and the + "top 3–5" cutoff are starting points, not measured values — a separate tuning pass, not + folded into Open Question 2's pause-time work (this is a leak-detection sensitivity + tradeoff, not a safepoint-cost tradeoff). See + [reference-chains-collection-summary.md](../reference-chains-collection-summary.md) for + the full mechanism. + - `LivenessTracker::selectLeakCandidates(KlassCandidate *out, int max)` returns, on + demand under a shared lock, the positive-slope klasses ranked by magnitude descending, + capped at `min(max, MAX_LEAK_CANDIDATES = 5)` — this top-N cutoff *is* the per-pass + seeding cap, so no separate budget constant is needed. Each `KlassCandidate` carries the + klass id and its representative `jweak`. + - Known limitation, stated rather than solved: if population trends positive across + *many* klasses simultaneously, that's more likely heap-wide growth (warm-up, load + increase) than several independent leaks. The top-3–5 ranking limits how many candidates + get chased, but does not distinguish this case from true multi-leak; a future refinement. + - The leak *judgment* is retrospective (needs a full window of GC-epoch history to see the + trend) but the *reconstruction target* does not need to be the exact instance that built + up the trend — any currently-live tracked instance of the flagged klass is evidence of + the same leak. **The bridging step is a READ, not a `SetTag` write** (a correction to + this doc's original proposal, found while grounding + `LiveHeapReferenceChains-RemainingWorkPlan.md` (kept locally, not committed); + see its "Correction to the design doc's Open Question 3 mechanism"). Pre-`SetTag`ing a + candidate before the forward walk reached it would make `heapReferenceCallback()`'s + `*tag_ptr == 0` branch — the *only* branch that records `parent_tag`/`depth` — skip it, + yielding an empty/root chain. Instead `ReferenceChainTracker::pollWatchedTargets()` + resolves each candidate's `jweak`, and if `runPass()`'s whole-graph walk has already + tagged it (`getTag() > 0`, a pure read), calls `buildChainEvent(tag, ...)` and emits the + chain. A candidate still at tag 0 is retried on a later poll, since the whole-graph walk + eventually visits every root-reachable object (barring the hop/budget/frontier caps). No + new backward-walk primitive is needed; this reuses what the frontier table already + records. + - This couples two independently-flagged, independently-scheduled subsystems + (`referencechains=...` vs. `_record_liveness`/`_gc_generations`) that had no existing + relationship — `LivenessTracker` identifies objects via `jweak` and never calls JVMTI + `SetTag`/`GetTag`, while `ReferenceChainTracker` identifies objects purely via JVMTI tags + it assigns during its own traversal. `pollWatchedTargets()` bridging them is the one new + piece of machinery this adds; everything else reuses existing structures. + - **Resolved:** `referencechains=...` gets this target-seeding behavior only when liveness + tracking *and* `_gc_generations` are both enabled (`LivenessTracker::gcGenerationsEnabled()`, + checked in `pollWatchedTargets()`); otherwise it falls back to the whole-graph-only + behavior (no target seeding), which is the doc's originally-stated fallback. +4. ~~Decide whether the frontier should use JVMTI object tags at all, or adopt + `LivenessTracker`'s weak-ref + index-table pattern instead.~~ **Resolved** (see + "Frontier metadata storage"): tags stay, because `FollowReferences`/ + `IterateThroughHeap` need tagged objects to filter/report frontier membership and a + weak-ref table has no hook into that machinery — but the per-tag *metadata* + (`parent_tag`, `referrer_klass`, `depth`) should be stored in a + `LivenessTracker`-style slot table (tag value as index) rather than a bespoke + structure, reusing its proven `SpinLock`/CAS-allocation/resize code. Remaining open + item: the table-sizing formula, folded into Open Question 2. +5. Decide the actual pass-scheduling policy now that GC callbacks can only be a signal, + not a vehicle: e.g. a background agent thread woken by the GC-callback signal that + then calls `FollowReferences`/`IterateThroughHeap` for the next pass (paying its own + transparent safepoint), vs. a fixed-cadence timer independent of GC activity. Needs a + cost model for how many such safepoints per second are acceptable before this stops + being "more palatable" than one larger pause. + **A decision shipped, but not the cost-modeled one this question asks for.** + `ReferenceChainTracker::shouldRunPass()` (referenceChains.cpp) combines both candidates + rather than choosing between them: it triggers a pass when the GC-finish epoch has + advanced since the last pass, *or* a fixed `PASS_CADENCE_NS` (1 second, explicitly + labeled provisional in `referenceChains.h`) has elapsed, whichever comes first. No + safepoints-per-second/per-pause-duration measurement backs the 1-second cadence value - + it was chosen only so an idle search still makes progress without polling tightly. The + cost model this question actually asks for is still open, deferred to Phase 5's + benchmark plan (`LiveHeapReferenceChains-BenchmarkPlan.md` (kept locally, not committed)), + which has not been run. + + **SHIPPED — folded into Open Question 2's pause-time-SLO feedback loop, not solved + separately.** Implemented in the same `ReferenceChainTracker::updatePacing()` + (`referenceChains.cpp`, Phase D) described under Open Question 2: one `PidController` + `compute()` call per pass drives both that question's budget adjustment and this question's + cadence adjustment from the single measured per-pass safepoint duration, rather than two + independently-tuned mechanisms. `shouldRunPass()` and `threadLoop()` now compare against + `_effective_cadence_ns` in place of the fixed `PASS_CADENCE_NS` constant (which survives + only as `_effective_cadence_ns`'s starting value in `start()` and as the unit + `MAX_EFFECTIVE_CADENCE_NS` scales from); `threadLoop()`'s own sleep between iterations uses + `_effective_cadence_ns` too; so a controller-driven relaxed cadence actually shortens how + long an idle, no-GC-event search waits between passes, not just what the comparison in + `shouldRunPass()` reads. The GC-finish-epoch trigger in `shouldRunPass()` remains unconditional + on cadence, exactly as before — cadence only governs the fixed-interval fallback for an idle + search, per this question's original framing. See Open Question 2 above for the concrete + clamp/overflow mechanics (`MIN_EFFECTIVE_CADENCE_NS = 10ms`, `MAX_EFFECTIVE_CADENCE_NS = 4s`, + `CADENCE_NS_PER_EDGE_OVERFLOW`) and the gain-tuning caveat, which applies identically here + since it is the same controller instance. The cost-modeled "how many safepoints/sec is + acceptable" question this Open Question originally asked for is answered structurally (the + controller widens cadence exactly when passes are running long relative to the configured + ceiling) rather than by a specific measured number — that number is still a + `LiveHeapReferenceChains-BenchmarkPlan.md` (kept locally, not committed) item. diff --git a/doc/reference-chains-collection-summary.md b/doc/reference-chains-collection-summary.md new file mode 100644 index 0000000000..6a31c34f36 --- /dev/null +++ b/doc/reference-chains-collection-summary.md @@ -0,0 +1,66 @@ +# Reference Chain Collection: Design Summary + +## Problem + +Given a JVM heap with objects suspected of leaking (e.g., klasses whose live population grows monotonically across GC generations), reconstruct a **referrer chain** from a GC root down to a representative instance of the suspect klass — without pausing the JVM for longer than a small, bounded budget, and without assuming the entire heap graph can be walked in one pass. + +Three constraints drive the design: + +1. **Detecting *which* klasses are worth walking** must be near-free and based on survivorship trend, not raw allocation volume. +2. **The walk itself** (JVMTI `FollowReferences`) can be arbitrarily expensive on a large heap, so it must be interruptible and resumable. +3. **Total STW/JVMTI-callback time per pass** must stay under a small budget so the profiler doesn't visibly perturb the target application. + +--- + +## Component 1: Surviving-Generation Signal (`LivenessTracker`) + +Rather than triggering a heap walk on every allocation or every GC, the tracker maintains a **per-klass population history** and only nominates a klass as a "leak candidate" once it shows a **sustained positive trend across GC generations** — i.e., its live (surviving) instance count keeps growing generation over generation, not just spiking transiently. + +**Mechanics:** + +- Population sampling is driven off the existing allocation-sampling hot path (`track()`), but the actual **per-klass counts are only folded into history at `cleanup_table()`'s GC-epoch-advance pass** — i.e., once per GC, not once per allocation. This keeps the hot path allocation-free and cheap. +- Each klass gets a small **ring buffer of recent per-epoch surviving counts** (`KLASS_POPULATION_RING_SIZE = 30` samples). A ring, not an unbounded history, because we only care about recent trend, not lifetime totals. +- A klass's trend is only trusted once its ring has a **minimum fill (`KLASS_POPULATION_MIN_FILL_FOR_TREND = 10` samples)** — avoids false-positive trend detection on a klass that's simply new to being tracked (too few points to fit a slope to). +- `selectLeakCandidates()` computes a slope over each ring and returns the **top-N klasses by slope magnitude** (`MAX_LEAK_CANDIDATES = 5`), each paired with a live representative instance (a `jweak`) discovered during sampling — this weak reference is what seeds the walk in Component 2. +- A klass only qualifies once its growth (`hasQualifyingGrowth()`) has held for `consecutive_positive` epochs at or above a **hysteresis threshold** — `LEAK_TREND_HYSTERESIS_BASE = 5` by default, lowered to `LEAK_TREND_HYSTERESIS_CORROBORATED = 3` when the aggregate post-GC heap floor is itself rising (`heapFloorRising()`, fed by a lock-free, single-writer `_heap_floor_ring` populated from `onGC()`). Because the aggregate heap-floor signal can't attribute growth to any one klass, it only ever raises or lowers the bar uniformly for the whole scan — it never reorders or singles out individual candidates. +- The whole table (`_klass_population`, up to `MAX_KLASS_POPULATION_ENTRIES = 256` entries) is a flat array scanned linearly — deliberately no index structure, since 256 entries is cheap to scan and this stays off the allocation hot path. +- Everything under this table (population array, size counter) is guarded by a single `SpinLock` (`_table_lock`) — the *same* lock `cleanup_table()` already holds for its epoch-advance pass, rather than adding a second lock. **Any code path that mutates this table (including test-only reset seams) must take that lock — mutating `_klass_population_size` or the array unguarded is a data race against the epoch-advance pass**, discovered in practice while hardening test seams. + +**Why this design:** it decouples "is this klass suspicious" (cheap, GC-cadence, statistical) from "reconstruct why it's suspicious" (expensive, JVMTI, on-demand) — the expensive walk only ever runs against klasses that have already earned a positive trend signal, not against every allocation site. + +--- + +## Component 2: Resumable Frontier Walk (`ReferenceChainTracker`) + +Once a klass is nominated, a **persistent background BFS thread** reconstructs a path from a GC root to a tagged instance of that klass, using JVMTI's `FollowReferences`/heap-tag mechanism — but broken into many small, budgeted passes rather than one unbounded walk. + +**Mechanics:** + +- The tracker is a **process-wide singleton** with its own thread (`threadLoop()`), woken on a fixed cadence (`effectiveCadenceNs`) rather than synchronously from allocation or GC callbacks — decouples walk progress from the rate of GC/allocation events. +- `runPass()` dispatches on `_search_started`: + - **First pass for a search**: does a real root-seeded `FollowReferences(0, nullptr, nullptr, ...)` call — full JVMTI heap-root walk, tagging objects as it goes. + - **Every subsequent pass**: calls `expandFrontier()`, which resumes from a **persisted frontier** (the previous pass's boundary tags) instead of re-walking from roots. This is the resumability mechanism: each pass advances the frontier outward by one bounded increment and stops. +- Each pass is capped by an **edge-admission budget** (`effectiveBudget`, e.g. `edges_admitted` capped at a configured value like 4000/200000/500 depending on test config) — `expandFrontier()`'s nested loops (`while (!ctx.truncated && progress)` outer, `for (jlong tag : candidate_tags)` inner) both check a truncation flag and bail out the moment the budget is exhausted, so a single pass's JVMTI-callback time is bounded regardless of heap size. +- **Cooperative abort**: an `std::atomic _abort_pass_requested` flag, checked inside `heapReferenceCallback()` (the JVMTI callback invoked per edge), lets `stopThread()` interrupt an **in-flight** walk promptly — set before `pthread_kill(WAKEUP_SIGNAL)`/`pthread_join()`, cleared by `startThread()`. Without this, a `FollowReferences` call already in progress at JVM shutdown or profiler restart can't be interrupted, and `pthread_join()` blocks indefinitely (a real, previously-diagnosed shutdown hang). +- Search state is a small state machine: `RUNNING → {ABANDONED | COMPLETED}`. `RUNNING` can **restart itself** (fresh root walk) once a candidate's chain is found and its tags released, gated by `canAffordNewSearch()`'s **pacing budget** — self-throttling, not unconditional: a search won't restart back-to-back if it would blow the perturbation budget. Once a search reaches a terminal state (`ABANDONED`/`COMPLETED`) it stays there — restarts only happen from within `RUNNING`. +- A search is marked `ABANDONED` (with a reason code) if it runs out of frontier budget without completing — e.g. hitting a frontier-cap under a tiny configured budget. This is a deliberate, observable outcome, not a silent failure — surfaced so operators can distinguish "the walk gave up" from "the walk is still in progress." + +**Why this design:** treating the walk as a resumable state machine (persisted frontier + tags) rather than one atomic call means a heap graph of unbounded size never forces an unbounded pause — cost is amortized across many cheap passes, each individually bounded and individually abortable. + +--- + +## Component 3: Latency Budget Enforcement + +The system enforces its "don't perturb the app" guarantee at **three independent layers**, not just one: + +1. **Per-pass edge budget** (`effectiveBudget`) — caps JVMTI callback invocations per pass (Component 2). +2. **Pain budget** (`_pain_budget`/`_search_pain_ms`, spent via `_pain_budget.spend(...)`) — tracks cumulative walk cost against a wall-clock ceiling; used by `canAffordNewSearch()` to decide whether a new search/restart is affordable right now, not just whether the current pass fit its edge budget. This is what prevents "many cheap passes" from silently adding up to an expensive aggregate cost. +3. **Pass cadence** (`effectiveCadenceNs`) — the background thread only wakes and attempts a pass on a fixed cadence (plus GC-epoch-triggered wakeups), rather than continuously spinning, bounding CPU overhead between passes. + +Together these mean: a single pass is bounded (edge budget), a sequence of passes is bounded (pain budget), and idle overhead between passes is bounded (cadence) — the three layers target three different ways an unbounded-cost walk could otherwise leak into the target application's latency. + +--- + +## Output Path + +Once a candidate's chain is fully reconstructed, `pollWatchedTargets()` builds a chain event (`buildChainEvent()`), which is enqueued (`enqueueChainEvent()`) and later drained (`drainPendingChainEvents()`, called from `Profiler::dump()`, not from the BFS scheduling thread) into `Profiler::writeReferenceChain()` — ultimately surfaced as a `datadog.ReferenceChain` JFR event on the next `Profiler::dump()`. This keeps the expensive walk and the (comparatively cheap, already-existing) JFR-write path decoupled — the walk never blocks on JFR I/O, and JFR writes never trigger a walk. diff --git a/doc/reference-chains-design.md b/doc/reference-chains-design.md new file mode 100644 index 0000000000..059a181189 --- /dev/null +++ b/doc/reference-chains-design.md @@ -0,0 +1,259 @@ +# Reference Chains for Surviving Live Heap Samples + +**Status:** Implemented (see `doc/reference-chains-collection-summary.md` for the as-built design) +**Date:** 2026-07-07 +**Jira:** TBD + +## Goal + +For a subset of live-heap samples that survive past their allocation window, produce +a **reference chain** — a sequence of referrer *types* (not full field-level paths, not +necessarily to *all* GC roots) connecting the sampled instance back to *a* GC root. This +is diagnostic information ("what kind of object chain is keeping this alive"), not a +heap-dump-grade exact retainer analysis. + +## Constraints + +- Must run cheaply, with as short a safepoint / STW contribution as possible. +- Must work on stock vendor JDKs the agent attaches to — no forked/patched JVM builds. +- Exhaustive (all-roots, full-path) chains are explicitly **not** required; referrer-type-only, + bounded-depth, best-effort chains are acceptable. + +## Approaches considered + +Three approaches were evaluated; two are ruled out as launch requirements for concrete, +evidence-backed reasons. One sub-idea (Approach C's `ParallelObjectIterator` variant) is +explicitly kept open as a conditional future option; see its discussion below. + +| # | Approach | Completeness | Complexity | Feasibility | Status | +|---|---|---|---|---|---| +| A | Full JVMTI `FollowReferences` reverse-graph walk, piggybacked on an already-scheduled major GC | 4/5 | 4/5 | 2/5 | Rejected | +| B | Bounded BFS-from-roots with frontier pruning (JFR "leak profiler" technique, adapted) | 3/5 | 3/5\* | 4/5 | **Chosen** | +| C | Hook GC mark/copy closures (G1, ZGC) to record parent pointers inline during marking | 2/5 | 5/5 | 1/5 | Rejected | + +\* This 3/5 reflects only the single-pass BFS sketched at selection time. The "Chosen +design" section below replaces that sketch with an incremental, resumable BFS +(JVMTI-tag-based frontier persistence across GC cycles, a dedicated `VM_Operation` per +pass, GC-callback signaling, and explicit termination/tag-cleanup bookkeeping), which is +materially more complex than this score suggests — closer to 4/5 in implementation and +maintenance effort. The score is left unchanged above (it documents the state of the +comparison at decision time) rather than retroactively edited. + +### A — Full reverse-reachability walk (rejected) + +Safepoint length scales with live-set size regardless of how the walk is triggered. +Modern regionalized collectors (G1, Shenandoah) rarely perform a true full-heap walk +during ordinary major GCs, so "ride an already-paid pause" is not a reliable amortization +strategy. Cost is fundamentally at odds with the "short safepoint" constraint. + +### B — Bounded BFS-from-roots (chosen) + +Mirrors OpenJDK's own `jdk.OldObjectSample` leak-profiler implementation +(`src/hotspot/share/jfr/leakprofiler/chains/{edgeStore,bfsClosure,dfsClosure}.cpp`): +a `VM_Operation`-driven BFS from GC roots, retaining only edges on the frontier toward a +small, fixed sample set, with a hard hop cap (HotSpot itself caps chains at ~200 hops, +split 100/100 from leaf and from root). We can go cheaper than JFR because only the +**referrer class**, not object identity or field name, is needed — the `EdgeStore` +degenerates to `(referrer_klass, parent_ref, depth)` records, where `parent_ref` links +each record back to the record that discovered it, enabling chain reconstruction. + +Adopting this pattern is a re-scoping of proven, shipping HotSpot code, not a novel +algorithm design. + +### C — GC mark/copy closure piggyback (rejected) + +Investigated specifically for G1 and ZGC on the premise that per-edge referrer +information is already available inside the collector's own marking/evacuation closures +(`G1ParCopyClosure::do_oop_work`, ZGC's `ZMarkConcurrentRootsIteratorClosure` / +load-barrier closures), so recording it would cost nothing beyond what the GC already +pays. + +Rejected because there is no stable, externally reachable hook into these closures: + +- They are internal, template-instantiated C++ classes compiled into `libjvm.so` at + HotSpot build time — not a registrable/pluggable extension point. +- This differs categorically from `VMStructs`-style introspection already used in this + codebase (`ddprof-lib/src/main/cpp/hotspot/vmStructs.cpp`), which reads VM state + passively via an officially exported offset table. Intercepting a GC closure's + *behavior* would require either shipping a patched OpenJDK build (a fork/maintenance + commitment far beyond anything in this codebase) or binary-patching unversioned, + per-build-mangled function addresses — not shippable across JDK point releases. + +A related idea — using HotSpot's internal `ParallelObjectIterator` +(landed via [JDK-8322043](https://www.mail-archive.com/serviceability-dev@openjdk.org/msg12977.html), +used by `VM_HeapDumper` to partition heap regions across GC worker threads for parallel +heap dumping) to shrink Approach B's safepoint by parallelizing the walk — was also +investigated. Same verdict: it is an internal C++ class, not exposed via JVMTI, with no +stable ABI for an attached agent to call. Symbol-sniffing internal HotSpot functions *is* +an established pattern in this codebase (`VMStructs::findHeapUsageFunc`, +`vmStructs.cpp:489-509`), but that precedent covers a single leaf virtual method with a +value/POD-ish return; `ParallelObjectIterator` is a multi-class subsystem that coordinates +the VM's own GC worker threads under safepoint control — an order of magnitude larger +fragility surface, with a much higher blast radius if a layout assumption is wrong (GC +worker-thread coordination corruption vs. a bad JMX stat). Not pursued as a launch +requirement; revisit only if Approach B's single-threaded pause proves to be a measured +bottleneck, and treat it as an isolated, heavily version/flag-gated fast path with +automatic fallback — never a dependency. + +## Chosen design: incremental, resumable bounded BFS + +A single-pass bounded BFS still means one pause sized to whatever budget is configured. +The refinement below spreads that budget across multiple short passes instead of one +contiguous one, trading a possibly-higher *aggregate* STW total for a much better +*latency distribution* — no single long tail pause. + +### Why the frontier can survive across passes: JVMTI object tags + +The obstacle to pausing and resuming a BFS is that the frontier (the worklist of +not-yet-expanded objects) is normally a set of raw addresses, and a moving/compacting GC +between passes can relocate or collect any of them. + +JVMTI object tags solve this: + +- Tags are identity-based and GC-move-transparent — a tagged object can be re-resolved + after a GC regardless of where it moved. +- Tags are **non-retaining** — tagging does not keep an object alive. This is the same + property the existing live-object sampler in this codebase already relies on, so this + is a new *use* of an existing mechanism, not new risk surface. +- Non-retention gives incremental resumption a useful side effect for free: if a frontier + object dies between passes, it simply fails to re-resolve on the next pass. That branch + of the search is pruned automatically, with no extra liveness bookkeeping required. + +### Data structures + +- **Frontier**: a set of `(tag, parent_tag, referrer_klass, depth)` records. `tag` is the + JVMTI tag assigned to a not-yet-expanded object; `parent_tag` links back for chain + reconstruction; `depth` supports the hop cap. +- **EdgeStore**: accumulates `(referrer_klass, parent_tag, depth)` per discovered edge for + objects that are on a path toward a target sample. Keyed by tag, not address — + degenerate relative to JFR's `EdgeStore` since object identity/field names are not + required, but it retains the same `parent_tag` linkage field as the Frontier so a chain + can be walked back from a target sample to a root by following `parent_tag` across + EdgeStore records. + +### Algorithm + +1. Seed the frontier from GC roots (first pass) or from the persisted frontier + (resumed pass). +2. Resolve currently-live tagged frontier objects. Objects that fail to resolve are + dropped (dead — free pruning). +3. Expand the frontier up to a fixed per-pass budget (edge count or time slice). +4. Newly discovered objects are tagged and added to the frontier for the next pass. +5. Persist the frontier (native memory owned by the agent, not thread-local scratch) and + return control to the VM. +6. Repeat until: a target sample is reached, the hop cap is hit, or a per-search + abandonment limit (see Termination) is exceeded. + +### Triggering passes: resolved — cannot avoid dedicated safepoints + +Investigated whether pass-continuation work could ride the JVMTI +`GarbageCollectionStart`/`GarbageCollectionFinish` callbacks — the same callback this +codebase already uses to call `_heap_usage_func` (`vmStructs.cpp`) — instead of +scheduling a dedicated `VM_Operation` per pass. + +**Confirmed the VM is genuinely at a safepoint (all mutators stopped) for the full +duration of both callbacks**, on every collector: + +- JVMTI spec: *"This event is sent while the VM is still stopped... the event handler + must not use JNI functions and must not use JVM TI functions except those which + specifically allow such use (see the raw monitor, memory management, and environment + local storage functions)."* +- openjdk/jdk source: delivery is synchronous on the VMThread + (`src/hotspot/share/prims/jvmtiExport.cpp:2752-2790`, comment *"this event is posted + from VM-Thread"*); every call site is inside a safepoint-executing `VM_Operation::doit()`, + backed by explicit asserts — e.g. Parallel GC's + `assert(SafepointSynchronize::is_at_safepoint())` (`gc/parallel/psScavenge.cpp:305-306`), + G1's `assert_at_safepoint_on_vm_thread()` (`gc/g1/g1VMOperations.cpp:141-157`), + Shenandoah and ZGC wrapping the same `SvcGCMarker` only inside their respective + `VM_Operation`/`VM_ZOperation::doit()` paths. Stable JDK 11 → mainline, across + Serial/Parallel/G1/Shenandoah/ZGC. + +**But this does not make the callback usable as the execution vehicle for a pass.** The +"functions which specifically allow such use" are exactly two: `Allocate` and +`Deallocate` (the entire **Memory Management** category). `SetTag`, `GetTag`, +`GetObjectsWithTags`, `FollowReferences`, and `IterateThroughHeap` are all in the +**Heap** category, which is *not* on that allowlist — calling any of them from inside +`GarbageCollectionStart`/`Finish` is exactly what the restriction forbids. The spec's own +prescribed escape hatch — notify a raw monitor from the callback, do the real work on a +separate agent thread — doesn't preserve the "rides the pause" property either: by the +time the woken agent thread runs, `VM_Operation::doit()` has already returned and the +safepoint has been released, so the tagging/walk work ends up running concurrently with +resumed mutators, not during the STW window. + +The only way to do the tag/walk work *while actually inside* the callback's STW window +would be to bypass the official JVMTI entry points and reach into HotSpot's internal +`JvmtiTagMap` directly via symbol-sniffing — reintroducing exactly the fragility class +already rejected for Approach C (unversioned internal C++ state, no stable ABI). Doing +that here would undo the reason C was rejected. + +**Conclusion: "no new marginal safepoints" is not achievable while staying within +official JVMTI usage.** Each pass needs its own dedicated, budget-capped `VM_Operation`. +The GC callbacks remain useful only as a low-cost *signal* ("a GC just happened, a pass +may be worth scheduling soon") — not as the execution vehicle for the pass itself. This +does not change the core incremental design (frontier persistence via JVMTI tags, +self-pruning of dead branches, per-pass budget) — it only removes the "zero marginal +safepoints" claim from the cost/benefit case. The design's actual value remains what it +was framed as: trading one long pause for several short, independently-scheduled ones — +a latency-distribution improvement, not a total-STW reduction. + +### Termination and abandonment + +Because passes are spread across a mutating heap, a search that never reaches a root or +the hop cap could otherwise persist indefinitely, accumulating abandoned frontier state +across GC cycles. Required cutoffs: + +- Hop cap (as in Approach B's single-pass form). +- A hard cap on passes-per-search or wall-clock TTL from first observation. +- Explicit reporting of abandoned searches (no silent truncation) so this shows up as a + measurable "chain not found within budget" outcome rather than being indistinguishable + from "no chain exists." + +### Correctness note: chains are historical, not a single consistent snapshot + +A chain built across multiple passes stitches together `"A referenced B"` facts observed +at different points in time, not one frozen graph. For the stated purpose — explaining, +by referrer type, what typically retains this class of surviving object — this is +sufficient, and is not meaningfully weaker than a single-pass walk: GC roots (e.g. thread +stack frames) are themselves a live-changing set across a single pause's boundary, so +"one true snapshot" is already an approximation in the single-pass case. Any +documentation or output surface built on this must describe results as an **observed** +retaining path, not a claim about the object's current exact retention state. + +### Cost/benefit summary + +- **Does not reduce total STW time.** Each safepoint/callback entry pays fixed + synchronization overhead; K short increments likely sum to equal or *more* aggregate + pause time than one contiguous walk covering the same work. +- **Improves latency distribution.** No single long tail pause — the thing most likely to + actually affect deployed application health (p99 latency, heartbeat timeouts), even + when total accumulated pause-ms is flat or slightly worse. + +## Non-goals + +- Exhaustive paths to all GC roots. +- Field-level or object-identity-level chains (referrer *type* only). +- Any GC-internal-closure hook (Approach C) or internal parallel-iteration API use as a + launch dependency. + +## Open questions before implementation + +1. ~~Confirm `GarbageCollectionStart`/`GarbageCollectionFinish` callback timing relative to + safepoint release.~~ **Resolved** (see Triggering section): the callback is genuinely + at a safepoint, but the JVMTI Heap-category functions needed to do frontier work + (`SetTag`/`GetTag`/`FollowReferences`/`IterateThroughHeap`) are not in the callback's + allowed function set, so each pass still needs its own dedicated `VM_Operation`. The + "no new marginal safepoints" framing is dropped; the design's value is latency + distribution, not total-STW reduction. +2. Choose per-pass budget defaults (edge count vs. time slice) and hop cap — needs + measurement against representative heap shapes, not a guess. +3. Decide the sample-batching policy: one incremental search per live-heap sample, or + batched multi-target BFS sharing a single frontier walk (batching amortizes better but + couples unrelated samples' termination conditions together). +4. Decide behavior when JVMTI tagging is already saturated by the existing live-object + sampler (tag-table sizing/contention) — this reuses infrastructure that has other + consumers in this codebase. +5. Decide the actual pass-scheduling policy now that GC callbacks can only be a signal, + not a vehicle: e.g. a background thread woken by the GC-callback signal that then + requests its own bounded `VM_Operation`, vs. a fixed-cadence timer independent of GC + activity. Needs a cost model for how many dedicated small safepoints per second are + acceptable before this stops being "more palatable" than one larger pause. From dd0447aaabecddaf98e94e4c09405198b6a65031 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Thu, 6 Aug 2026 11:59:54 +0200 Subject: [PATCH 6/7] Address reference-chains review findings Reset class-count counters in restartSearch(), fix null-base pointer UB in symbols_linux.cpp, gate JNI ref-minting in foldKlassCountsLocked() on allow_resolve, add totalHops to the ReferenceChain JFR event to avoid silent truncation, make LivenessTracker::_gc_generations atomic, widen per-klass population counters to u32, restore deleted FrameType tests, and correct stale root-discovery comments/docs. Co-Authored-By: Claude Sonnet 5 --- ddprof-lib/src/main/cpp/flightRecorder.cpp | 6 +++- ddprof-lib/src/main/cpp/jfrMetadata.cpp | 2 ++ ddprof-lib/src/main/cpp/livenessTracker.cpp | 33 ++++++++++++------ ddprof-lib/src/main/cpp/livenessTracker.h | 37 ++++++++++++++++----- ddprof-lib/src/main/cpp/referenceChains.cpp | 6 ++++ ddprof-lib/src/main/cpp/referenceChains.h | 21 ++++++------ ddprof-lib/src/main/cpp/symbols_linux.cpp | 4 ++- ddprof-lib/src/test/cpp/frame_ut.cpp | 16 +++++++++ doc/architecture/LiveHeapReferenceChains.md | 2 +- doc/reference-chains-collection-summary.md | 2 +- 10 files changed, 96 insertions(+), 33 deletions(-) diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index 3b6276eefc..c0c3754246 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -2180,7 +2180,7 @@ void Recording::recordReferenceChain(Buffer *buf, ReferenceChainEvent *event) { buf, RECORDING_BUFFER_LIMIT - (MAX_VAR32_LENGTH /* multi-byte size prefix, below */ + 3 * MAX_VAR64_LENGTH /* type id, start_time, target_tag */ + - 2 * MAX_VAR32_LENGTH /* depth, chain count */ + + 3 * MAX_VAR32_LENGTH /* depth, totalHops, chain count */ + 32 /* rootKind string */ + (int)emitted_size * MAX_VAR32_LENGTH)); // Multi-byte size prefix (like writeDatadogSetting() above), not @@ -2192,6 +2192,10 @@ void Recording::recordReferenceChain(Buffer *buf, ReferenceChainEvent *event) { buf->putVar64(event->_target_tag); buf->putVar32(event->_depth); buf->putUtf8(root_kind_name); + // Original (pre-truncation) chain length, so a consumer can tell a full + // chain (totalHops == chain.length) from a silently truncated one - + // mirrors ReferenceChainAbandonedEvent's "no silent truncation" design. + buf->putVar32(chain_size); // T_CLASS array field (F_CPOOL|F_ARRAY, jfrMetadata.cpp) - each entry is a // StringDictionary class id, same encoding as a scalar objectClass field // (e.g. recordAllocation() above), just repeated `count` times. diff --git a/ddprof-lib/src/main/cpp/jfrMetadata.cpp b/ddprof-lib/src/main/cpp/jfrMetadata.cpp index 33501dfafa..9b2b889610 100644 --- a/ddprof-lib/src/main/cpp/jfrMetadata.cpp +++ b/ddprof-lib/src/main/cpp/jfrMetadata.cpp @@ -212,6 +212,8 @@ void JfrMetadata::initialize( << field("targetTag", T_LONG, "Frontier Tag", F_UNSIGNED) << field("depth", T_INT, "Depth") << field("rootKind", T_STRING, "GC Root Kind") + << field("totalHops", T_INT, + "Total Chain Length Before Truncation") << field("chain", T_CLASS, "Referrer Chain (Leaf to Root)", F_CPOOL | F_ARRAY)) diff --git a/ddprof-lib/src/main/cpp/livenessTracker.cpp b/ddprof-lib/src/main/cpp/livenessTracker.cpp index c3ca047376..12e5186fc5 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.cpp +++ b/ddprof-lib/src/main/cpp/livenessTracker.cpp @@ -104,7 +104,7 @@ void LivenessTracker::cleanup_table(bool forced, bool allow_resolve) { u64 target_gc_epoch = load(_gc_epoch); TEST_LOG("LivenessTracker::cleanup_table forced=%d gc_generations=%d current_epoch=%llu " "target_epoch=%llu table_size=%d", - forced, _gc_generations, (unsigned long long)current, + forced, _gc_generations.load(std::memory_order_relaxed), (unsigned long long)current, (unsigned long long)target_gc_epoch, _table_size); // is_epoch_owner is true iff this call is the one that moves _last_gc_epoch @@ -173,7 +173,7 @@ void LivenessTracker::cleanup_table(bool forced, bool allow_resolve) { } _table[target].age += epoch_diff; - if (_gc_generations && is_epoch_owner) { + if (_gc_generations.load(std::memory_order_relaxed) && is_epoch_owner) { // Per-klass population tracking (design doc's Open Question 3) - // gated on _gc_generations so this new cost is paid only when the // caller actually asked for generation/survival-shaped data @@ -236,8 +236,9 @@ void LivenessTracker::cleanup_table(bool forced, bool allow_resolve) { TEST_LOG("LivenessTracker::cleanup_table survivors=%u klass_count_scratch_size=%d", newsz, _klass_count_scratch_size); - if (_gc_generations && is_epoch_owner && _klass_count_scratch_size > 0) { - foldKlassCountsLocked(env, target_gc_epoch); + if (_gc_generations.load(std::memory_order_relaxed) && is_epoch_owner && + _klass_count_scratch_size > 0) { + foldKlassCountsLocked(env, target_gc_epoch, allow_resolve); } end = OS::nanotime(); @@ -285,7 +286,7 @@ u32 LivenessTracker::resolveKlassId(JNIEnv *env, jobject ref) { void LivenessTracker::accumulateKlassCount(u32 klass_id, jweak sample_source) { for (int i = 0; i < _klass_count_scratch_size; i++) { if (_klass_count_scratch[i].klass_id == klass_id) { - if (_klass_count_scratch[i].count < UINT16_MAX) { + if (_klass_count_scratch[i].count < UINT32_MAX) { _klass_count_scratch[i].count++; } return; @@ -305,7 +306,7 @@ void LivenessTracker::accumulateKlassCount(u32 klass_id, jweak sample_source) { } jweak LivenessTracker::recordKlassPopulationSampleLocked(u32 klass_id, - u16 count, + u32 count, u64 epoch, int *out_slot, bool *out_created) { @@ -375,7 +376,8 @@ jweak LivenessTracker::recordKlassPopulationSampleLocked(u32 klass_id, return evicted_ref; } -void LivenessTracker::foldKlassCountsLocked(JNIEnv *env, u64 epoch) { +void LivenessTracker::foldKlassCountsLocked(JNIEnv *env, u64 epoch, + bool allow_resolve) { TEST_LOG("LivenessTracker::foldKlassCountsLocked epoch=%llu scratch_size=%d", (unsigned long long)epoch, _klass_count_scratch_size); for (int i = 0; i < _klass_count_scratch_size; i++) { @@ -389,6 +391,17 @@ void LivenessTracker::foldKlassCountsLocked(JNIEnv *env, u64 epoch) { if (evicted != nullptr) { env->DeleteWeakGlobalRef(evicted); } + if (!allow_resolve) { + // track()'s table-overflow branch calls cleanup_table(true, false) + // synchronously from the JVMTI SampledObjectAlloc callback stack - the + // same reason resolveKlassId() is skipped there (cleanup_table()'s own + // comment above). The representative-minting NewLocalRef/ + // NewWeakGlobalRef/DeleteLocalRef churn below is no Java-bytecode + // upcall, but it is still avoidable JNI work on that hot path; leaving + // the representative unset here is safe because the retry condition + // right below picks it up again on the next allow_resolve=true sweep. + continue; + } // Also retry minting when an existing entry's representative is stale: // either the field itself is still nullptr (a klass whose first-epoch // sample_source died in the brief window between cleanup_table()'s @@ -782,7 +795,7 @@ Error LivenessTracker::initialize(Arguments &args) { // reason: each profiler start should observe the flag it was actually // started with, even though the tracking table itself persists across // recordings. - _gc_generations = args._gc_generations; + _gc_generations.store(args._gc_generations, std::memory_order_relaxed); if (!_enabled) { return Error::OK; @@ -1000,7 +1013,7 @@ void LivenessTracker::track(JNIEnv *env, AllocEvent &event, jint tid, } void LivenessTracker::maybeForceCleanup(u64 now_ns) { - if (!_enabled || !_gc_generations) { + if (!_enabled || !_gc_generations.load(std::memory_order_relaxed)) { return; } constexpr u64 FORCE_CLEANUP_INTERVAL_NS = 30ULL * 1000 * 1000 * 1000; @@ -1052,7 +1065,7 @@ void LivenessTracker::onGC() { store(_used_after_last_gc, HeapUsage::get(false)._used); } - if (_gc_generations) { + if (_gc_generations.load(std::memory_order_relaxed)) { // Feeds heapFloorRising()'s corroboration check (selectLeakCandidates()) // - gated on _gc_generations, same as the per-klass population table // itself, since this ring exists purely to support that feature. diff --git a/ddprof-lib/src/main/cpp/livenessTracker.h b/ddprof-lib/src/main/cpp/livenessTracker.h index 97c7c6b414..ce0b311e2c 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.h +++ b/ddprof-lib/src/main/cpp/livenessTracker.h @@ -11,6 +11,7 @@ #include "engine.h" #include "event.h" #include "spinLock.h" +#include #include #include #include @@ -58,7 +59,11 @@ typedef struct KlassPopulationEntry { // foldKlassCountsLocked()'s comment for why aliasing // would leave a dangling handle once cleanup_table() // reaps the original TrackingEntry). - u16 count_ring[30]; // ring buffer of per-epoch live population counts + u32 count_ring[30]; // ring buffer of per-epoch live population counts. + // u32, not u16: a single klass's surviving count in + // one epoch can reach _table_max_cap + // (MAX_TRACKING_TABLE_SIZE = 262144), which u16 + // cannot represent losslessly. u8 ring_head; // next slot to write u8 ring_fill; // samples written so far, caps at 30 // Number of consecutive epochs (most recent first) for which @@ -230,8 +235,11 @@ class alignas(alignof(SpinLock)) LivenessTracker { // Question 3 by itself, but the plan built on top of this table requires // liveness tracking *and* _gc_generations, matching the doc's stated // fallback of "no target-seeding" when generations tracking isn't on - // (arguments.cpp:223-227,244). - bool _gc_generations; + // (arguments.cpp:223-227,244). std::atomic (relaxed) since initialize() + // writes it from the control thread while the BFS thread + // (maybeForceCleanup()) and the GC-callback thread (cleanup_table()) can + // still be reading it from a session that persists across a restart. + std::atomic _gc_generations; // Per-klass population history table (see KlassPopulationEntry above). // Populated only from cleanup_table()'s GC-epoch-advance pass, never from @@ -251,7 +259,7 @@ class alignas(alignof(SpinLock)) LivenessTracker { // end of the pass. typedef struct KlassCountScratch { u32 klass_id; - u16 count; + u32 count; // matches count_ring's width - see that field's own comment. jweak sample_source; // the original TrackingEntry::ref of the first // surviving instance of this klass seen this // epoch; consulted only by foldKlassCountsLocked() @@ -364,7 +372,7 @@ class alignas(alignof(SpinLock)) LivenessTracker { // so the caller can DeleteWeakGlobalRef() it. // Precondition: _table_lock is held (by cleanup_table(), the only // production caller). - jweak recordKlassPopulationSampleLocked(u32 klass_id, u16 count, u64 epoch, + jweak recordKlassPopulationSampleLocked(u32 klass_id, u32 count, u64 epoch, int *out_slot, bool *out_created); // Drains _klass_count_scratch into _klass_population for the epoch that @@ -384,7 +392,14 @@ class alignas(alignof(SpinLock)) LivenessTracker { // dangling handle if it aliased the same jweak. Resets // _klass_count_scratch_size to 0 once drained. Called with _table_lock // held, at the end of cleanup_table()'s epoch-advance pass. - void foldKlassCountsLocked(JNIEnv *env, u64 epoch); + // allow_resolve mirrors resolveKlassId()'s own parameter (cleanup_table()'s + // header comment): when false, this runs synchronously on the JVMTI + // SampledObjectAlloc callback stack (track()'s table-overflow branch), so + // the representative-minting NewLocalRef/NewWeakGlobalRef/DeleteLocalRef + // churn below is skipped - the ring/count bookkeeping still happens, and a + // missing representative is retried on the next allow_resolve=true sweep + // (see the retry-condition comment in livenessTracker.cpp). + void foldKlassCountsLocked(JNIEnv *env, u64 epoch, bool allow_resolve); // --- Slope computation and candidate ranking (selectLeakCandidates() below) --- @@ -504,7 +519,9 @@ class alignas(alignof(SpinLock)) LivenessTracker { // calling selectLeakCandidates() entirely when the feature isn't in use, // rather than relying on that method's own "returns 0" fallback to make // the no-op cheap. Read-only; this accessor never toggles the flag. - bool gcGenerationsEnabled() const { return _gc_generations; } + bool gcGenerationsEnabled() const { + return _gc_generations.load(std::memory_order_relaxed); + } // Third trigger for cleanup_table(), alongside track()'s table-overflow // branch (forced) and flush_table()'s JFR-flush cadence (organic): those @@ -549,7 +566,7 @@ class alignas(alignof(SpinLock)) LivenessTracker { } return false; } - jweak klassPopulationRecordForTest(u32 klass_id, u16 count, u64 epoch, + jweak klassPopulationRecordForTest(u32 klass_id, u32 count, u64 epoch, int *out_slot, bool *out_created) { return recordKlassPopulationSampleLocked(klass_id, count, epoch, out_slot, out_created); @@ -618,7 +635,9 @@ class alignas(alignof(SpinLock)) LivenessTracker { // gcGenerationsEnabled()'s gate (e.g. referenceChains_ut.cpp's // pollWatchedTargets() tests) use this instead of standing up a full // initialize()/start() call. - void setGcGenerationsForTest(bool v) { _gc_generations = v; } + void setGcGenerationsForTest(bool v) { + _gc_generations.store(v, std::memory_order_relaxed); + } private: void getLiveTraceIds(std::unordered_set& out_buffer); diff --git a/ddprof-lib/src/main/cpp/referenceChains.cpp b/ddprof-lib/src/main/cpp/referenceChains.cpp index 763926464d..b0f65e03f6 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.cpp +++ b/ddprof-lib/src/main/cpp/referenceChains.cpp @@ -772,6 +772,12 @@ void ReferenceChainTracker::restartSearch() { _last_pass_gc_finish_epoch = 0; store(_last_pass_ns, (u64)0); store(_passes_run, 0); + // Reset back to their just-constructed values (0 / -1) like every other + // per-search field this method touches: resolveLoadedClasses() and + // admitStaticFieldRoots() must both run unconditionally on the restarted + // search's first pass, exactly as they do for a brand-new tracker. + _last_resolved_class_count = 0; + _last_static_field_class_count = -1; // _resolved_chains is intentionally left intact: a chain resolved by the // finishing search stays cached (and keeps being re-emitted on every dump) // across the restart, since it describes a sample that is still live. The diff --git a/ddprof-lib/src/main/cpp/referenceChains.h b/ddprof-lib/src/main/cpp/referenceChains.h index 8b42848cbd..c33611dc21 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.h +++ b/ddprof-lib/src/main/cpp/referenceChains.h @@ -33,19 +33,20 @@ // 2. JVMTI object tags round-trip a live object across a GC (SetTag/GetTag), // via the minimal tagObject()/getTag()/clearTag() helpers below. // The tag-indexed FrontierTable was added next, followed by the actual heap -// walk (runPass() calling jvmtiEnv::FollowReferences from the heap roots, -// heapReferenceCallback() populating FrontierTable subject to the hop -// cap/budget/frontier cap) - but that walk originally ran as a single, +// walk (runPass() calling jvmtiEnv::IterateOverReachableObjects to enumerate +// heap roots via heapRootCallback()/stackRefCallback(), populating +// FrontierTable subject to the hop cap/budget/frontier cap) - but that walk +// originally ran as a single, // non-resumable pass with no cross-pass persistence, no GC-epoch-driven // scheduling, and no tag release. This revision makes the search resumable // and terminating: -// - runPass() now distinguishes a search's first pass (seed -// FollowReferences from the heap roots, exactly as the original -// single-pass walk did) from a resumed pass (expandFrontier() below: -// resolve each not-yet-expanded frontier entry via GetObjectsWithTags - -// dead ones are pruned for free - then call FollowReferences with that -// object as initial_object to discover its own outgoing edges, -// continuing until the per-pass budget or the frontier cap is hit). +// - runPass() now distinguishes a search's first pass (IterateOverReachableObjects +// to enumerate heap roots, exactly as the original single-pass walk did) +// from a resumed pass (expandFrontier() below: resolve each +// not-yet-expanded frontier entry via GetObjectsWithTags - dead ones are +// pruned for free - then call FollowReferences with that object as +// initial_object to discover its own outgoing edges, continuing until +// the per-pass budget or the frontier cap is hit). // - The Termination section's cutoffs are enforced across passes: the hop // cap already carried over via FrontierEntry::depth; this adds a // wall-clock TTL cutoff (_ttl_ms, from first pass) and treats the diff --git a/ddprof-lib/src/main/cpp/symbols_linux.cpp b/ddprof-lib/src/main/cpp/symbols_linux.cpp index 8a15165600..0bb8d0c11e 100644 --- a/ddprof-lib/src/main/cpp/symbols_linux.cpp +++ b/ddprof-lib/src/main/cpp/symbols_linux.cpp @@ -606,7 +606,9 @@ void ElfParser::parseDynamicSection() { uint32_t nsyms = 0; const char* dyn_start = at(dynamic); - const char* dyn_end = dyn_start + dynamic->p_memsz; + // at(dynamic) is NULL when dynamic->p_vaddr == 0 - same null-base + // pointer-arithmetic UB as the other fixes in this file. + const char* dyn_end = (const char*)((uintptr_t)dyn_start + dynamic->p_memsz); for (ElfDyn* dyn = (ElfDyn*)dyn_start; dyn < (ElfDyn*)dyn_end; dyn++) { switch (dyn->d_tag) { case DT_SYMTAB: diff --git a/ddprof-lib/src/test/cpp/frame_ut.cpp b/ddprof-lib/src/test/cpp/frame_ut.cpp index c7aaa6b8b4..951db75fb8 100644 --- a/ddprof-lib/src/test/cpp/frame_ut.cpp +++ b/ddprof-lib/src/test/cpp/frame_ut.cpp @@ -79,6 +79,13 @@ TEST(FrameTypeEncodeTest, RawPointerBitNotSetByDefault) { EXPECT_EQ(encoded & (1 << 30), 0) << "rawPointer flag (bit 30) must not be set by default"; } +TEST(FrameTypeEncodeTest, EncodedValuesArePositive) { + for (int t = FRAME_INTERPRETED; t <= FRAME_TYPE_MAX; ++t) { + int encoded = FrameType::encode(t, 0); + EXPECT_GT(encoded, 0) << "encode() must return a positive value for type " << t; + } +} + // ---- decode ---------------------------------------------------------------- TEST(FrameTypeDecodeTest, DecodeZeroReturnsJitCompiled) { @@ -126,6 +133,15 @@ TEST(FrameTypeDecodeTest, RoundTripAllTypesNonZeroBci) { } } +TEST(FrameTypeDecodeTest, DecodedTypeIsInValidRange) { + for (int t = FRAME_INTERPRETED; t <= FRAME_TYPE_MAX; ++t) { + int encoded = FrameType::encode(t, 42); + FrameTypeId decoded = FrameType::decode(encoded); + EXPECT_GE(decoded, FRAME_INTERPRETED); + EXPECT_LE(decoded, FRAME_TYPE_MAX); + } +} + // ---- isRawPointer ---------------------------------------------------------- TEST(FrameTypeIsRawPointerTest, FalseForZero) { diff --git a/doc/architecture/LiveHeapReferenceChains.md b/doc/architecture/LiveHeapReferenceChains.md index 16611b22c6..5276da3f6b 100644 --- a/doc/architecture/LiveHeapReferenceChains.md +++ b/doc/architecture/LiveHeapReferenceChains.md @@ -432,7 +432,7 @@ retaining path, not a claim about the object's current exact retention state. `LiveHeapReferenceChains-BenchmarkPlan.md` (kept locally, not committed) item, not fully closed by this mechanism landing. - Measurement point: `runPass()` (`referenceChains.cpp`) times its own root - `FollowReferences` call (first pass) or `expandFrontier()`'s + `IterateOverReachableObjects` call (first pass) or `expandFrontier()`'s `GetObjectsWithTags`+`FollowReferences` pair (resumed pass) — already the thread blocked inside the safepoint those calls trigger (Triggering section) — and converts to whole milliseconds before feeding `_pause_pid.compute()` (matching every other `PidController` diff --git a/doc/reference-chains-collection-summary.md b/doc/reference-chains-collection-summary.md index 6a31c34f36..01b05059a3 100644 --- a/doc/reference-chains-collection-summary.md +++ b/doc/reference-chains-collection-summary.md @@ -38,7 +38,7 @@ Once a klass is nominated, a **persistent background BFS thread** reconstructs a - The tracker is a **process-wide singleton** with its own thread (`threadLoop()`), woken on a fixed cadence (`effectiveCadenceNs`) rather than synchronously from allocation or GC callbacks — decouples walk progress from the rate of GC/allocation events. - `runPass()` dispatches on `_search_started`: - - **First pass for a search**: does a real root-seeded `FollowReferences(0, nullptr, nullptr, ...)` call — full JVMTI heap-root walk, tagging objects as it goes. + - **First pass for a search**: enumerates heap roots via `IterateOverReachableObjects()` (`heapRootCallback()`/`stackRefCallback()`), tagging root-referenced objects as it goes. - **Every subsequent pass**: calls `expandFrontier()`, which resumes from a **persisted frontier** (the previous pass's boundary tags) instead of re-walking from roots. This is the resumability mechanism: each pass advances the frontier outward by one bounded increment and stops. - Each pass is capped by an **edge-admission budget** (`effectiveBudget`, e.g. `edges_admitted` capped at a configured value like 4000/200000/500 depending on test config) — `expandFrontier()`'s nested loops (`while (!ctx.truncated && progress)` outer, `for (jlong tag : candidate_tags)` inner) both check a truncation flag and bail out the moment the budget is exhausted, so a single pass's JVMTI-callback time is bounded regardless of heap size. - **Cooperative abort**: an `std::atomic _abort_pass_requested` flag, checked inside `heapReferenceCallback()` (the JVMTI callback invoked per edge), lets `stopThread()` interrupt an **in-flight** walk promptly — set before `pthread_kill(WAKEUP_SIGNAL)`/`pthread_join()`, cleared by `startThread()`. Without this, a `FollowReferences` call already in progress at JVM shutdown or profiler restart can't be interrupted, and `pthread_join()` blocks indefinitely (a real, previously-diagnosed shutdown hang). From da35063fa619d7324477e57e6fd259fb1274b238 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Thu, 6 Aug 2026 12:41:41 +0200 Subject: [PATCH 7/7] Migrate ReferenceChainTrackingTest to the JfrEvents API Fixes the CodeQL Autobuild failure: this test still used JMC's IItemCollection/IItem after AbstractProfilerTest's verifyEvents() switched to the jafar-backed JfrEvents API. Adds a JfrEvents overload of ReferenceChainAssertions.findMatchForClass alongside the existing IItemCollection one, which LeakingCacheScenario/ReferenceChainJfrParserTest still use unchanged. Co-Authored-By: Claude Sonnet 5 --- .../ReferenceChainAssertions.java | 74 +++++++++++++++++++ .../ReferenceChainTrackingTest.java | 51 +++++-------- 2 files changed, 91 insertions(+), 34 deletions(-) diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java index 87a923ab93..8edeb28d67 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java @@ -5,6 +5,8 @@ package com.datadoghq.profiler.referencechains; +import com.datadoghq.profiler.JfrEvent; +import com.datadoghq.profiler.JfrEvents; import org.openjdk.jmc.common.IMCType; import org.openjdk.jmc.common.item.IAccessorKey; import org.openjdk.jmc.common.item.IItem; @@ -16,6 +18,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; /** * Shared {@code datadog.ReferenceChain} JFR-parsing helpers, extracted out of @@ -23,6 +26,13 @@ * {@link LeakingCacheScenario} (run inside a genuinely separate child JVM by * {@code ExternalProcessReferenceChainTest}) can reuse the exact same JMC-accessor logic * rather than maintaining two copies. + * + *

    {@link #findMatchForClass(JfrEvents, Class)} is a separate, jafar-backed counterpart to + * {@link #findMatchForClass(IItemCollection, Class)} for {@code ReferenceChainTrackingTest}, which + * loads recordings via {@code AbstractProfilerTest}'s {@code JfrEvents}-returning helpers + * (jb/jfr-lightweight-query-api). {@link LeakingCacheScenario} and {@code ReferenceChainJfrParserTest} + * load recordings directly via JMC's {@code JfrLoaderToolkit} and keep using the + * {@code IItemCollection} overload unchanged. */ public final class ReferenceChainAssertions { private ReferenceChainAssertions() {} @@ -40,6 +50,19 @@ public static final class ChainMatch { } } + /** Result of {@link #findMatchForClass(JfrEvents, Class)}: one resolved chain event's fields. */ + public static final class JfrChainMatch { + public final List chain; + public final long targetTag; + public final int depth; + + JfrChainMatch(List chain, long targetTag, int depth) { + this.chain = chain; + this.targetTag = targetTag; + this.depth = depth; + } + } + /** * Scans {@code events} for a {@code datadog.ReferenceChain} item whose {@code chain[0]} is * {@code targetClass} specifically, ignoring any events for other klasses this same @@ -82,6 +105,57 @@ public static ChainMatch findMatchForClass(IItemCollection events, Class targ return null; } + /** + * jafar/{@code JfrEvents}-backed counterpart to {@link #findMatchForClass(IItemCollection, Class)} - + * see this class's own header comment for why these are two separate overloads rather than one. + */ + public static JfrChainMatch findMatchForClass(JfrEvents events, Class targetClass) { + if (events == null || !events.hasItems()) { + return null; + } + for (JfrEvent item : events) { + Object chainValue = item.get("chain"); + if (!(chainValue instanceof Object[])) { + throw new IllegalStateException( + "'chain' field resolved to " + chainValue + ", expected an array"); + } + Object[] rawChain = (Object[]) chainValue; + if (rawChain.length == 0 || !targetClass.getName().equals(classFullName(rawChain[0]))) { + continue; + } + List chain = new ArrayList<>(rawChain.length); + for (Object element : rawChain) { + chain.add(classFullName(element)); + } + long targetTag = item.getLong("targetTag", -1); + int depth = (int) item.getLong("depth", -1); + return new JfrChainMatch(chain, targetTag, depth); + } + return null; + } + + /** + * The full name (e.g. {@code java.lang.String}) of a resolved {@code chain[]} array element - + * mirrors {@code JfrEvent.getClassName(String)}'s own class-reference-map unwrapping, applied + * to an array element rather than a named field. + */ + @SuppressWarnings("unchecked") + private static String classFullName(Object element) { + if (!(element instanceof Map)) { + throw new IllegalStateException( + "chain[] element resolved to " + element + ", expected a class reference map"); + } + Object name = ((Map) element).get("name"); + String s; + if (name instanceof Map) { + Object v = ((Map) name).get("string"); + s = v != null ? v.toString() : null; + } else { + s = name != null ? name.toString() : null; + } + return s != null ? s.replace('/', '.') : null; + } + /** * Looks up a field's accessor by identifier rather than via {@code Attribute.attr(...)}: JMC's * v1 chunk parser (internal.parser.v1.ValueReaders.ArrayReader#getContentType()) registers diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java index 8289eb9094..136ea60096 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java @@ -7,6 +7,8 @@ import com.datadoghq.profiler.AbstractProfilerTest; import com.datadoghq.profiler.JavaProfiler; +import com.datadoghq.profiler.JfrEvent; +import com.datadoghq.profiler.JfrEvents; import com.datadoghq.profiler.Platform; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Order; @@ -14,12 +16,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; import org.junitpioneer.jupiter.RetryingTest; -import org.openjdk.jmc.common.IMCType; -import org.openjdk.jmc.common.item.IAttribute; -import org.openjdk.jmc.common.item.IItem; -import org.openjdk.jmc.common.item.IItemCollection; -import org.openjdk.jmc.common.item.IItemIterable; -import org.openjdk.jmc.common.item.IMemberAccessor; import java.nio.file.Files; import java.nio.file.Path; @@ -32,8 +28,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.openjdk.jmc.common.item.Attribute.attr; -import static org.openjdk.jmc.common.unit.UnitLookup.PLAIN_TEXT; /** * PROF-15341 (+ lifecycle-wiring follow-up, + the Remaining Work Plan's target-selection bridging, @@ -92,9 +86,6 @@ @Tag("slow") public class ReferenceChainTrackingTest extends AbstractProfilerTest { - private static final IAttribute SETTING_NAME = attr("name", "", "", PLAIN_TEXT); - private static final IAttribute SETTING_VALUE = attr("value", "", "", PLAIN_TEXT); - // Arbitrary, test-chosen klass ids for the debug-only population-seeding seams below (see // ReferenceChainTestSeamsTest's own comment: LivenessTracker's population table treats these as // opaque keys, so they need not resolve to any real class). Distinct per test/from @@ -224,19 +215,11 @@ protected boolean isPlatformSupported() { @RetryingTest(5) public void shouldExposeReferenceChainsSettingWhenEnabled() { stopProfiler(); - IItemCollection settings = verifyEvents("jdk.ActiveSetting"); + JfrEvents settings = verifyEvents("jdk.ActiveSetting"); boolean sawEnabledSetting = false; - for (IItemIterable iterable : settings) { - IMemberAccessor nameAccessor = SETTING_NAME.getAccessor(iterable.getType()); - IMemberAccessor valueAccessor = SETTING_VALUE.getAccessor(iterable.getType()); - if (nameAccessor == null || valueAccessor == null) { - continue; - } - for (IItem item : iterable) { - if ("enabled".equals(nameAccessor.getMember(item)) - && "true".equals(valueAccessor.getMember(item))) { - sawEnabledSetting = true; - } + for (JfrEvent item : settings) { + if ("enabled".equals(item.getString("name")) && "true".equals(item.getString("value"))) { + sawEnabledSetting = true; } } assertTrue(sawEnabledSetting, "datadog.ReferenceChain#enabled setting was not found"); @@ -334,7 +317,7 @@ public void shouldReconstructReferrerChainToGcRoot() throws Exception { // flag as leak candidates - this test's own assertions below look for ChainLink specifically // among however many datadog.ReferenceChain events actually appear, rather than assuming it // is the only one. - ReferenceChainAssertions.ChainMatch match = null; + ReferenceChainAssertions.JfrChainMatch match = null; boolean seededTestKlassTrend = false; int totalRounds = 16; for (int round = 1; round <= totalRounds && match == null; round++) { @@ -436,7 +419,7 @@ public void shouldReconstructReferrerChainToGcRoot() throws Exception { // keep as its representative. Everything above chain[0] reflects real JDK-internal // collection representation (e.g. ArrayList's backing array) rather than anything this test // controls, so it is deliberately not asserted beyond "at least one hop was reconstructed". - assertEquals(ChainLink.class.getName(), match.chain.get(0).getFullName()); + assertEquals(ChainLink.class.getName(), match.chain.get(0)); assertTrue(match.targetTag > 0, "targetTag should be a valid, non-zero JVMTI tag"); assertTrue(match.depth >= 0, "depth should be a non-negative hop count"); assertTrue(!gcRootHolder.isEmpty()); // keeps every allocated ChainLink reachable until here @@ -515,7 +498,7 @@ public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exc // floor rather than this test's own "memory=64" request, why totalRounds is capped at // 16 rather than a larger margin above the 10-round minimum (shared-fork heap headroom), // and why per-round growth itself is clamped to round 10 (Math.min(round, 10) below). - ReferenceChainAssertions.ChainMatch match = null; + ReferenceChainAssertions.JfrChainMatch match = null; boolean seededTestKlassTrend = false; int totalRounds = 16; @@ -559,7 +542,7 @@ public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exc nextKey += newEntries; System.gc(); dump(scratchDumpPath); - IItemCollection events1 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); + JfrEvents events1 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); match = ReferenceChainAssertions.findMatchForClass(events1, CachedPayload.class); if (match == null && "debug".equals(System.getProperty("ddprof_test.config"))) { @@ -581,7 +564,7 @@ public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exc JavaProfiler.setKlassPopulationRepresentativeForTest0(CACHED_PAYLOAD_TEST_KLASS_ID, cache.get(keys[0])); JavaProfiler.pollReferenceChainTargets0(); dump(scratchDumpPath); - IItemCollection events2 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); + JfrEvents events2 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); match = ReferenceChainAssertions.findMatchForClass(events2, CachedPayload.class); } @@ -591,7 +574,7 @@ public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exc // slot against Profiler::dump()'s own exclusive hold. Thread.sleep(300); dump(scratchDumpPath); - IItemCollection events3 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); + JfrEvents events3 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); match = ReferenceChainAssertions.findMatchForClass(events3, CachedPayload.class); } } @@ -599,7 +582,7 @@ public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exc for (int attempt = 0; match == null && attempt < 5; attempt++) { Thread.sleep(1000); dump(scratchDumpPath); - IItemCollection events4 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); + JfrEvents events4 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); match = ReferenceChainAssertions.findMatchForClass(events4, CachedPayload.class); } @@ -607,7 +590,7 @@ public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exc "Never observed a datadog.ReferenceChain event whose chain[0] is " + CachedPayload.class + " after " + cache.size() + " cached entries across up to " + totalRounds + " population-growth rounds plus a grace period"); - assertEquals(CachedPayload.class.getName(), match.chain.get(0).getFullName()); + assertEquals(CachedPayload.class.getName(), match.chain.get(0)); assertTrue(match.targetTag > 0, "targetTag should be a valid, non-zero JVMTI tag"); assertTrue(match.depth >= 0, "depth should be a non-negative hop count"); @@ -615,8 +598,8 @@ public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exc // actually passed through the cache's own internal storage, not some other, coincidental // retainer - cache is the only thing keeping any CachedPayload instance reachable. boolean sawHashMapInternals = false; - for (IMCType type : match.chain) { - if (type.getFullName().startsWith("java.util.HashMap")) { + for (String type : match.chain) { + if (type.startsWith("java.util.HashMap")) { sawHashMapInternals = true; break; } @@ -685,7 +668,7 @@ public void shouldReportAbandonedSearchOnTinyFrontierCap() throws Exception { Path dumpPath = Paths.get("referencechains-abandoned-test.jfr"); try { dump(dumpPath); - IItemCollection abandoned = verifyEvents(dumpPath, "datadog.ReferenceChainAbandoned", true); + JfrEvents abandoned = verifyEvents(dumpPath, "datadog.ReferenceChainAbandoned", true); assertTrue(abandoned.hasItems(), "Expected at least one datadog.ReferenceChainAbandoned event"); } finally { Files.deleteIfExists(dumpPath);