diff --git a/.gitignore b/.gitignore index a8105a0a53..1300051698 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,10 @@ bin project.properties out +# Un-ignore the wrapper script shipped in the distribution +!shark/shark-cli/src/dist/bin/ +!shark/shark-cli/src/dist/bin/shark-ai-investigate + # Finder .DS_Store @@ -42,4 +46,7 @@ site docs/api # Python virtual env -venv \ No newline at end of file +venv + +# Claude Code +.claude \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 375e99403f..5e2b7b2b40 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,7 @@ buildscript { dependencies { classpath(libs.gradlePlugin.android) classpath(libs.gradlePlugin.kotlin) + classpath(libs.gradlePlugin.kotlinSerialization) classpath(libs.gradlePlugin.dokka) classpath(libs.gradlePlugin.mavenPublish) classpath(libs.gradlePlugin.detekt) diff --git a/docs/changelog.md b/docs/changelog.md index be1d862610..794d2b953b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,16 @@ Please thank our [contributors](https://github.com/square/leakcanary/graphs/contributors) 🙏 🙏 🙏. +## Unreleased + +`shark-cli` gains a new **AI-driven leak investigation skill**: run `shark-ai-investigate --hprof ` to load a heap dump and start a session. A shell wrapper prints a session shortcode, then starts the `ai-investigate` daemon in the background. An AI agent (or a human) can then send commands to the daemon via `ai-investigate-cmd ` — all over named pipes. Commands include `trace` (leak traces as structured JSON), `fields` / `string` / `array` (inspect objects), `mark-leaking` / `mark-not-leaking` (supply context), `retained-size` (memory retained by an object), and `human-readable-trace` (plain-text summary). The skill embeds a full algorithm guide in its `--help` output so an AI agent can drive the investigation autonomously. + +New in `shark`: + +* **`SingleObjectRetainedSizeCalculator`**: computes the retained size of a single heap object (the total memory freed if it were GC'd) using the provably-correct exclude-and-reach two-BFS algorithm. +* **`HeapClass.readInstanceFieldCount()`**: reads the declared instance field count as a single unsigned short from the class fields buffer, without allocating field records. +* **`ShallowSizeCalculator` accuracy improvements**: array objects now include the 12–16 byte ART object header that HPROF omits from array records; class object size now uses static field value sizes plus `ArtField` metadata (16 B × field count in `LinearAlloc`) instead of the raw HPROF record size. + ## Version 3.0 Alpha 8 (2024-06-04) * Added support for proper hprof handling on heap growth detection failures. Also inlined some public collaborators to achieve that. We now have a single class that's a bit larger but also a lot more obvious. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e892c236e..793eaf53b0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ androidCompileSdk = "34" [libraries] gradlePlugin-android = { module = "com.android.tools.build:gradle", version = "8.0.0" } gradlePlugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +gradlePlugin-kotlinSerialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } gradlePlugin-dokka = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version = "1.8.10" } gradlePlugin-binaryCompatibility = { module = "org.jetbrains.kotlinx:binary-compatibility-validator", version = "0.13.1" } gradlePlugin-mavenPublish = { module = "com.vanniktech:gradle-maven-publish-plugin", version = "0.25.2" } @@ -36,6 +37,7 @@ gradlePlugin-keeper = { module = "com.slack.keeper:keeper", version = "0.7.0" } gradlePlugin-sqldelight = { module = "app.cash.sqldelight:gradle-plugin", version = "2.0.0-alpha05" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.5.1" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } diff --git a/shark-ai-investigate.sh b/shark-ai-investigate.sh new file mode 100755 index 0000000000..4b412474f6 --- /dev/null +++ b/shark-ai-investigate.sh @@ -0,0 +1,2 @@ +./gradlew --quiet --no-configuration-cache :shark:shark-cli:installDist +./shark/shark-cli/build/install/shark-cli/bin/shark-ai-investigate "$@" diff --git a/shark/shark-android/src/test/java/shark/HprofRetainedHeapPerfTest.kt b/shark/shark-android/src/test/java/shark/HprofRetainedHeapPerfTest.kt index a746f32e26..6b1611b7e0 100644 --- a/shark/shark-android/src/test/java/shark/HprofRetainedHeapPerfTest.kt +++ b/shark/shark-android/src/test/java/shark/HprofRetainedHeapPerfTest.kt @@ -53,7 +53,7 @@ class HprofRetainedHeapPerfTest { val retained = analysisRetained - baselineHeap.retainedHeap(ANALYSIS_THREAD).first - assertThat(retained).isEqualTo(4.5 MB +-5 % margin) + assertThat(retained).isEqualTo(4.88 MB +-5 % margin) } @Test fun `freeze retained memory when indexing leak_asynctask_m`() { @@ -70,7 +70,7 @@ class HprofRetainedHeapPerfTest { val retained = analysisRetained - baselineHeap.retainedHeap(ANALYSIS_THREAD).first - assertThat(retained).isEqualTo(4.4 MB +-5 % margin) + assertThat(retained).isEqualTo(4.74 MB +-5 % margin) } @Test fun `freeze retained memory through analysis steps of leak_asynctask_o`() { @@ -110,14 +110,14 @@ class HprofRetainedHeapPerfTest { retainedPair.first - retainedBeforeAnalysis to retainedPair.second } - assertThat(retained after PARSING_HEAP_DUMP).isEqualTo(5.01 MB +-5 % margin) - assertThat(retained after EXTRACTING_METADATA).isEqualTo(5.06 MB +-5 % margin) - assertThat(retained after FINDING_RETAINED_OBJECTS).isEqualTo(5.16 MB +-5 % margin) - assertThat(retained after FINDING_PATHS_TO_RETAINED_OBJECTS).isEqualTo(6.56 MB +-5 % margin) - assertThat(retained after FINDING_DOMINATORS).isEqualTo(6.56 MB +-5 % margin) - assertThat(retained after INSPECTING_OBJECTS).isEqualTo(6.57 MB +-5 % margin) - assertThat(retained after COMPUTING_NATIVE_RETAINED_SIZE).isEqualTo(6.57 MB +-5 % margin) - assertThat(retained after COMPUTING_RETAINED_SIZE).isEqualTo(5.49 MB +-5 % margin) + assertThat(retained after PARSING_HEAP_DUMP).isEqualTo(5.38 MB +-5 % margin) + assertThat(retained after EXTRACTING_METADATA).isEqualTo(5.63 MB +-5 % margin) + assertThat(retained after FINDING_RETAINED_OBJECTS).isEqualTo(5.68 MB +-5 % margin) + assertThat(retained after FINDING_PATHS_TO_RETAINED_OBJECTS).isEqualTo(6.96 MB +-5 % margin) + assertThat(retained after FINDING_DOMINATORS).isEqualTo(6.96 MB +-5 % margin) + assertThat(retained after INSPECTING_OBJECTS).isEqualTo(6.96 MB +-5 % margin) + assertThat(retained after COMPUTING_NATIVE_RETAINED_SIZE).isEqualTo(6.96 MB +-5 % margin) + assertThat(retained after COMPUTING_RETAINED_SIZE).isEqualTo(5.92 MB +-5 % margin) } private fun indexRecordsOf(hprofFile: File): HprofIndex { diff --git a/shark/shark-android/src/test/java/shark/LegacyHprofTest.kt b/shark/shark-android/src/test/java/shark/LegacyHprofTest.kt index 0a148b1725..f8dd13f2a5 100644 --- a/shark/shark-android/src/test/java/shark/LegacyHprofTest.kt +++ b/shark/shark-android/src/test/java/shark/LegacyHprofTest.kt @@ -31,7 +31,7 @@ class LegacyHprofTest { "LeakCanary version" to "Unknown" ) ) - assertThat(analysis.allLeaks.sumBy { it.totalRetainedHeapByteSize!! }).isEqualTo(193431) + assertThat(analysis.allLeaks.sumBy { it.totalRetainedHeapByteSize!! }).isEqualTo(195007) } @Test fun androidM() { @@ -41,14 +41,14 @@ class LegacyHprofTest { val leak = analysis.applicationLeaks[0].leakTraces.first() assertThat(leak.leakingObject.className).isEqualTo("com.example.leakcanary.MainActivity") assertThat(leak.gcRootType).isEqualTo(GcRootType.STICKY_CLASS) - assertThat(analysis.allLeaks.sumBy { it.totalRetainedHeapByteSize!! }).isEqualTo(49584) + assertThat(analysis.allLeaks.sumBy { it.totalRetainedHeapByteSize!! }).isEqualTo(52824) } @Test fun gcRootReferencesUnknownObject() { val analysis = analyzeHprof("gcroot_unknown_object.hprof") assertThat(analysis.applicationLeaks).hasSize(2) - assertThat(analysis.allLeaks.sumBy { it.totalRetainedHeapByteSize!! }).isEqualTo(5306218) + assertThat(analysis.allLeaks.sumBy { it.totalRetainedHeapByteSize!! }).isEqualTo(5339418) } @Test fun androidMStripped() { @@ -77,7 +77,7 @@ class LegacyHprofTest { assertThat(analysis.applicationLeaks).hasSize(1) val leak = analysis.applicationLeaks[0].leakTraces.first() assertThat(leak.leakingObject.className).isEqualTo("com.example.leakcanary.MainActivity") - assertThat(analysis.allLeaks.sumBy { it.totalRetainedHeapByteSize!! }).isEqualTo(211038) + assertThat(analysis.allLeaks.sumBy { it.totalRetainedHeapByteSize!! }).isEqualTo(214410) } private enum class WRAPS_ACTIVITY { diff --git a/shark/shark-cli/build.gradle.kts b/shark/shark-cli/build.gradle.kts index 78e5bded14..25b529955b 100644 --- a/shark/shark-cli/build.gradle.kts +++ b/shark/shark-cli/build.gradle.kts @@ -7,6 +7,8 @@ plugins { id("com.vanniktech.maven.publish") } +apply(plugin = "kotlinx-serialization") + java { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 @@ -21,10 +23,15 @@ tasks.withType { dependencies { api(projects.shark.sharkAndroid) + implementation(libs.kotlinx.serialization.json) implementation(libs.clikt) implementation(libs.neo4j) implementation(libs.jline) implementation(libs.kotlin.stdlib) + + testImplementation(libs.junit) + testImplementation(libs.assertjCore) + testImplementation(projects.shark.sharkHprofTest) } application { diff --git a/shark/shark-cli/src/dist/bin/shark-ai-investigate b/shark/shark-cli/src/dist/bin/shark-ai-investigate new file mode 100755 index 0000000000..a00995e68c --- /dev/null +++ b/shark/shark-cli/src/dist/bin/shark-ai-investigate @@ -0,0 +1,95 @@ +#!/bin/sh +# +# Wrapper for 'shark-cli ... ai-investigate'. +# +# 1. Generates a unique session shortcode. +# 2. Optionally prints the investigation instructions (--no-help skips this). +# 3. Starts the ai-investigate daemon in the background with that shortcode. +# 4. Waits until the daemon has created its named pipes (happens after heap +# analysis, so may take a minute on large dumps). +# 5. Prints SHORT_CODE= — the AI agent uses this in every command. +# +# Usage: +# shark-ai-investigate --hprof +# shark-ai-investigate --hprof --obfuscation-mapping +# shark-ai-investigate --hprof --no-help # skip instructions +# + +# Resolve the directory that contains this script, following symlinks. +app_path=$0 +while [ -h "$app_path" ]; do + ls=$(ls -ld "$app_path") + link=${ls#*' -> '} + case $link in + /*) app_path=$link ;; + *) app_path=$(dirname "$app_path")/$link ;; + esac +done +SCRIPT_DIR=$(cd "$(dirname "$app_path")" && pwd -P) +SHARK_CLI="$SCRIPT_DIR/shark-cli" + +# If the first argument is "cmd", forward the rest to ai-investigate-cmd and exit. +# shark-ai-investigate cmd +if [ "$1" = "cmd" ]; then + shift + exec "$SHARK_CLI" ai-investigate-cmd "$@" +fi + +# Generate a unique session shortcode. +shortcode=$(uuidgen | tr -d '-' | tr '[:upper:]' '[:lower:]' | cut -c1-8) + +# Check whether --no-help was passed; if so skip the instructions step. +no_help=false +for arg in "$@"; do + case "$arg" in + --no-help) no_help=true ;; + esac +done + +# --no-help is consumed by this wrapper and must not be forwarded to shark-cli. +# Rebuild $@ without it using a POSIX-safe eval loop. +orig_count=$# +i=1 +while [ $i -le $orig_count ]; do + eval "arg=\$$i" + case "$arg" in + --no-help) ;; + *) set -- "$@" "$arg" ;; + esac + i=$((i + 1)) +done +shift "$orig_count" +# $@ now contains only the shark-cli global options (--hprof, --device, etc.) + +if ! "$no_help"; then + # Print instructions (blocking — agent reads them before the session opens). + "$SHARK_CLI" "$@" ai-investigate --instructions-only +fi + +# Start the daemon in the background with the session shortcode. +# Redirect stdout/stderr to /dev/null so the daemon does not inherit this +# script's stdout pipe. Without this, any process that waits for stdout EOF +# (e.g. a task runner) would block until the daemon exits, because the daemon +# holds the write end of the pipe open. The daemon communicates via named +# pipes, not stdout, so nothing useful is lost. +"$SHARK_CLI" "$@" ai-investigate --session "$shortcode" >/dev/null 2>&1 & + +# Wait until the daemon creates its named pipes. +# Pipes are created after heap analysis completes, so this may take a while. +printf 'Running heap analysis and creating pipes\n' +fifo="/tmp/shark-$shortcode.in" +i=0 +while [ ! -p "$fifo" ] && [ "$i" -lt 600 ]; do + sleep 0.5 + i=$((i + 1)) +done +if [ ! -p "$fifo" ]; then + printf '{"error":"Daemon did not start within 5 minutes."}\n' + exit 1 +fi + +# Print a separator, then the shortcode. +# The agent passes the shortcode as the first argument to every command: +# shark-ai-investigate cmd +printf '\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n' +printf 'SHORT_CODE=%s\n' "$shortcode" diff --git a/shark/shark-cli/src/main/java/shark/AiInvestigateCmdCommand.kt b/shark/shark-cli/src/main/java/shark/AiInvestigateCmdCommand.kt new file mode 100644 index 0000000000..fb0b25226d --- /dev/null +++ b/shark/shark-cli/src/main/java/shark/AiInvestigateCmdCommand.kt @@ -0,0 +1,96 @@ +package shark + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.context +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.multiple +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import shark.SharkCliCommand.Companion.echo + +/** + * Thin client that sends a single command to a running [AiInvestigateCommand] daemon and prints + * the JSON response. The daemon and this command communicate via two named pipes identified by + * the session shortcode printed by shark-ai-investigate. + * + * Usage (start the session first with shark-ai-investigate, then send commands): + * shark-ai-investigate --hprof # prints SHORT_CODE= + * shark-ai-investigate cmd trace + * shark-ai-investigate cmd fields 3 + * shark-ai-investigate cmd mark-leaking 3 mDestroyed is true + * shark-ai-investigate cmd close-session + */ +class AiInvestigateCmdCommand : CliktCommand( + name = "ai-investigate-cmd", + help = "Send a command to a running ai-investigate daemon. First argument is the session shortcode." +) { + + init { + context { + // Disable @-file expansion so `fields @` isn't treated as "load args from file". + expandArgumentFiles = false + // Stop option-parsing after the first positional arg (session shortcode). + // Clikt 2.3.0 treats ANY token containing '=' as a long-option attempt, so a reason like + // "size=1" would otherwise trigger "no such option: ..." before even reaching the daemon. + allowInterspersedArgs = false + } + } + + private val session by argument(help = "Session shortcode printed by shark-ai-investigate (SHORT_CODE=...)") + + private val commandParts by argument(help = "Command and arguments to send to the daemon") + .multiple(required = true) + + override fun run() { + val inPath = "/tmp/shark-$session.in" + val outPath = "/tmp/shark-$session.out" + + if (!File(inPath).exists() || !File(outPath).exists()) { + echo("""{"error":"No active session '$session'. Run: shark-ai-investigate --hprof "}""") + return + } + + val cmd = commandParts.joinToString(" ") + + // Opening a FIFO blocks until both ends are open. Start both opens concurrently so + // the client is never waiting sequentially while the daemon waits on the other FIFO. + // supplyAsync uses the common fork-join pool (daemon threads), matching the daemon side. + val writerFuture = CompletableFuture.supplyAsync { FileOutputStream(inPath).bufferedWriter() } + val readerFuture = CompletableFuture.supplyAsync { File(outPath).bufferedReader() } + + try { + CompletableFuture.allOf(writerFuture, readerFuture).get(10_000L, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + echo("""{"error":"Session '$session' is not responding. Daemon may have exited."}""") + return + } + + val writer = writerFuture.get() + val reader = readerFuture.get() + + writer.write(cmd + "\n") + writer.flush() + writer.close() + + val response = reader.readLine() + reader.close() + + if (response != null) { + if (commandParts.firstOrNull() == "human-readable-trace") { + // Daemon sends a single JSON line {"trace":"..."}; extract and print plain text. + val trace = runCatching { + Json.parseToJsonElement(response).jsonObject["trace"]?.jsonPrimitive?.content + }.getOrNull() + echo(trace ?: response) + } else { + echo(response) + } + } + } +} diff --git a/shark/shark-cli/src/main/java/shark/AiInvestigateCommand.kt b/shark/shark-cli/src/main/java/shark/AiInvestigateCommand.kt new file mode 100644 index 0000000000..f1293520dd --- /dev/null +++ b/shark/shark-cli/src/main/java/shark/AiInvestigateCommand.kt @@ -0,0 +1,917 @@ +package shark + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.concurrent.CompletableFuture +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import shark.HeapObject.HeapClass +import shark.HeapObject.HeapInstance +import shark.HeapObject.HeapObjectArray +import shark.HeapObject.HeapPrimitiveArray +import shark.HprofHeapGraph.Companion.openHeapGraph +import shark.LeakTraceObject.LeakingStatus +import shark.LeakTraceObject.LeakingStatus.LEAKING +import shark.LeakTraceObject.LeakingStatus.NOT_LEAKING +import shark.SharkCliCommand.Companion.echo +import shark.SharkCliCommand.Companion.retrieveHeapDumpFile +import shark.SharkCliCommand.Companion.sharkCliParams +import shark.ValueHolder.ReferenceHolder + +class AiInvestigateCommand : CliktCommand( + name = "ai-investigate", + help = "AI-driven heap leak investigation skill. Run with --help to see full instructions." +) { + + override fun getFormattedHelp(): String = INSTRUCTIONS + + // --instructions-only prints the raw INSTRUCTIONS string and returns normally from run(). + // This lets the shark-ai-investigate wrapper print instructions before starting the daemon. + private val instructionsOnly by option( + "--instructions-only", + help = "Print instructions and exit without starting the daemon (used by shark-ai-investigate)" + ).flag() + + // Unique session shortcode generated by shark-ai-investigate and passed to every daemon. + // Also required as the first argument to ai-investigate-cmd. + private val session by option( + "--session", + help = "Session shortcode generated by shark-ai-investigate" + ) + + override fun run() { + if (instructionsOnly) { + echo(INSTRUCTIONS) + return + } + + val shortcode = session ?: error("--session is required (use shark-ai-investigate to start a session)") + val inPath = "/tmp/shark-$shortcode.in" + val outPath = "/tmp/shark-$shortcode.out" + + // Delete stale pipes immediately so the wrapper's poll cannot mistake a + // leftover file from a previous session for a ready signal. + File(inPath).delete() + File(outPath).delete() + + val params = context.sharkCliParams + val heapDumpFile = retrieveHeapDumpFile(params) + val proguardMapping = params.obfuscationMappingPath?.let { + ProguardMappingReader(it.inputStream()).readProguardMapping() + } + + heapDumpFile.openHeapGraph(proguardMapping).use { graph -> + val investigationSession = LeakInvestigationSession( + referenceMatchers = AndroidReferenceMatchers.appDefaults, + objectInspectors = AndroidObjectInspectors.appDefaults + ) + + val leakingObjectIds = FilteringLeakingObjectFinder( + AndroidObjectInspectors.appLeakingObjectFilters + ).findLeakingObjectIds(graph) + + val analysis = investigationSession.analyze(graph, leakingObjectIds) + val heapDumpTimestamp = HprofHeader.parseHeaderOf(heapDumpFile).heapDumpTimestamp + val metadata = AndroidMetadataExtractor.extractMetadata(graph) + + // Create named pipes after analysis so the wrapper's pipe-existence check + // doubles as a signal that the heap is loaded and the daemon is ready. + ProcessBuilder("mkfifo", inPath, outPath).start().waitFor() + Runtime.getRuntime().addShutdownHook(Thread { + File(inPath).delete() + File(outPath).delete() + }) + + val state = SessionState( + graph, investigationSession, analysis, heapDumpFile.absolutePath, + heapDumpTimestamp, metadata + ) + state.runDaemon(inPath, outPath, shortcode) + } + } +} + +@Suppress("MaxLineLength") +private val INSTRUCTIONS = """ + |AI AGENT SKILL — Android Memory Leak Investigation + | + | You are reading these instructions because you ran shark-ai-investigate. + | The heap is being analyzed in the background. Your session shortcode will + | be printed below when analysis is complete — use it in every command. + | + | To skip these instructions next time: add --no-help to your command. + | + |MANDATORY RULES — VIOLATIONS INVALIDATE THE INVESTIGATION + | + | 1. Do NOT declare any conclusion until "progressPct" reaches 100. + | The initial trace statuses are computed by heuristics and are often + | incomplete. Many nodes start as UNKNOWN. That is your work to do. + | + | 2. Do NOT mark any node without first running "fields N" on it. + | Runtime field state is the primary evidence. Source code tells you + | what fields mean; the heap tells you what they actually are. + | + | 3. Do NOT mark any node without first reading its class source. + | For app/library classes: search local files in this repository first. + | If the class is not found locally, use GitHub search or other code + | search tools to locate the source (e.g. GitHub code search, grep.app). + | For Android Framework classes: https://cs.android.com/search?q=ClassName.java + | (e.g. View.java, Fragment.java, Activity.java, Window.java) + | + | 4. Always supply a concrete reason when marking — cite specific field + | values or source code facts. Never write "unknown", "TBD", or guess. + | + | 5. Work the UNKNOWN nodes one at a time using bisection. Do not skip + | ahead or batch-mark nodes without individual evidence. + | + | 6. Never summarize or paraphrase a leak trace. Whenever you include a + | leak trace somewhere, always paste it verbatim. Do not shorten it, + | strip fields, or reformat it. + | + |KEEPING NOTES — use "note TEXT" continuously throughout the investigation + | + | Record a note every time you learn something or form a hypothesis. + | Notes are written to the session log and help a human reviewer follow + | your reasoning without re-running the investigation. + | + | Record a note when you: + | - Learn something from reading source code + | note ClassName.fieldName is cleared in onDestroy(), so null means destroyed + | - Observe a significant field value from a "fields" command + | note node 4 mFinished=true — Activity has finished + | - Form a hypothesis BEFORE you verify it + | note hypothesis: the leak is caused by Foo holding a reference to Bar via mListener + | - Confirm or refute a hypothesis after gathering evidence + | note hypothesis confirmed: mListener is never cleared after onPause() + | - Reach a conclusion about a node's status + | note node 2 is NOT_LEAKING: it is the Application singleton and never destroyed + | + | Notes have no required format — write whatever will be most useful to a + | human reading the log. Do not wait until the end of the investigation to + | record notes; write them as you go. + | + |CONTEXT + | + | This command must be run from the root of the application's source + | repository so the agent can read source files as part of the loop. + | The heap dump and the source tree are used together throughout. + | + |IF shark-ai-investigate FAILS TO START + | + | If running shark-ai-investigate produces any error output, or does not + | print SHORT_CODE=..., STOP IMMEDIATELY. + | Do not attempt any commands. Tell the user exactly what error you saw. + | + |HOW TO SEND COMMANDS + | + | Your session shortcode is printed after these instructions, e.g.: + | SHORT_CODE=f3a91b2c + | The shortcode is different every session. Use the actual value you received + | (shown below), not the example above. + | + | In every example that follows, replace with your actual value. + | + |Commands — send with: shark-ai-investigate cmd + | + | Every command requires a REASON: a plain-English explanation of why you are + | running it and what you are trying to learn. This is mandatory — the daemon + | will reject any command sent without one. + | + | TRACE NAVIGATION + | trace REASON Show current leak trace JSON + | human-readable-trace REASON Show trace as formatted by LeakCanary (for human sharing) + | summary REASON List all leak groups + | select-group N REASON Switch to leak group N + | select-trace N REASON Switch to trace instance N in current group + | + | NODE STATUS OVERRIDES (bisect the UNKNOWN window to find the leak) + | mark-leaking N REASON Override node N -> LEAKING (reason required) + | mark-not-leaking N REASON Override node N -> NOT_LEAKING (reason required) + | mark-unknown N REASON Remove override for node N + | + | HEAP QUERIES + | node N REASON Class, objectId, leaking status & labels for node N + | fields N REASON Instance fields of trace node N + | fields @ID REASON Instance fields of any object by heap ID + | instances CLASS_NAME REASON All instances of a class in the heap + | string @ID REASON Read object as Java String + | referrers @ID REASON Objects holding a reference to @ID (slow scan) + | retained-size @ID REASON Retained size of @ID: own size + all objects it exclusively dominates (slow scan) + | + | ping REASON Confirm daemon is alive; prints the heap dump path + | help Show these full instructions + | close-session REASON Stop the daemon and clean up named pipes + | + | NOTES + | note TEXT Append a note to the session log + | + |Node indices: 0 = GC root, last = leaking object (from "nodes" array in JSON). + |Heap IDs come from the "objectId" field in JSON responses. + | + |THE THREE-ZONE MODEL + | + | A trace is a reference chain from a GC root to a retained object. + | Every node has one of three statuses: + | + | NOT_LEAKING This object should be alive. Marking a node NOT_LEAKING + | automatically forces all nodes above it to NOT_LEAKING. + | + | LEAKING This object should have been GC'd. The bottom node is + | always LEAKING. Marking a node LEAKING automatically + | forces all nodes below it to LEAKING. + | + | UNKNOWN Status not yet determined. These are your targets. + | + | The leak is ONE bad reference: the crossing from the last NOT_LEAKING + | node to the first LEAKING node. Your job is to eliminate every UNKNOWN + | until that boundary is unambiguous. + | + |THE INVESTIGATION ALGORITHM — run this loop until progressPct reaches 100 + | + | STEP 0 — Verify the session (do this once, before anything else) + | Run: shark-ai-investigate cmd ping verifying session is alive before starting + | The response contains "heapDumpPath". Verify it matches the file the + | user asked you to analyze. If you get any error, or the path does not + | match, STOP and tell the user before proceeding. + | + | STEP 1 — Check state + | Run: shark-ai-investigate cmd trace checking progress and scanning for UNKNOWN nodes + | Check "progressPct". When it reaches 100, go to WIN CONDITION. + | Otherwise scan "nodes" for entries where "leakingStatus" is "UNKNOWN". + | + | STEP 2 — Pick the target node (bisect) + | Find the range of UNKNOWN nodes (from suspectWindowStart onward). + | Pick the node at the midpoint of that range. + | This collapses half the UNKNOWN window per iteration. + | + | STEP 3 — Gather evidence (BOTH of these are required) + | a. Read the class source. Search the local repo first; if not found, + | use GitHub search or other code search tools (e.g. grep.app) to + | locate it, or check cs.android.com for Android Framework classes. + | Understand what keeps this object alive vs. what should signal that + | it is done (destroyed, detached, finished, etc.). + | b. Run: shark-ai-investigate cmd fields checking lifecycle state of + | Look for lifecycle indicators: mDestroyed=true, mDetached=true, + | mFinished=true, mAttachInfo==null (View not attached to a window), + | mParent==null, listener fields that are null/cleared, negative or + | zeroed counters, closed/cancelled flags. + | If a field references another object worth inspecting: + | Run: shark-ai-investigate cmd fields @ inspecting to understand its state + | + | STEP 4 — Mark the node + | Combine source knowledge + observed field values to answer: + | "Should this specific instance be alive right now?" + | YES → shark-ai-investigate cmd mark-not-leaking + | NO → shark-ai-investigate cmd mark-leaking + | Reason must cite specific field values or source code facts. + | Example reasons (derive yours from actual evidence): + | "mDestroyed is true, Activity has been destroyed" + | "mAttachInfo is null, View is detached from window" + | "held by Application singleton which never dies" + | "Fragment.mAdded is false and mDetached is true" + | + | STEP 5 — Repeat + | After marking, the trace re-renders with propagated statuses. + | Return to STEP 1. + | + |WIN CONDITION — progressPct: 100 + | + | When progressPct reaches 100 (no UNKNOWN nodes remain), "leakFound" + | becomes true and "culpritReferenceIndex" points to the bad reference — + | the one crossing from the last NOT_LEAKING node to the first LEAKING node. + | + |AFTER THE WIN + | + | 1. Identify the culprit: run "node N REASON" at culpritReferenceIndex. + | + | 2. Read the source of the class holding that field. Find why the + | reference is not cleared. Common root causes: + | - Listener/callback registered but never unregistered + | - Static or application-scoped field holding a short-lived object + | - Anonymous/inner class implicitly capturing the outer instance + | - Handler or Runnable with a pending message outliving its host + | - Coroutine or background thread holding a reference past teardown + | + | 3. Make a fix hypothesis. The session remains open. Continue with "fields N REASON", + | "instances CLASS_NAME REASON", and "referrers @ID REASON", as well as exploring code, + | to gather further evidence or verify the fix hypothesis. + | + | 4. Run retained-size using "firstLeakingObjectId" from the trace JSON: + | shark-ai-investigate cmd retained-size @ measuring memory impact of the leak + | Interpret the result: + | - If no UNKNOWN nodes remain (progressPct == 100): + | "retainedSize" is the exact memory this leak is retaining. + | - If UNKNOWN nodes remain: + | "retainedSize" is a lower bound — actual retained memory may be higher. + | Record this value; it MUST appear in your final reply to the human. + | + | 5. Once done, run: shark-ai-investigate cmd human-readable-trace generating final output for the user + | Copy the output verbatim into your reply to the human. + | Do NOT render or reformat it yourself. + | + | 6. In the same reply, include: + | a. The verbatim human-readable trace (step 5 above). + | b. A summary of every field you read (node index, class, field name, + | observed value) and every source file or URL you consulted. + | c. The retained size from step 4, and whether it is exact or a lower bound. + | d. Your conclusion: what holds what, through which field, why the + | reference was not cleared, what the fix should be and why. + | + | 7. You are done, close the session: shark-ai-investigate cmd close-session investigation complete + | + |MULTIPLE LEAK GROUPS + | + | "summary" lists all groups. Each group is a distinct bug signature. + | Use "select-group N" and "select-trace N" to navigate. + | + |JSON SCHEMA — trace commands + | + | { + | "group": , + | "traceInstance": , + | "overrides": , + | "progressPct": , 0-100; 100 = WIN CONDITION reached + | "suspectWindowStart": , index of first suspect reference (-1 = none) + | "leakFound": , true when progressPct == 100 + | "culpritReferenceIndex": , index of the bad reference (-1 = not yet known) + | "firstLeakingObjectId": , objectId of the first LEAKING node (-1 = none yet) + | "key": "", KeyedWeakReference key that uniquely identifies this leaking object instance. + | Match this value against the key shown in a leak report to confirm + | you are investigating the correct instance. + | "nodes": [ + | { + | "index": , 0 = GC root, last = leaking object + | "objectId": , use with: fields @ID string @ID referrers @ID + | "className": "", + | "leakingStatus": "LEAKING|NOT_LEAKING|UNKNOWN", + | "leakingStatusReason": "", + | "isSuspect": true = outgoing reference is a suspect (~) + | } + | ] + | } + | + | fields / node → { "fields":[...] } or { "traceIndex":N, ... } + | instances → { "count":, "instances":[{ "objectId": }] } + | retained-size → { "objectId":, "retainedObjectCount":, "retainedSize":"", "retainedSizeBytes": } + | + |ENDING THE SESSION + | + | Always close the session when done: + | shark-ai-investigate cmd close-session investigation complete + | This stops the daemon and cleans up the named pipes. +""".trimMargin() + +// --------------------------------------------------------------------------- +// Session state — holds the heap graph and all mutable investigation state. +// All command handling lives here so that no function needs to pass graph around. +// --------------------------------------------------------------------------- + +@Suppress("TooManyFunctions") +private class SessionState( + val graph: HeapGraph, + private val session: LeakInvestigationSession, + val analysis: InitialAnalysis, + private val heapDumpPath: String, + private val heapDumpTimestamp: Long, + private val metadata: Map, +) { + var currentGroupIndex = 0 + var currentTraceIndex = 0 + + /** + * Heap object ID → (leakingStatus, reason). Shared across all traces in the session so that + * overrides for objects appearing in multiple paths carry over when switching traces. + */ + val statusOverrides = mutableMapOf>() + var currentTrace: LeakTrace = reinspect() + + // --------------------------------------------------------------------------- + // FIFO daemon loop + // --------------------------------------------------------------------------- + + fun runDaemon(inPath: String, outPath: String, shortcode: String) { + val logWriter = File("shark-$shortcode.log").bufferedWriter() + logWriter.write("${timestamp()} daemon starting: $heapDumpPath") + logWriter.newLine() + logWriter.flush() + try { + while (true) { + // Opening a FIFO blocks until both ends are open: open(RDONLY) blocks until + // another process opens the same path with open(WRONLY), and vice versa. + // The daemon needs to open two FIFOs — inPath for read and outPath for write. + // If we opened them sequentially the daemon would block on the first open, + // and if the client happened to open the second FIFO first, both sides would + // be waiting on the other to open the first one — a deadlock. + // + // The fix: start both opens concurrently via CompletableFuture so the daemon + // is always waiting on both FIFOs simultaneously. The client can then open + // them in any order and will always unblock one future first. The common + // fork-join pool used by supplyAsync uses daemon threads, so a SIGTERM while + // blocking here does not keep the JVM alive. get() returns non-null directly. + val readerFuture = CompletableFuture.supplyAsync { File(inPath).bufferedReader() } + val writerFuture = + CompletableFuture.supplyAsync { FileOutputStream(outPath).bufferedWriter() } + val reader = readerFuture.get() + val writer = writerFuture.get() + + val cmd = reader.use { it.readLine()?.trim() } + + if (cmd == null) { + writer.close() + continue + } + + val cmdName = cmd.substringBefore(' ') + + if (cmdName == "note") { + val noteText = cmd.substringAfter(' ', "").trim() + val response = if (noteText.isBlank()) { + Json.encodeToString(ErrorResponse("note: text required")) + } else { + logWriter.write("${timestamp()} [note] $noteText") + logWriter.newLine() + logWriter.flush() + Json.encodeToString(NoteResponse(noteText)) + } + try { + writer.use { it.write(response + "\n"); it.flush() } + } catch (_: IOException) {} + continue + } + + val response = handleCommand(cmd) + try { + writer.use { + it.write(response + "\n") + it.flush() + } + } catch (_: IOException) { + // Client disconnected before reading the response — ignore and loop. + } + + val prettyResponse = PrettyPrintJson().format(response) + logWriter.write("${timestamp()} > $cmd") + logWriter.newLine() + logWriter.write(prettyResponse) + logWriter.newLine() + // The JSON trace is optimized for the AI agent; also log the human-readable + // form so a person reviewing the log can follow the investigation at a glance. + if (cmdName == "trace") { + logWriter.write(currentTrace.toString()) + logWriter.newLine() + } + logWriter.flush() + + if (cmdName == "close-session") break + } + } finally { + logWriter.close() + } + } + + // --------------------------------------------------------------------------- + // Command dispatch + // --------------------------------------------------------------------------- + + fun handleCommand(cmd: String): String { + if (cmd.isBlank() || cmd == "help") return Json.encodeToString(HelpResponse(INSTRUCTIONS)) + val parts = cmd.split("\\s+".toRegex(), limit = 3) + return try { + when (parts[0]) { + "ping" -> { requireReason(parts); Json.encodeToString(PingResponse(heapDumpPath)) } + "close-session" -> { requireReason(parts); Json.encodeToString(CloseSession("Session closed.")) } + "summary" -> { requireReason(parts); Json.encodeToString(buildSummary()) } + "human-readable-trace" -> { + requireReason(parts) + Json.encodeToString(HumanReadableTraceResponse(buildHumanReadableTrace())) + } + "node" -> Json.encodeToString(nodeResponse(parts)) + "fields" -> Json.encodeToString(fieldsResponse(parts)) + "instances" -> Json.encodeToString(instancesResponse(parts)) + "string" -> Json.encodeToString(stringResponse(parts)) + "referrers" -> Json.encodeToString(referrersResponse(parts)) + "retained-size" -> Json.encodeToString(retainedSizeResponse(parts)) + "trace", + "mark-leaking", + "mark-not-leaking", + "mark-unknown", + "select-group", + "select-trace" -> { + when (parts[0]) { + "trace" -> requireReason(parts) + "mark-leaking" -> applyMark(parts, LEAKING) + "mark-not-leaking" -> applyMark(parts, NOT_LEAKING) + "mark-unknown" -> applyMarkUnknown(parts) + "select-group" -> applySelectGroup(parts) + "select-trace" -> applySelectTrace(parts) + } + Json.encodeToString(buildTrace()) + } + + else -> Json.encodeToString(ErrorResponse("Unknown command: ${parts[0]}")) + } + } catch (e: CmdError) { + e.json + } catch (e: Exception) { + Json.encodeToString(ErrorResponse("Internal error: ${e.javaClass.simpleName}: ${e.message}")) + } + } + + // --------------------------------------------------------------------------- + // Command handlers + // --------------------------------------------------------------------------- + + private fun nodeResponse(parts: List): NodeResponse { + val index = requireTraceIndex(parts) + requireReason(parts, afterArgs = 1) + val objectId = currentCachedPath().objectIds[index] + val obj = + graph.findObjectByIdOrNull(objectId) ?: cmdError("Object @$objectId not found in heap") + val traceObj = traceNodeAt(index) + return NodeResponse( + traceIndex = index, + objectId = objectId, + className = renderObjectClass(obj), + leakingStatus = traceObj.leakingStatus.toString(), + leakingStatusReason = traceObj.leakingStatusReason, + labels = traceObj.labels.toList() + ) + } + + private fun fieldsResponse(parts: List): FieldsResponse { + val arg = parts.getOrNull(1) ?: cmdError("Usage: fields N|@ID REASON") + requireReason(parts, afterArgs = 1) + val obj = resolveObjectArg(arg) + if (obj !is HeapInstance) cmdError("Not an instance: ${renderObjectClass(obj)}") + val fields = obj.readFields().map { f -> + FieldEntry( + declaringClass = f.declaringClass.name, name = f.name, value = renderValueJson(f.value) + ) + }.toList() + return FieldsResponse(objectId = obj.objectId, fields = fields) + } + + private fun instancesResponse(parts: List): InstancesResponse { + val className = parts.getOrNull(1) ?: cmdError("Usage: instances CLASS_NAME REASON") + requireReason(parts, afterArgs = 1) + val clazz = graph.findClassByName(className) ?: cmdError("Class not found: $className") + val instances = clazz.instances.map { InstanceEntry(it.objectId) }.toList() + return InstancesResponse(className = className, count = instances.size, instances = instances) + } + + private fun stringResponse(parts: List): StringResponse { + val arg = parts.getOrNull(1) ?: cmdError("Usage: string @ID REASON") + requireReason(parts, afterArgs = 1) + return StringResponse((resolveObjectArg(arg) as? HeapInstance)?.readAsJavaString()) + } + + private fun referrersResponse(parts: List): ReferrersResponse { + val arg = parts.getOrNull(1) ?: cmdError("Usage: referrers @ID REASON") + requireReason(parts, afterArgs = 1) + val targetId = parseObjectId(arg) + val referrers = graph.instances.filter { instance -> + instance.readFields().any { field -> + val holder = field.value.holder + holder is ReferenceHolder && !holder.isNull && holder.value == targetId + } + }.map { ReferrerEntry(it.objectId, it.instanceClassName) }.toList() + return ReferrersResponse(targetId = targetId, count = referrers.size, referrers = referrers) + } + + private fun retainedSizeResponse(parts: List): RetainedSizeResponse { + val arg = parts.getOrNull(1) ?: cmdError("Usage: retained-size @ID REASON") + requireReason(parts, afterArgs = 1) + val targetId = parseObjectId(arg) + graph.findObjectByIdOrNull(targetId) ?: cmdError("Object @$targetId not found") + val result = SingleObjectRetainedSizeCalculator(graph).computeRetainedSize(targetId) + return RetainedSizeResponse( + objectId = result.objectId, + retainedObjectCount = result.retainedObjectCount, + retainedSize = result.retainedSize.toString(), + retainedSizeBytes = result.retainedSize.inWholeBytes + ) + } + + private fun applyMark(parts: List, status: LeakingStatus) { + val index = requireTraceIndex(parts) + val reason = parts.getOrElse(2) { "marked by agent" } + statusOverrides[currentCachedPath().objectIds[index]] = status to reason + currentTrace = reinspect() + } + + private fun applyMarkUnknown(parts: List) { + requireReason(parts, afterArgs = 1) + statusOverrides.remove(currentCachedPath().objectIds[requireTraceIndex(parts)]) + currentTrace = reinspect() + } + + private fun applySelectGroup(parts: List) { + val n = parts.getOrNull(1)?.toIntOrNull() ?: cmdError("Usage: select-group N REASON") + requireReason(parts, afterArgs = 1) + if (n !in analysis.allLeaks.indices) { + cmdError("Invalid group $n. Range: 0..${analysis.allLeaks.lastIndex}") + } + currentGroupIndex = n + currentTraceIndex = 0 + currentTrace = reinspect() + } + + private fun applySelectTrace(parts: List) { + val n = parts.getOrNull(1)?.toIntOrNull() ?: cmdError("Usage: select-trace N REASON") + requireReason(parts, afterArgs = 1) + val max = currentLeak().leakTraces.lastIndex + if (n !in 0..max) cmdError("Invalid trace $n. Range: 0..$max") + currentTraceIndex = n + currentTrace = reinspect() + } + + // --------------------------------------------------------------------------- + // Response builders + // --------------------------------------------------------------------------- + + fun buildHumanReadableTrace(): String { + val metadataSection = buildString { + append("====================================\n") + append("METADATA\n\n") + append("Please include this in bug reports and Stack Overflow questions.") + if (metadata.isNotEmpty()) { + append("\n") + append(metadata.map { "${it.key}: ${it.value}" }.joinToString("\n")) + } + append("\nHeap dump file path: $heapDumpPath") + append("\nHeap dump timestamp: $heapDumpTimestamp") + append("\n====================================") + } + return "${currentTrace}\n$metadataSection" + } + + fun buildSummary(): SummaryResponse { + val groups = analysis.allLeaks.mapIndexed { i, leak -> + LeakGroupEntry( + index = i, + type = if (leak is LibraryLeak) "library" else "application", + description = leak.shortDescription, + instances = leak.leakTraces.size + ) + } + return SummaryResponse(groups) + } + + fun buildTrace(): TraceResponse { + val path = currentCachedPath() + val allNodes = currentTrace.referencePath.map { it.originObject } + + listOf(currentTrace.leakingObject) + + val suspectWindowStart = currentTrace.referencePath.indices + .firstOrNull { i -> currentTrace.referencePathElementIsSuspect(i) } + ?: -1 + + val unknownCount = allNodes.count { it.leakingStatus == LeakingStatus.UNKNOWN } + val progressPct = (allNodes.size - unknownCount) * 100 / allNodes.size + + val leakFound = unknownCount == 0 + val culpritIdx = if (leakFound) { + currentTrace.referencePath.indices + .firstOrNull { i -> currentTrace.referencePathElementIsSuspect(i) } + ?: -1 + } else { + -1 + } + + val nodes = allNodes.mapIndexed { i, node -> + val objectId = path.objectIds[i] + val isSuspect = i < currentTrace.referencePath.size && + currentTrace.referencePathElementIsSuspect(i) + TraceNodeEntry( + index = i, + objectId = objectId, + className = node.className, + leakingStatus = node.leakingStatus.toString(), + leakingStatusReason = node.leakingStatusReason, + isSuspect = isSuspect + ) + } + + val firstLeakingObjectId = nodes + .firstOrNull { it.leakingStatus == LeakingStatus.LEAKING.toString() } + ?.objectId ?: -1L + + return TraceResponse( + group = currentGroupIndex, + traceInstance = currentTraceIndex, + overrides = statusOverrides.size, + progressPct = progressPct, + suspectWindowStart = suspectWindowStart, + leakFound = leakFound, + culpritReferenceIndex = culpritIdx, + firstLeakingObjectId = firstLeakingObjectId, + key = currentTrace.leakingObject.labels + .first { it.startsWith("key = ") } + .removePrefix("key = "), + nodes = nodes, + heapDumpTimestamp = heapDumpTimestamp, + metadata = metadata + ) + } + + // --------------------------------------------------------------------------- + // Value rendering helpers + // --------------------------------------------------------------------------- + + private fun renderObjectClass(obj: HeapObject): String = when (obj) { + is HeapInstance -> obj.instanceClassName + is HeapClass -> obj.name + is HeapObjectArray -> obj.arrayClassName + is HeapPrimitiveArray -> obj.arrayClassName + } + + private fun renderValueJson(value: HeapValue): JsonElement { + val holder = value.holder + return when { + holder is ReferenceHolder && holder.isNull -> JsonNull + holder is ReferenceHolder -> { + val obj = value.asObject + buildJsonObject { + put("objectId", holder.value) + if (obj != null) put("class", renderObjectClass(obj)) + } + } + + else -> JsonPrimitive(holder.toString()) + } + } + + // --------------------------------------------------------------------------- + // State helpers + // --------------------------------------------------------------------------- + + private fun currentLeak(): Leak = analysis.allLeaks[currentGroupIndex] + + private fun currentCachedPath(): CachedPath = + analysis.groupedPaths[currentGroupIndex][currentTraceIndex] + + private fun reinspect(): LeakTrace = + session.reinspectPath(graph, currentCachedPath(), statusOverrides) + + private fun traceNodeAt(index: Int): LeakTraceObject { + val allNodes = currentTrace.referencePath.map { it.originObject } + + listOf(currentTrace.leakingObject) + return allNodes[index] + } + + private fun requireReason(parts: List, afterArgs: Int = 0) { + val reason = parts.drop(1 + afterArgs).joinToString(" ").trim() + if (reason.isBlank()) cmdError("${parts[0]}: reason required — explain what you are trying to learn") + } + + @Suppress("ThrowsCount") + private fun requireTraceIndex(parts: List): Int { + val raw = parts.getOrNull(1) ?: cmdError("Usage: ${parts[0]} N") + val index = raw.toIntOrNull() ?: cmdError("Expected integer, got: $raw") + val max = currentCachedPath().objectIds.lastIndex + if (index !in 0..max) cmdError("Index $index out of range (0..$max)") + return index + } + + @Suppress("ThrowsCount") + private fun resolveObjectArg(arg: String): HeapObject { + return if (arg.startsWith("@")) { + val id = parseObjectId(arg) + graph.findObjectByIdOrNull(id) ?: cmdError("Object @$id not found") + } else { + val index = arg.toIntOrNull() + ?: cmdError("Expected trace node index N or @ID, got: $arg") + val max = currentCachedPath().objectIds.lastIndex + if (index !in 0..max) cmdError("Index $index out of range (0..$max)") + val id = currentCachedPath().objectIds[index] + graph.findObjectByIdOrNull(id) ?: cmdError("Object @$id not found") + } + } + + private fun parseObjectId(arg: String): Long = + arg.removePrefix("@").toLongOrNull() ?: cmdError("Invalid object ID: $arg") +} + +@Serializable private data class NoteResponse(val note: String) + +@Serializable private data class PingResponse(val heapDumpPath: String) + +@Serializable private data class HumanReadableTraceResponse(val trace: String) + +@Serializable private data class HelpResponse(val help: String) + +@Serializable private data class CloseSession(val message: String) + +@Serializable private data class ErrorResponse(val error: String) + +private val timestampFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS") + +private fun timestamp(): String = "[${LocalDateTime.now().format(timestampFormatter)}]" + +private class CmdError(val json: String) : Exception(json) + +private fun cmdError(msg: String): Nothing = throw CmdError(Json.encodeToString(ErrorResponse(msg))) + +@Serializable +private data class NodeResponse( + val traceIndex: Int, + val objectId: Long, + val className: String, + val leakingStatus: String, + val leakingStatusReason: String, + val labels: List +) + +@Serializable +private data class FieldEntry( + @SerialName("class") val declaringClass: String, + val name: String, + val value: JsonElement +) + +@Serializable +private data class FieldsResponse( + val objectId: Long, + val fields: List +) + +@Serializable private data class InstanceEntry(val objectId: Long) + +@Serializable +private data class InstancesResponse( + val className: String, + val count: Int, + val instances: List +) + +@Serializable private data class StringResponse(val value: String?) + +@Serializable +private data class ReferrerEntry( + val objectId: Long, + @SerialName("class") val className: String +) + +@Serializable +private data class ReferrersResponse( + val targetId: Long, + val count: Int, + val referrers: List +) + +@Serializable +private data class RetainedSizeResponse( + val objectId: Long, + val retainedObjectCount: Int, + val retainedSize: String, + val retainedSizeBytes: Long +) + +@Serializable +private data class LeakGroupEntry( + val index: Int, + val type: String, + val description: String, + val instances: Int +) + +@Serializable private data class SummaryResponse(val leakGroups: List) + +@Serializable +private data class TraceNodeEntry( + val index: Int, + val objectId: Long, + val className: String, + val leakingStatus: String, + val leakingStatusReason: String, + val isSuspect: Boolean +) + +@Serializable +private data class TraceResponse( + val group: Int, + val traceInstance: Int, + val overrides: Int, + val progressPct: Int, + val suspectWindowStart: Int, + val leakFound: Boolean, + val culpritReferenceIndex: Int, + val firstLeakingObjectId: Long, + val key: String, + val nodes: List, + val heapDumpTimestamp: Long, + val metadata: Map +) diff --git a/shark/shark-cli/src/main/java/shark/Main.kt b/shark/shark-cli/src/main/java/shark/Main.kt index 929252222d..3e2805d013 100644 --- a/shark/shark-cli/src/main/java/shark/Main.kt +++ b/shark/shark-cli/src/main/java/shark/Main.kt @@ -10,5 +10,7 @@ fun main(args: Array) = DumpProcessCommand(), StripHprofCommand(), DeobfuscateHprofCommand(), - HeapGrowthCommand() + HeapGrowthCommand(), + AiInvestigateCommand(), + AiInvestigateCmdCommand() ).main(args) diff --git a/shark/shark-cli/src/main/java/shark/PrettyPrintJson.kt b/shark/shark-cli/src/main/java/shark/PrettyPrintJson.kt new file mode 100644 index 0000000000..a898069dbb --- /dev/null +++ b/shark/shark-cli/src/main/java/shark/PrettyPrintJson.kt @@ -0,0 +1,52 @@ +package shark + +class PrettyPrintJson { + + fun format(json: String): String { + val out = StringBuilder() + var depth = 0 + var inString = false + var needsNewlineAndIndent = false + var i = 0 + while (i < json.length) { + val c = json[i] + when { + inString -> { + out.append(c) + when (c) { + // consume escaped char verbatim + '\\' -> out.append(json[++i]) + '"' -> inString = false + } + } + // discard existing formatting + c.isWhitespace() -> Unit + c == '}' || c == ']' -> { + needsNewlineAndIndent = false + out.newlineAndIndent(--depth) + out.append(c) + } + else -> { + if (needsNewlineAndIndent) { + out.newlineAndIndent(depth) + needsNewlineAndIndent = false + } + out.append(c) + when (c) { + '"' -> inString = true + '{', '[' -> { depth++; needsNewlineAndIndent = true } + ',' -> needsNewlineAndIndent = true + ':' -> out.append(' ') + } + } + } + i++ + } + return out.toString() + } + + private fun StringBuilder.newlineAndIndent(depth: Int) { + append('\n') + repeat(depth) { append(" ") } + } +} diff --git a/shark/shark-cli/src/main/java/shark/SharkCliCommand.kt b/shark/shark-cli/src/main/java/shark/SharkCliCommand.kt index a42e4be389..7cffdb6deb 100644 --- a/shark/shark-cli/src/main/java/shark/SharkCliCommand.kt +++ b/shark/shark-cli/src/main/java/shark/SharkCliCommand.kt @@ -120,9 +120,10 @@ class SharkCliCommand : CliktCommand( obfuscationMappingPath = obfuscationMappingPath ) } - } else { - throw UsageError("Must provide one of --process, --hprof") } + // else: no source provided. sharkCliParams is not set; subcommands that need it will + // throw UsageError when they access it. Subcommands that don't (e.g. ai-investigate-cmd) + // work fine without --hprof / --process. } private fun setupVerboseLogger() { @@ -162,7 +163,7 @@ class SharkCliCommand : CliktCommand( if (ctx.obj is CommandParams) return ctx.obj as CommandParams ctx = ctx.parent } - throw IllegalStateException("CommandParams not found in Context.obj") + throw UsageError("Must provide one of --process, --hprof") } set(value) { obj = value diff --git a/shark/shark-cli/src/test/java/shark/AiInvestigateDaemonTest.kt b/shark/shark-cli/src/test/java/shark/AiInvestigateDaemonTest.kt new file mode 100644 index 0000000000..7bb0984fa5 --- /dev/null +++ b/shark/shark-cli/src/test/java/shark/AiInvestigateDaemonTest.kt @@ -0,0 +1,141 @@ +package shark + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import shark.ValueHolder.IntHolder +import java.io.File +import java.util.concurrent.TimeUnit + +private const val SESSION = "testXXXX" + +/** + * Integration test for the ai-investigate FIFO daemon. + * + * Starts the daemon as a real subprocess (using the current JVM's classpath), then exercises it + * through a series of ai-investigate-cmd subprocesses — one process per command — mirroring + * exactly how an AI agent would use it in production. + */ +class AiInvestigateDaemonTest { + + @get:Rule + val testFolder = TemporaryFolder() + + private var daemon: Process? = null + private val daemonOutput = mutableListOf() + + @After + fun cleanup() { + daemon?.destroyForcibly() + } + + @Test + fun `daemon responds to commands via FIFO and exits cleanly on close-session`() { + // Minimal Android-compatible heap dump. + // AndroidReferenceMatchers.appDefaults requires android.os.Build and + // android.os.Build$VERSION in the heap, so we add the fields they read. + val hprofFile = testFolder.newFile("test.hprof") + hprofFile.dump { + val manufacturer = string("Test") + val buildId = string("TEST") + clazz( + className = "android.os.Build", + staticFields = listOf("MANUFACTURER" to manufacturer, "ID" to buildId) + ) + clazz( + className = "android.os.Build\$VERSION", + staticFields = listOf("SDK_INT" to IntHolder(29)) + ) + "GcRoot" clazz { + staticField["leak"] = "Leaking" watchedInstance {} + } + } + + // Start the daemon with a fixed session shortcode. Daemon prints nothing to stdout. + daemon = jvmProcess("--hprof", hprofFile.absolutePath, "ai-investigate", "--session", SESSION) + + // Collect daemon stderr for diagnostics on failure. + Thread { + daemon!!.errorStream.bufferedReader().forEachLine { line -> + synchronized(daemonOutput) { daemonOutput += "[stderr] $line" } + } + }.also { it.isDaemon = true }.start() + + // Daemon creates FIFOs before loading the heap (so this is fast). + val inFifo = File("/tmp/shark-$SESSION.in") + val deadline = System.currentTimeMillis() + 30_000 + while (!inFifo.exists() && System.currentTimeMillis() < deadline) Thread.sleep(100) + check(inFifo.exists()) { + "Daemon did not create FIFOs within 30 seconds.\n\nDaemon output:\n${daemonOutput.joinToString("\n")}" + } + + // summary → exactly one leak group found in the heap dump + val summary = cmd("summary", "checking how many leak groups exist") + assertThat(summary["leakGroups"]!!.jsonArray).hasSize(1) + + // trace → nodes array present; the last node (the leaking object) is LEAKING + val trace = cmd("trace", "checking progress and scanning for UNKNOWN nodes") + val nodes = trace["nodes"]!!.jsonArray + assertThat(nodes).isNotEmpty + assertThat(nodes.last().jsonObject["leakingStatus"]!!.jsonPrimitive.content) + .isEqualTo("LEAKING") + + // node 0 → has a className (the GC root end of the path) + val node0 = cmd("node", "0", "inspecting GC root node class") + assertThat(node0["className"]).isNotNull + + // fields @ → returns a FieldsResponse for the leaking instance + // (node 0 is a class object, so we use the leaking instance via its objectId) + val leakingObjectId = nodes.last().jsonObject["objectId"]!!.jsonPrimitive.content + val fields = cmd("fields", "@$leakingObjectId", "reading lifecycle fields of leaking instance") + assertThat(fields["objectId"]).isNotNull + assertThat(fields["fields"]).isNotNull + + // close-session → daemon acknowledges, then exits with code 0 + val close = cmd("close-session", "investigation complete") + assertThat(close["message"]!!.jsonPrimitive.content).isEqualTo("Session closed.") + assertThat(daemon!!.waitFor(10, TimeUnit.SECONDS)).isTrue + assertThat(daemon!!.exitValue()).isZero + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /** Spawns a JVM subprocess with [args] prepended after the main class. */ + private fun jvmProcess(vararg args: String): Process = + ProcessBuilder( + "${System.getProperty("java.home")}/bin/java", + "-cp", System.getProperty("java.class.path"), + "shark.MainKt", + *args + ).start() + + /** + * Runs `ai-investigate-cmd SESSION [cmdArgs]` as a subprocess, waits for it to finish, + * and returns the parsed JSON response. Prints any stderr output so failures are easy to diagnose. + */ + private fun cmd(vararg cmdArgs: String): JsonObject { + val process = jvmProcess("ai-investigate-cmd", SESSION, *cmdArgs) + val cmd = "ai-investigate-cmd $SESSION ${cmdArgs.joinToString(" ")}" + check(process.waitFor(30, TimeUnit.SECONDS)) { + "$cmd timed out\n\nDaemon output:\n${daemonOutput.joinToString("\n")}" + } + val out = process.inputStream.bufferedReader().readText().trim() + val err = process.errorStream.bufferedReader().readText().trim() + if (err.isNotEmpty()) { + System.err.println("[cmd ${cmdArgs.joinToString(" ")} stderr] $err") + } + check(out.isNotEmpty()) { + "Empty output from $cmd\n\nCmd stderr: $err\n\nDaemon output:\n${daemonOutput.joinToString("\n")}" + } + return Json.parseToJsonElement(out).jsonObject + } +} diff --git a/shark/shark-cli/src/test/java/shark/PrettyPrintJsonTest.kt b/shark/shark-cli/src/test/java/shark/PrettyPrintJsonTest.kt new file mode 100644 index 0000000000..574bd39913 --- /dev/null +++ b/shark/shark-cli/src/test/java/shark/PrettyPrintJsonTest.kt @@ -0,0 +1,140 @@ +package shark + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class PrettyPrintJsonTest { + + private val pretty = PrettyPrintJson() + + @Test + fun `flat object`() { + assertThat(pretty.format("""{"a":"b","c":"d"}""")).isEqualTo( + """ + { + "a": "b", + "c": "d" + } + """.trimIndent() + ) + } + + @Test + fun `nested object`() { + assertThat(pretty.format("""{"a":{"b":"c"}}""")).isEqualTo( + """ + { + "a": { + "b": "c" + } + } + """.trimIndent() + ) + } + + @Test + fun `array of primitives`() { + assertThat(pretty.format("""[1,2,3]""")).isEqualTo( + """ + [ + 1, + 2, + 3 + ] + """.trimIndent() + ) + } + + @Test + fun `array of objects`() { + assertThat(pretty.format("""[{"a":1},{"b":2}]""")).isEqualTo( + """ + [ + { + "a": 1 + }, + { + "b": 2 + } + ] + """.trimIndent() + ) + } + + @Test + fun `structural characters inside string are not treated as structural`() { + assertThat(pretty.format("""{"a":"{[,:]}"}""")).isEqualTo( + """ + { + "a": "{[,:]}" + } + """.trimIndent() + ) + } + + @Test + fun `escaped quote inside string`() { + assertThat(pretty.format("""{"a":"say \"hi\""}""")).isEqualTo( + """ + { + "a": "say \"hi\"" + } + """.trimIndent() + ) + } + + @Test + fun `escaped backslash followed by closing quote`() { + assertThat(pretty.format("""{"a":"path\\"}""")).isEqualTo( + """ + { + "a": "path\\" + } + """.trimIndent() + ) + } + + @Test + fun `null value`() { + assertThat(pretty.format("""{"a":null}""")).isEqualTo( + """ + { + "a": null + } + """.trimIndent() + ) + } + + @Test + fun `number and boolean values`() { + assertThat(pretty.format("""{"n":42,"b":true}""")).isEqualTo( + """ + { + "n": 42, + "b": true + } + """.trimIndent() + ) + } + + @Test + fun `empty object`() { + assertThat(pretty.format("{}")).isEqualTo("{\n}") + } + + @Test + fun `empty array`() { + assertThat(pretty.format("[]")).isEqualTo("[\n]") + } + + @Test + fun `existing whitespace in input is ignored`() { + assertThat(pretty.format("""{ "a" : "b" }""")).isEqualTo( + """ + { + "a": "b" + } + """.trimIndent() + ) + } +} diff --git a/shark/shark-graph/api/shark-graph.api b/shark/shark-graph/api/shark-graph.api index bc8adc362c..70cbec0d96 100644 --- a/shark/shark-graph/api/shark-graph.api +++ b/shark/shark-graph/api/shark-graph.api @@ -84,6 +84,7 @@ public final class shark/HeapObject$HeapClass : shark/HeapObject { public final fun isPrimitiveArrayClass ()Z public final fun isPrimitiveWrapperClass ()Z public final fun readFieldsByteSize ()I + public final fun readInstanceFieldCount ()I public fun readRecord ()Lshark/HprofRecord$HeapDumpRecord$ObjectRecord$ClassDumpRecord; public synthetic fun readRecord ()Lshark/HprofRecord$HeapDumpRecord$ObjectRecord; public final fun readRecordFields ()Ljava/util/List; diff --git a/shark/shark-graph/src/main/java/shark/HeapObject.kt b/shark/shark-graph/src/main/java/shark/HeapObject.kt index a8fa60fc96..bc0e18a7d9 100644 --- a/shark/shark-graph/src/main/java/shark/HeapObject.kt +++ b/shark/shark-graph/src/main/java/shark/HeapObject.kt @@ -264,6 +264,12 @@ sealed class HeapObject { fun readRecordFields() = hprofGraph.classDumpFields(indexedObject) + /** + * Returns the number of instance fields declared by this class, without allocating + * any field records. Cheaper than [readRecordFields].size when only the count is needed. + */ + fun readInstanceFieldCount() = hprofGraph.classDumpInstanceFieldCount(indexedObject) + /** * Returns the name of the field declared in this class for the specified [fieldRecord]. */ diff --git a/shark/shark-graph/src/main/java/shark/HprofHeapGraph.kt b/shark/shark-graph/src/main/java/shark/HprofHeapGraph.kt index 92b175c7d2..3f8a2578e7 100644 --- a/shark/shark-graph/src/main/java/shark/HprofHeapGraph.kt +++ b/shark/shark-graph/src/main/java/shark/HprofHeapGraph.kt @@ -221,6 +221,10 @@ class HprofHeapGraph internal constructor( return index.classFieldsReader.classDumpFields(indexedClass) } + internal fun classDumpInstanceFieldCount(indexedClass: IndexedClass): Int { + return index.classFieldsReader.classDumpInstanceFieldCount(indexedClass) + } + internal fun classDumpHasReferenceFields(indexedClass: IndexedClass): Boolean { return index.classFieldsReader.classDumpHasReferenceFields(indexedClass) } diff --git a/shark/shark-graph/src/main/java/shark/internal/ClassFieldsReader.kt b/shark/shark-graph/src/main/java/shark/internal/ClassFieldsReader.kt index bb60394978..87a782bcbd 100644 --- a/shark/shark-graph/src/main/java/shark/internal/ClassFieldsReader.kt +++ b/shark/shark-graph/src/main/java/shark/internal/ClassFieldsReader.kt @@ -61,6 +61,13 @@ internal class ClassFieldsReader( } } + fun classDumpInstanceFieldCount(indexedClass: IndexedClass): Int { + return read(initialPosition = indexedClass.fieldsIndex) { + skipStaticFields() + readUnsignedShort() + } + } + fun classDumpHasReferenceFields(indexedClass: IndexedClass): Boolean { return read(initialPosition = indexedClass.fieldsIndex) { skipStaticFields() diff --git a/shark/shark/api/shark.api b/shark/shark/api/shark.api index 222a67a8c1..2cb4e3db22 100644 --- a/shark/shark/api/shark.api +++ b/shark/shark/api/shark.api @@ -111,6 +111,10 @@ public final class shark/ByteSizeKt { public static final fun getZERO_BYTES ()J } +public final class shark/CachedPath { + public final fun getObjectIds ()Ljava/util/List; +} + public final class shark/ChainingInstanceReferenceReader : shark/ReferenceReader { public fun (Ljava/util/List;Lshark/FlatteningPartitionedInstanceReferenceReader;Lshark/FieldInstanceReferenceReader;)V public fun read (Lshark/HeapObject$HeapInstance;)Lkotlin/sequences/Sequence; @@ -315,6 +319,11 @@ public final class shark/IgnoredReferenceMatcher : shark/ReferenceMatcher { public fun toString ()Ljava/lang/String; } +public final class shark/InitialAnalysis { + public final fun getAllLeaks ()Ljava/util/List; + public final fun getGroupedPaths ()Ljava/util/List; +} + public final class shark/InitialState : shark/HeapTraversalInput { public static final field Companion Lshark/InitialState$Companion; public static final field DEFAULT_SCENARIO_LOOPS_PER_GRAPH I @@ -396,6 +405,12 @@ public abstract class shark/Leak : java/io/Serializable { public final class shark/Leak$Companion { } +public final class shark/LeakInvestigationSession { + public fun (Ljava/util/List;Ljava/util/List;)V + public final fun analyze (Lshark/HeapGraph;Ljava/util/Set;)Lshark/InitialAnalysis; + public final fun reinspectPath (Lshark/HeapGraph;Lshark/CachedPath;Ljava/util/Map;)Lshark/LeakTrace; +} + public final class shark/LeakTrace : java/io/Serializable { public static final field Companion Lshark/LeakTrace$Companion; public fun (Lshark/LeakTrace$GcRootType;Ljava/util/List;Lshark/LeakTraceObject;)V @@ -1005,6 +1020,26 @@ public final class shark/ShortestPathObjectNode$GrowingChildNode { public fun toString ()Ljava/lang/String; } +public final class shark/SingleObjectRetainedSizeCalculator { + public fun (Lshark/HeapGraph;)V + public final fun computeRetainedSize (J)Lshark/SingleObjectRetainedSizeCalculator$Result; +} + +public final class shark/SingleObjectRetainedSizeCalculator$Result { + public synthetic fun (JIJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component2 ()I + public final fun component3-UyN4wxk ()J + public final fun copy-UMppbeQ (JIJ)Lshark/SingleObjectRetainedSizeCalculator$Result; + public static synthetic fun copy-UMppbeQ$default (Lshark/SingleObjectRetainedSizeCalculator$Result;JIJILjava/lang/Object;)Lshark/SingleObjectRetainedSizeCalculator$Result; + public fun equals (Ljava/lang/Object;)Z + public final fun getObjectId ()J + public final fun getRetainedObjectCount ()I + public final fun getRetainedSize-UyN4wxk ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class shark/VirtualizingMatchingReferenceReaderFactory : shark/ReferenceReader$Factory { public fun (Ljava/util/List;Lshark/ChainingInstanceReferenceReader$VirtualInstanceReferenceReader$ChainFactory;)V public fun createFor (Lshark/HeapGraph;)Lshark/ReferenceReader; diff --git a/shark/shark/src/main/java/shark/LeakInvestigationSession.kt b/shark/shark/src/main/java/shark/LeakInvestigationSession.kt new file mode 100644 index 0000000000..668a8659aa --- /dev/null +++ b/shark/shark/src/main/java/shark/LeakInvestigationSession.kt @@ -0,0 +1,178 @@ +package shark + +import shark.LeakTraceObject.LeakingStatus +import shark.LeakTraceObject.LeakingStatus.LEAKING +import shark.LeakTraceObject.LeakingStatus.NOT_LEAKING +import shark.RealLeakTracerFactory.ShortestPath + +/** + * Holds the result of the initial one-time analysis together with cached shortest paths so that + * the inspection+bisecting step can be re-run cheaply whenever the user overrides a node's status. + * + * @param allLeaks All leaks found, application leaks first then library leaks, in the same order + * as [groupedPaths]. + * @param groupedPaths Outer list is parallel to [allLeaks]. Inner list is parallel to + * [Leak.leakTraces]. Each [CachedPath] holds the corresponding [ShortestPath] plus an ordered + * list of object IDs so that a trace-node index maps to a concrete heap object. + */ +class InitialAnalysis internal constructor( + val allLeaks: List, + val groupedPaths: List> +) + +/** + * A cached reference path produced during the BFS phase of the initial analysis. + * + * @param objectIds Ordered list of heap object IDs corresponding to the nodes in the associated + * [LeakTrace]. Index 0 is the GC root object; the last index ([LeakTrace.referencePath].size) + * is the leaking object, matching [LeakTrace.leakingObject]. + */ +class CachedPath internal constructor( + internal val shortestPath: ShortestPath, + val objectIds: List +) + +/** + * Drives an interactive leak investigation session over an open heap dump. + * + * ## Design rationale + * + * LeakCanary's analysis pipeline has two expensive stages and two cheap ones: + * + * 1. **Path finding (BFS)** — traverses the entire heap graph to find shortest reference paths + * from GC roots to each retained (leaking) object. This stage takes seconds and dominates + * analysis time. + * 2. **Deduplication** — removes duplicate paths that share the same route through the graph. + * Runs in milliseconds. + * 3. **Inspection + bisecting** — runs [ObjectInspector] implementations on every node in every + * found path, then propagates `LEAKING`/`NOT_LEAKING` statuses via `computeLeakStatuses()`: + * NOT_LEAKING cascades downward toward the leaking object; LEAKING cascades upward toward the + * GC root. The "suspect window" — the references an agent should investigate — is the range + * between the last NOT_LEAKING node and the first LEAKING node. Runs in milliseconds. + * 4. **Trace building** — assembles [LeakTrace] data objects from the inspection results. + * Milliseconds. + * + * [analyze] runs all four stages once and caches the [ShortestPath] objects from stage 1+2 in + * the returned [InitialAnalysis]. [reinspectPath] re-runs only stages 3+4 for a single path, + * so the agent's status overrides are reflected immediately without repeating the expensive BFS. + * + * ## Override precedence + * + * Status overrides are injected as an [ObjectInspector] appended **last** to the standard + * inspector list. When an override fires it clears the *opposing* reason set on [ObjectReporter] + * before adding its own reason, eliminating any conflict before `resolveStatus()` is called. + * Because `resolveStatus()` only sees one non-empty set, the override always wins regardless of + * what the standard inspectors said — no `leakingWins` flag magic needed. + * + * Example: a standard inspector marks a `View` as NOT_LEAKING ("View not attached"). The agent + * believes the View should be LEAKING. The override inspector clears `notLeakingReasons` and + * adds to `leakingReasons`. `resolveStatus()` sees only LEAKING → override wins. + * + * @param referenceMatchers Matchers guiding BFS traversal and identifying known library leaks. + * Typically [AndroidReferenceMatchers.appDefaults]. + * @param objectInspectors Inspectors that label heap objects and determine leaking status. + * Typically [AndroidObjectInspectors.appDefaults]. + */ +class LeakInvestigationSession( + private val referenceMatchers: List, + private val objectInspectors: List +) { + + /** + * Runs the full analysis pipeline (BFS path finding + inspection + bisecting) once and caches + * the found paths. Call this once per heap dump session. + * + * @param graph The open heap graph from the hprof file. + * @param leakingObjectIds IDs of the retained objects to trace from GC roots. + * @return [InitialAnalysis] with all found leaks and their cached paths for re-inspection. + */ + fun analyze( + graph: HeapGraph, + leakingObjectIds: Set + ): InitialAnalysis { + val result = buildFactory().findLeaksWithCachedPaths(graph, leakingObjectIds) + + val allLeaks: List = result.applicationLeaks + result.libraryLeaks + + val groupedPaths = result.groupedPaths.map { groupPaths -> + groupPaths.map { shortestPath -> + CachedPath(shortestPath, extractObjectIds(shortestPath)) + } + } + + return InitialAnalysis(allLeaks, groupedPaths) + } + + /** + * Re-runs only the inspection + bisecting stages (no BFS) for a single [CachedPath], applying + * [statusOverrides] on top of the standard inspectors. + * + * The returned [LeakTrace] can be displayed with [LeakTrace.toString] to produce the standard + * LeakCanary human-readable output with updated `Leaking: YES/NO/UNKNOWN` annotations and + * recalculated suspect-window underlines. + * + * @param graph The open heap graph (same instance as used for [analyze]). + * @param cachedPath The path to re-inspect, from [InitialAnalysis.groupedPaths]. + * @param statusOverrides Map of heap object ID to (status, reason). The override inspector + * runs last and clears the opposing reason set so overrides always win over inspectors. + * @return A fresh [LeakTrace] reflecting the current override state. + */ + fun reinspectPath( + graph: HeapGraph, + cachedPath: CachedPath, + statusOverrides: Map> + ): LeakTrace { + val overrideInspector = buildOverrideInspector(statusOverrides) + return buildFactory().reinspectPath(graph, cachedPath.shortestPath, listOf(overrideInspector)) + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private fun buildFactory(): RealLeakTracerFactory { + return RealLeakTracerFactory( + shortestPathFinderFactory = PrioritizingShortestPathFinder.Factory( + listener = { }, + referenceReaderFactory = AndroidReferenceReaderFactory(referenceMatchers), + gcRootProvider = MatchingGcRootProvider(referenceMatchers), + computeRetainedHeapSize = false + ), + objectInspectors = objectInspectors + ) { } + } + + /** + * Builds the override [ObjectInspector]. It runs **last** in the inspector list so it can + * unconditionally win by clearing the opposing reason set: + * - LEAKING override → clears [ObjectReporter.notLeakingReasons], adds to [ObjectReporter.leakingReasons] + * - NOT_LEAKING override → clears [ObjectReporter.leakingReasons], adds to [ObjectReporter.notLeakingReasons] + * - UNKNOWN override → no-op (callers should remove the entry from [statusOverrides] instead) + */ + private fun buildOverrideInspector( + statusOverrides: Map> + ): ObjectInspector = ObjectInspector { reporter -> + val (status, reason) = statusOverrides[reporter.heapObject.objectId] ?: return@ObjectInspector + when (status) { + LEAKING -> { + reporter.notLeakingReasons.clear() + reporter.leakingReasons += reason + } + NOT_LEAKING -> { + reporter.leakingReasons.clear() + reporter.notLeakingReasons += reason + } + LeakingStatus.UNKNOWN -> { /* caller removes the entry from the map */ } + } + } + + /** + * Extracts heap object IDs from a [ShortestPath] in trace order: + * index 0 = GC root object, last index = leaking object. + */ + private fun extractObjectIds(path: ShortestPath): List { + val ids = mutableListOf(path.root.objectId) + path.childPathWithDetails.mapTo(ids) { (childNode, _) -> childNode.objectId } + return ids + } +} diff --git a/shark/shark/src/main/java/shark/RealLeakTracerFactory.kt b/shark/shark/src/main/java/shark/RealLeakTracerFactory.kt index f0eb49d267..b67c61f517 100644 --- a/shark/shark/src/main/java/shark/RealLeakTracerFactory.kt +++ b/shark/shark/src/main/java/shark/RealLeakTracerFactory.kt @@ -551,4 +551,90 @@ class RealLeakTracerFactory constructor( is HeapPrimitiveArray -> heap.arrayClassName } } + + /** + * Runs the full analysis pipeline once: path finding (BFS) + deduplication + inspection + + * bisecting. Returns the grouped leaks together with the cached [ShortestPath] objects so that + * [reinspectPath] can re-run only the cheap inspection+bisecting step without re-doing BFS. + * + * [groupedPaths] is parallel to `(applicationLeaks + libraryLeaks)`: the outer list corresponds + * to each [Leak] group and the inner list corresponds to each [LeakTrace] instance inside that + * group, in the same order. + */ + internal fun findLeaksWithCachedPaths( + graph: HeapGraph, + leakingObjectIds: Set + ): LeaksWithCachedPaths { + val helpers = FindLeakInput(graph, shortestPathFinderFactory.createFor(graph), objectInspectors) + val pathFindingResults = helpers.shortestPathFinder.findShortestPathsFromGcRoots(leakingObjectIds) + val shortestPaths = deduplicateShortestPaths(pathFindingResults.pathsToLeakingObjects) + val inspectedObjectsByPath = helpers.inspectObjects(shortestPaths) + + // Build grouped leaks (same logic as buildLeakTraces but without retained sizes). + val (applicationLeaks, libraryLeaks) = helpers.buildLeakTraces(shortestPaths, inspectedObjectsByPath, null) + + // Build a parallel grouping of ShortestPaths matching the (applicationLeaks + libraryLeaks) + // ordering. We replicate the same map-keyed grouping that buildLeakTraces uses so the indices + // align exactly. + val appPathsMap = linkedMapOf>() + val libPathsMap = linkedMapOf>() + + shortestPaths.forEachIndexed { pathIndex, shortestPath -> + val inspectedObjects = inspectedObjectsByPath[pathIndex] + val leakTraceObjects = buildLeakTraceObjects(inspectedObjects, null) + val referencePath = helpers.buildReferencePath(shortestPath, leakTraceObjects) + val leakTrace = LeakTrace( + gcRootType = GcRootType.fromGcRoot(shortestPath.root.gcRoot), + referencePath = referencePath, + leakingObject = leakTraceObjects.last() + ) + val firstLibraryLeakMatcher = shortestPath.firstLibraryLeakMatcher() + if (firstLibraryLeakMatcher != null) { + val signature = firstLibraryLeakMatcher.pattern.toString().createSHA1Hash() + libPathsMap.getOrPut(signature) { mutableListOf() } += shortestPath + } else { + appPathsMap.getOrPut(leakTrace.signature) { mutableListOf() } += shortestPath + } + } + + val groupedPaths = (appPathsMap.values + libPathsMap.values).map { it.toList() } + return LeaksWithCachedPaths(applicationLeaks, libraryLeaks, groupedPaths) + } + + /** + * Re-runs only the inspection + bisecting stages (stages 3 and 4) for a single previously + * found [ShortestPath], injecting [additionalInspectors] at the end of the inspector list. + * + * This is fast — no BFS graph traversal. Used by [LeakInvestigationSession] to reflect + * status overrides entered by the user/agent without restarting the entire analysis. + */ + internal fun reinspectPath( + graph: HeapGraph, + path: ShortestPath, + additionalInspectors: List + ): LeakTrace { + val allInspectors = objectInspectors + additionalInspectors + val helpers = FindLeakInput(graph, shortestPathFinderFactory.createFor(graph), allInspectors) + val inspectedObjects = helpers.inspectObjects(listOf(path)).first() + val leakTraceObjects = buildLeakTraceObjects(inspectedObjects, null) + val referencePath = helpers.buildReferencePath(path, leakTraceObjects) + return LeakTrace( + gcRootType = GcRootType.fromGcRoot(path.root.gcRoot), + referencePath = referencePath, + leakingObject = leakTraceObjects.last() + ) + } + + /** + * Result of [findLeaksWithCachedPaths]. Holds the grouped leaks alongside the [ShortestPath] + * objects cached from the BFS run, organised in the same nested structure so callers can map + * `(groupIndex, traceIndex)` to the corresponding [ShortestPath] for re-inspection. + */ + internal class LeaksWithCachedPaths( + val applicationLeaks: List, + val libraryLeaks: List, + /** Outer list = leak groups (parallel to applicationLeaks + libraryLeaks). + * Inner list = trace instances within a group (parallel to Leak.leakTraces). */ + val groupedPaths: List> + ) } diff --git a/shark/shark/src/main/java/shark/SingleObjectRetainedSizeCalculator.kt b/shark/shark/src/main/java/shark/SingleObjectRetainedSizeCalculator.kt new file mode 100644 index 0000000000..78d7a9ee17 --- /dev/null +++ b/shark/shark/src/main/java/shark/SingleObjectRetainedSizeCalculator.kt @@ -0,0 +1,145 @@ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +package shark + +import java.util.ArrayDeque +import shark.HeapObject.HeapClass +import shark.HeapObject.HeapInstance +import shark.HeapObject.HeapObjectArray +import shark.HeapObject.HeapPrimitiveArray +import shark.ValueHolder.ReferenceHolder +import shark.internal.hppc.LongScatterSet + +/** + * Computes the retained size of a single heap object by determining which objects it dominates. + * + * An object X *dominates* object Y if every path from a GC root to Y must pass through X. + * The retained size of X is the sum of the shallow sizes of X and all objects X dominates — + * in other words, the total memory that would be freed if X were garbage-collected. + * + * ## Algorithm + * + * This uses the "exclude-and-reach" (two-BFS) method, which is the standard correct approach + * for computing a single object's dominance set without building a full dominator tree: + * + * 1. **BFS without X**: traverse the heap from all GC roots, treating X as removed. Mark every + * object reached as "reachable-without-X". + * + * 2. **BFS from X**: traverse the heap starting from X. Every object that is reachable from X + * but NOT in the "reachable-without-X" set is exclusively dominated by X. + * + * 3. **Sum sizes**: the retained size equals the sum of the shallow sizes of X and its dominated + * set. + * + * This is provably correct for any directed reference graph, without any assumptions about + * traversal order. + * + * ## Object size + * + * Shallow sizes are computed by [AndroidObjectSizeCalculator], which combines: + * - The declared instance-field byte size from the HPROF class dump (what the VM reports as the + * object's on-heap footprint). + * - Any native-side allocations tracked via [AndroidNativeSizeMapper] (e.g. Bitmap pixel + * buffers stored in native memory but associated with a heap object). + * + * This matches what LeakCanary's [HeapAnalyzer] and Android Studio's Memory Profiler report for + * retained sizes on Android. Pure JVM tools such as Eclipse MAT only count on-heap field sizes + * without the native supplement; we include it because on Android a significant fraction of an + * object's true memory cost often lives in native memory. + */ +class SingleObjectRetainedSizeCalculator(private val graph: HeapGraph) { + + /** + * @param objectId the heap ID of the object whose retained size was computed. + * @param retainedObjectCount the total number of objects in the retained set, including + * [objectId] itself. + * @param retainedSize total retained memory: [objectId] plus all dominated objects. + */ + data class Result( + val objectId: Long, + val retainedObjectCount: Int, + val retainedSize: ByteSize + ) + + /** + * Computes the retained size of the object identified by [objectId]. + * + * This traverses the entire heap twice and is therefore a slow operation on large heap dumps. + */ + fun computeRetainedSize(objectId: Long): Result { + // ------------------------------------------------------------------ + // Step 1: BFS from all GC roots, bypassing objectId. + // Every object reached here is reachable WITHOUT objectId. + // ------------------------------------------------------------------ + val reachableWithout = LongScatterSet(graph.objectCount / 4) + val queue = ArrayDeque() + + for (gcRoot in graph.gcRoots) { + val rootId = gcRoot.id + if (rootId == ValueHolder.NULL_REFERENCE || !graph.objectExists(rootId)) continue + if (rootId == objectId) continue + if (reachableWithout.add(rootId)) queue.add(rootId) + } + + while (queue.isNotEmpty()) { + val current = queue.poll() + val obj = graph.findObjectByIdOrNull(current) ?: continue + for (refId in outgoingObjectIds(obj)) { + if (refId == objectId) continue + if (reachableWithout.add(refId)) queue.add(refId) + } + } + + // ------------------------------------------------------------------ + // Step 2: BFS from objectId. + // Objects not in reachableWithout are exclusively dominated by objectId. + // ------------------------------------------------------------------ + val retained = LongScatterSet() + if (graph.objectExists(objectId)) { + retained.add(objectId) + queue.add(objectId) + while (queue.isNotEmpty()) { + val current = queue.poll() + val obj = graph.findObjectByIdOrNull(current) ?: continue + for (refId in outgoingObjectIds(obj)) { + if (!reachableWithout.contains(refId) && retained.add(refId)) { + queue.add(refId) + } + } + } + } + + // ------------------------------------------------------------------ + // Step 3: Sum shallow (+ native) sizes over the retained set. + // ------------------------------------------------------------------ + val sizeCalculator = AndroidObjectSizeCalculator(graph) + var totalBytes = 0L + retained.elementSequence().forEach { id -> + totalBytes += sizeCalculator.computeSize(id) + } + + return Result( + objectId = objectId, + retainedObjectCount = retained.size(), + retainedSize = ByteSize(totalBytes) + ) + } + + /** + * Returns the object IDs of all objects directly referenced by [obj]. + * Primitive arrays have no object references; all other types are fully traversed. + */ + private fun outgoingObjectIds(obj: HeapObject): Sequence = when (obj) { + is HeapInstance -> obj.readFields().mapNotNull { field -> + val holder = field.value.holder + if (holder is ReferenceHolder && !holder.isNull) holder.value else null + } + is HeapObjectArray -> obj.readRecord().elementIds.asSequence() + .filter { it != ValueHolder.NULL_REFERENCE } + is HeapClass -> obj.readStaticFields().mapNotNull { field -> + val holder = field.value.holder + if (holder is ReferenceHolder && !holder.isNull) holder.value else null + } + is HeapPrimitiveArray -> emptySequence() + } +} diff --git a/shark/shark/src/main/java/shark/internal/ShallowSizeCalculator.kt b/shark/shark/src/main/java/shark/internal/ShallowSizeCalculator.kt index f807e4a122..01cd9d610e 100644 --- a/shark/shark/src/main/java/shark/internal/ShallowSizeCalculator.kt +++ b/shark/shark/src/main/java/shark/internal/ShallowSizeCalculator.kt @@ -7,6 +7,15 @@ import shark.HeapObject.HeapObjectArray import shark.HeapObject.HeapPrimitiveArray import shark.ObjectArrayReferenceReader.Companion.isSkippablePrimitiveWrapperArray import shark.ValueHolder +import shark.ValueHolder.BooleanHolder +import shark.ValueHolder.ByteHolder +import shark.ValueHolder.CharHolder +import shark.ValueHolder.DoubleHolder +import shark.ValueHolder.FloatHolder +import shark.ValueHolder.IntHolder +import shark.ValueHolder.LongHolder +import shark.ValueHolder.ReferenceHolder +import shark.ValueHolder.ShortHolder /** * Provides approximations for the shallow size of objects in memory. @@ -18,6 +27,14 @@ import shark.ValueHolder */ internal class ShallowSizeCalculator(private val graph: HeapGraph) { + // Object arrays: header = RoundUp(kFirstElementOffset=12, component_size). + // Component size for object arrays equals identifierByteSize (4B or 8B), so + // all object arrays in a heap share the same header. Primitive arrays compute + // their header locally because the component size varies per array type. + // See art/runtime/mirror/array.h (DataOffset, kFirstElementOffset) + // art/runtime/mirror/array-inl.h (Array::SizeOf) + private val arrayHeader: Int = if (graph.identifierByteSize == 8) 16 else 12 + fun computeShallowSize(objectId: Long): Int { return when (val heapObject = graph.findObjectById(objectId)) { is HeapInstance -> { @@ -37,13 +54,15 @@ internal class ShallowSizeCalculator(private val graph: HeapGraph) { heapObject.byteSize } } - // Number of elements * object id size + // Array header + element data. + // HPROF OBJECT_ARRAY_DUMP and PRIMITIVE_ARRAY_DUMP records store only element data, + // not the object header, so heapObject.byteSize is element data only. We add the header. is HeapObjectArray -> { if (heapObject.isSkippablePrimitiveWrapperArray) { // In PathFinder we ignore references from primitive wrapper arrays when building the // dominator tree, so we add that size back here. val elementIds = heapObject.readRecord().elementIds - val shallowSize = elementIds.size * graph.identifierByteSize + val shallowSize = arrayHeader + elementIds.size * graph.identifierByteSize val firstNonNullElement = elementIds.firstOrNull { it != ValueHolder.NULL_REFERENCE } if (firstNonNullElement != null) { val sizeOfOneElement = computeShallowSize(firstNonNullElement) @@ -53,13 +72,54 @@ internal class ShallowSizeCalculator(private val graph: HeapGraph) { shallowSize } } else { - heapObject.byteSize + arrayHeader + heapObject.byteSize } } - // Number of elements * primitive type size - is HeapPrimitiveArray -> heapObject.byteSize - // This is probably way off but is a cheap approximation. - is HeapClass -> heapObject.recordSize + is HeapPrimitiveArray -> { + // Primitive component size determines alignment: long/double (8B) → 16B header, else 12B. + val primitiveArrayHeader = if (heapObject.primitiveType.byteSize == 8) 16 else 12 + primitiveArrayHeader + heapObject.byteSize + } + is HeapClass -> { + // mirror::Class in ART is variable-size: fixed_header + static_field_values. + // (art/runtime/mirror/class.h: class_size_ tracks the total; fields_[] at the end) + // + // In Android HPROF, class objects have NO INSTANCE_DUMP record — each class is + // represented solely by its CLASS_DUMP record (art/runtime/hprof/hprof.cc). + // java.lang.Class.instanceByteSize == 0 in Android HPROF because ART special-cases it, + // so we cannot recover the fixed mirror::Class header (~100–130 B of internal C++ + // fields: vtable, class_flags_, dex_cache_, etc.) from HPROF data. + // + // We therefore sum two approximable portions: + // + // 1. Static field values: the variable in-object portion of class_size_ — readable + // from CLASS_DUMP via readRecordStaticFields(). + // + // 2. ArtField metadata: each declared field (instance or static) has a corresponding + // ArtField object in ART's LinearAlloc native memory — NOT on the Java heap, hence + // absent from HPROF. sizeof(ArtField) == 16 on all Android targets: four uint32_t + // fields (declaring_class_ GcRoot, access_flags_, field_dex_idx_, offset_). + // (art/runtime/art_field.h) + // The count is cheaply readable from CLASS_DUMP, so we include this native cost. + // + // What remains uncaptured: the fixed mirror::Class header and ArtMethod metadata. + val staticFieldRecords = heapObject.readRecordStaticFields() + val staticValueSize = staticFieldRecords.sumOf { holderSize(it.value) } + val artFieldBytes = (staticFieldRecords.size + heapObject.readInstanceFieldCount()) * 16 + staticValueSize + artFieldBytes + } } } + + private fun holderSize(holder: ValueHolder): Int = when (holder) { + is ReferenceHolder -> graph.identifierByteSize + is BooleanHolder -> 1 + is CharHolder -> 2 + is FloatHolder -> 4 + is DoubleHolder -> 8 + is ByteHolder -> 1 + is ShortHolder -> 2 + is IntHolder -> 4 + is LongHolder -> 8 + } } diff --git a/shark/shark/src/test/java/shark/LeakInvestigationSessionTest.kt b/shark/shark/src/test/java/shark/LeakInvestigationSessionTest.kt new file mode 100644 index 0000000000..35af02ec4d --- /dev/null +++ b/shark/shark/src/test/java/shark/LeakInvestigationSessionTest.kt @@ -0,0 +1,384 @@ +package shark + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import shark.HeapObject.HeapInstance +import shark.HprofHeapGraph.Companion.openHeapGraph +import shark.LeakTraceObject.LeakingStatus.LEAKING +import shark.LeakTraceObject.LeakingStatus.NOT_LEAKING +import shark.LeakTraceObject.LeakingStatus.UNKNOWN +import shark.ValueHolder.ReferenceHolder + +/** + * Tests for [LeakInvestigationSession]: verifies the split analysis pipeline (one-time BFS + + * cheap re-inspection) and the override-wins-always invariant. + */ +class LeakInvestigationSessionTest { + + // Session with no custom inspectors beyond KEYED_WEAK_REFERENCE. + private val session = LeakInvestigationSession( + referenceMatchers = JdkReferenceMatchers.defaults, + objectInspectors = listOf(ObjectInspectors.KEYED_WEAK_REFERENCE) + ) + + // --------------------------------------------------------------------------- + // Helper: find leaking object IDs the same way AiInvestigateCommand does, + // but using the JDK filter set (which works with the watchedInstance DSL). + // --------------------------------------------------------------------------- + + private fun leakingObjectIds(graph: HeapGraph): Set = + FilteringLeakingObjectFinder(ObjectInspectors.jdkLeakingObjectFilters) + .findLeakingObjectIds(graph) + + // --------------------------------------------------------------------------- + // Helper: session with an extra inspector appended before KEYED_WEAK_REFERENCE. + // --------------------------------------------------------------------------- + + private fun sessionWithInspector(vararg extra: ObjectInspector) = LeakInvestigationSession( + referenceMatchers = JdkReferenceMatchers.defaults, + objectInspectors = extra.toList() + listOf(ObjectInspectors.KEYED_WEAK_REFERENCE) + ) + + private fun notLeakingInstance(className: String): ObjectInspector = ObjectInspector { reporter -> + val obj = reporter.heapObject + if (obj is HeapInstance && obj.instanceClassName == className) { + reporter.notLeakingReasons += "$className is not leaking" + } + } + + private fun leakingInstance(className: String): ObjectInspector = ObjectInspector { reporter -> + val obj = reporter.heapObject + if (obj is HeapInstance && obj.instanceClassName == className) { + reporter.leakingReasons += "$className is leaking" + } + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + @Test fun analyzeFindsLeakGroup() { + val heapDump = dump { + "GcRoot" clazz { + staticField["leak"] = "Leaking" watchedInstance {} + } + } + + heapDump.openHeapGraph().use { graph -> + val analysis = session.analyze(graph, leakingObjectIds(graph)) + + assertThat(analysis.allLeaks).hasSize(1) + assertThat(analysis.groupedPaths).hasSize(1) + assertThat(analysis.groupedPaths[0]).hasSize(1) + } + } + + @Test fun analyzeGroupsMultipleInstancesOfSameLeak() { + // Build: GcRoot.holders → Holder[] → [x] → Holder.child → Watched (leaking) + // Array entries all produce referenceGenericName "[x]" → both paths share same signature. + val heapDump = dump { + val watchedClassId = clazz(className = "Watched") + val holderClassId = clazz( + className = "Holder", + fields = listOf("child" to ReferenceHolder::class) + ) + val holderArrayClassId = arrayClass("Holder") + + val watched1 = instance(watchedClassId) + keyedWeakReference(watched1) + val watched2 = instance(watchedClassId) + keyedWeakReference(watched2) + + val holder1 = instance(holderClassId, listOf(watched1)) + val holder2 = instance(holderClassId, listOf(watched2)) + + clazz( + className = "GcRoot", + staticFields = listOf("holders" to objectArrayOf(holderArrayClassId, holder1, holder2)) + ) + } + + heapDump.openHeapGraph().use { graph -> + val analysis = session.analyze(graph, leakingObjectIds(graph)) + + // Both paths: GcRoot.holders[x].child → Watched share the same signature → one group + assertThat(analysis.allLeaks).hasSize(1) + assertThat(analysis.groupedPaths[0]).hasSize(2) + } + } + + @Test fun reinspectDefaultsToUnknownForMiddleNode() { + val heapDump = dump { + "GcRoot" clazz { + staticField["f"] = "Class1" instance { + field["g"] = "Leaking" watchedInstance {} + } + } + } + + heapDump.openHeapGraph().use { graph -> + val analysis = session.analyze(graph, leakingObjectIds(graph)) + val cachedPath = analysis.groupedPaths[0][0] + + val trace = session.reinspectPath(graph, cachedPath, emptyMap()) + + // Node at referencePath[1] is Class1 — no inspector opinion → UNKNOWN + assertThat(trace.referencePath[1].originObject.leakingStatus).isEqualTo(UNKNOWN) + } + } + + @Test fun markLeakingOverrideChangesNodeStatus() { + val heapDump = dump { + "GcRoot" clazz { + staticField["f"] = "Class1" instance { + field["g"] = "Leaking" watchedInstance {} + } + } + } + + heapDump.openHeapGraph().use { graph -> + val analysis = session.analyze(graph, leakingObjectIds(graph)) + val cachedPath = analysis.groupedPaths[0][0] + + // objectIds[1] = Class1 instance (index 0 = GcRoot class) + val class1Id = cachedPath.objectIds[1] + val trace = session.reinspectPath( + graph, cachedPath, + mapOf(class1Id to (LEAKING to "agent override")) + ) + + assertThat(trace.referencePath[1].originObject.leakingStatus).isEqualTo(LEAKING) + assertThat(trace.referencePath[1].originObject.leakingStatusReason) + .isEqualTo("agent override") + } + } + + @Test fun markNotLeakingOverrideChangesNodeStatus() { + val heapDump = dump { + "GcRoot" clazz { + staticField["f"] = "Class1" instance { + field["g"] = "Leaking" watchedInstance {} + } + } + } + + heapDump.openHeapGraph().use { graph -> + val analysis = session.analyze(graph, leakingObjectIds(graph)) + val cachedPath = analysis.groupedPaths[0][0] + + val class1Id = cachedPath.objectIds[1] + val trace = session.reinspectPath( + graph, cachedPath, + mapOf(class1Id to (NOT_LEAKING to "agent override")) + ) + + assertThat(trace.referencePath[1].originObject.leakingStatus).isEqualTo(NOT_LEAKING) + assertThat(trace.referencePath[1].originObject.leakingStatusReason) + .isEqualTo("agent override") + } + } + + @Test fun overrideWinsOverConflictingInspector() { + // Standard inspector votes NOT_LEAKING for Class1; override votes LEAKING → LEAKING wins. + val heapDump = dump { + "GcRoot" clazz { + staticField["f"] = "Class1" instance { + field["g"] = "Leaking" watchedInstance {} + } + } + } + + val s = sessionWithInspector(notLeakingInstance("Class1")) + + heapDump.openHeapGraph().use { graph -> + val analysis = s.analyze(graph, leakingObjectIds(graph)) + val cachedPath = analysis.groupedPaths[0][0] + + // Verify inspector verdict before override + val traceBefore = s.reinspectPath(graph, cachedPath, emptyMap()) + assertThat(traceBefore.referencePath[1].originObject.leakingStatus).isEqualTo(NOT_LEAKING) + + // Apply LEAKING override — must beat the inspector's NOT_LEAKING + val class1Id = cachedPath.objectIds[1] + val traceAfter = s.reinspectPath( + graph, cachedPath, + mapOf(class1Id to (LEAKING to "agent override")) + ) + assertThat(traceAfter.referencePath[1].originObject.leakingStatus).isEqualTo(LEAKING) + assertThat(traceAfter.referencePath[1].originObject.leakingStatusReason) + .isEqualTo("agent override") + } + } + + @Test fun notLeakingOverridePropagatesUpwardToEarlierNodes() { + // Path: GcRoot → Class1 → Class2 → Class3 → Leaking + // Marking Class3 NOT_LEAKING must force Class1 and Class2 to NOT_LEAKING too. + val heapDump = dump { + "GcRoot" clazz { + staticField["f1"] = "Class1" instance { + field["f2"] = "Class2" instance { + field["f3"] = "Class3" instance { + field["f4"] = "Leaking" watchedInstance {} + } + } + } + } + } + + heapDump.openHeapGraph().use { graph -> + val analysis = session.analyze(graph, leakingObjectIds(graph)) + val cachedPath = analysis.groupedPaths[0][0] + + // objectIds: [0]=GcRoot, [1]=Class1, [2]=Class2, [3]=Class3, [4]=Leaking + val class3Id = cachedPath.objectIds[3] + val trace = session.reinspectPath( + graph, cachedPath, + mapOf(class3Id to (NOT_LEAKING to "agent override")) + ) + + // Class3 itself is NOT_LEAKING + assertThat(trace.referencePath[3].originObject.leakingStatus).isEqualTo(NOT_LEAKING) + // Class1 and Class2 are forced NOT_LEAKING because a node below them is NOT_LEAKING + assertThat(trace.referencePath[1].originObject.leakingStatus).isEqualTo(NOT_LEAKING) + assertThat(trace.referencePath[2].originObject.leakingStatus).isEqualTo(NOT_LEAKING) + } + } + + @Test fun leakingOverridePropagatesDownwardToLaterNodes() { + // Path: GcRoot → Class1 → Class2 → Class3 → Leaking + // Marking Class1 LEAKING must force Class2 and Class3 to LEAKING. + val heapDump = dump { + "GcRoot" clazz { + staticField["f1"] = "Class1" instance { + field["f2"] = "Class2" instance { + field["f3"] = "Class3" instance { + field["f4"] = "Leaking" watchedInstance {} + } + } + } + } + } + + heapDump.openHeapGraph().use { graph -> + val analysis = session.analyze(graph, leakingObjectIds(graph)) + val cachedPath = analysis.groupedPaths[0][0] + + val class1Id = cachedPath.objectIds[1] + val trace = session.reinspectPath( + graph, cachedPath, + mapOf(class1Id to (LEAKING to "agent override")) + ) + + // Class1 is LEAKING + assertThat(trace.referencePath[1].originObject.leakingStatus).isEqualTo(LEAKING) + // Class2 and Class3 are forced LEAKING because a node above them is LEAKING + assertThat(trace.referencePath[2].originObject.leakingStatus).isEqualTo(LEAKING) + assertThat(trace.referencePath[3].originObject.leakingStatus).isEqualTo(LEAKING) + } + } + + @Test fun clearOverrideResetsNodeToInspectorDefault() { + val heapDump = dump { + "GcRoot" clazz { + staticField["f"] = "Class1" instance { + field["g"] = "Leaking" watchedInstance {} + } + } + } + + val s = sessionWithInspector(notLeakingInstance("Class1")) + + heapDump.openHeapGraph().use { graph -> + val analysis = s.analyze(graph, leakingObjectIds(graph)) + val cachedPath = analysis.groupedPaths[0][0] + val class1Id = cachedPath.objectIds[1] + + // Apply LEAKING override + val traceWithOverride = s.reinspectPath( + graph, cachedPath, + mapOf(class1Id to (LEAKING to "agent override")) + ) + assertThat(traceWithOverride.referencePath[1].originObject.leakingStatus).isEqualTo(LEAKING) + + // Remove override → inspector default (NOT_LEAKING) should be restored + val traceCleared = s.reinspectPath(graph, cachedPath, emptyMap()) + assertThat(traceCleared.referencePath[1].originObject.leakingStatus).isEqualTo(NOT_LEAKING) + } + } + + @Test fun overrideAppliedConsistentlyAcrossTraceInstances() { + // Same array-based heap structure as analyzeGroupsMultipleInstancesOfSameLeak. + // Path: GcRoot.holders[x] → Holder.child → Watched (leaking) + // Overriding one Holder by object ID should not affect the other trace's Holder. + val heapDump = dump { + val watchedClassId = clazz(className = "Watched") + val holderClassId = clazz( + className = "Holder", + fields = listOf("child" to ReferenceHolder::class) + ) + val holderArrayClassId = arrayClass("Holder") + + val watched1 = instance(watchedClassId) + keyedWeakReference(watched1) + val watched2 = instance(watchedClassId) + keyedWeakReference(watched2) + + val holder1 = instance(holderClassId, listOf(watched1)) + val holder2 = instance(holderClassId, listOf(watched2)) + + clazz( + className = "GcRoot", + staticFields = listOf("holders" to objectArrayOf(holderArrayClassId, holder1, holder2)) + ) + } + + heapDump.openHeapGraph().use { graph -> + val analysis = session.analyze(graph, leakingObjectIds(graph)) + + // Two trace instances in the single group + assertThat(analysis.groupedPaths[0]).hasSize(2) + + val cachedPath0 = analysis.groupedPaths[0][0] + val cachedPath1 = analysis.groupedPaths[0][1] + + // objectIds: [0]=GcRoot, [1]=Holder[], [2]=Holder instance, [3]=Watched + val holder0Id = cachedPath0.objectIds[2] + val override = mapOf(holder0Id to (NOT_LEAKING to "agent override")) + + // Trace 0: Holder is overridden → NOT_LEAKING + val trace0 = session.reinspectPath(graph, cachedPath0, override) + assertThat(trace0.referencePath[2].originObject.leakingStatus).isEqualTo(NOT_LEAKING) + + // Trace 1 has a different Holder instance (different object ID) → override does not apply + val trace1 = session.reinspectPath(graph, cachedPath1, override) + assertThat(trace1.referencePath[2].originObject.leakingStatus).isEqualTo(UNKNOWN) + } + } + + @Test fun objectIdsListCoversAllTraceNodes() { + val heapDump = dump { + "GcRoot" clazz { + staticField["f1"] = "Class1" instance { + field["f2"] = "Class2" instance { + field["f3"] = "Leaking" watchedInstance {} + } + } + } + } + + heapDump.openHeapGraph().use { graph -> + val analysis = session.analyze(graph, leakingObjectIds(graph)) + val cachedPath = analysis.groupedPaths[0][0] + val trace = session.reinspectPath(graph, cachedPath, emptyMap()) + + // objectIds: [GcRoot, Class1, Class2, Leaking] = referencePath.size + 1 + val expectedSize = trace.referencePath.size + 1 + assertThat(cachedPath.objectIds).hasSize(expectedSize) + + // Last objectId matches the leaking object (accessible via graph) + val leakingObjectId = cachedPath.objectIds.last() + val leakingObj = graph.findObjectByIdOrNull(leakingObjectId) + assertThat(leakingObj).isNotNull() + } + } +} diff --git a/shark/shark/src/test/java/shark/ObjectGrowthDetectorTest.kt b/shark/shark/src/test/java/shark/ObjectGrowthDetectorTest.kt index c4df0eea80..b403fa66fd 100644 --- a/shark/shark/src/test/java/shark/ObjectGrowthDetectorTest.kt +++ b/shark/shark/src/test/java/shark/ObjectGrowthDetectorTest.kt @@ -103,7 +103,7 @@ class ObjectGrowthDetectorTest { val growingObject = heapTraversal.growingObjects.single() assertThat(growingObject.retainedIncrease.objectCount).isEqualTo(1) - val expectedRetainedSizeIncrease = (12 + "World!".length * 2).bytes + val expectedRetainedSizeIncrease = (24 + "World!".length * 2).bytes assertThat(growingObject.retainedIncrease.heapSize).isEqualTo(expectedRetainedSizeIncrease) } @@ -126,7 +126,7 @@ class ObjectGrowthDetectorTest { val growingObject = heapTraversal.growingObjects.single() assertThat(growingObject.retainedIncrease.objectCount).isEqualTo(1) - val expectedRetainedSizeIncrease = (12 + "Turtles".length * 2).bytes + val expectedRetainedSizeIncrease = (24 + "Turtles".length * 2).bytes assertThat(growingObject.retainedIncrease.heapSize).isEqualTo(expectedRetainedSizeIncrease) } diff --git a/shark/shark/src/test/java/shark/RetainedSizeTest.kt b/shark/shark/src/test/java/shark/RetainedSizeTest.kt index fe00929ac9..e84b6128bc 100644 --- a/shark/shark/src/test/java/shark/RetainedSizeTest.kt +++ b/shark/shark/src/test/java/shark/RetainedSizeTest.kt @@ -65,8 +65,8 @@ class RetainedSizeTest { val retainedSize = retainedInstances() .firstRetainedSize() - // 4 byte reference, 2 bytes per char - assertThat(retainedSize).isEqualTo(8) + // 4 byte reference, 2 bytes per char, 12 byte array header + assertThat(retainedSize).isEqualTo(20) } @Test fun leakingInstanceWithString() { @@ -81,8 +81,8 @@ class RetainedSizeTest { val retainedSize = retainedInstances() .firstRetainedSize() - // 4 byte reference, string (4 array ref + 4 int + 2 byte per char) - assertThat(retainedSize).isEqualTo(16) + // 4 byte reference, string (4 array ref + 4 int + 12 byte array header + 2 byte per char) + assertThat(retainedSize).isEqualTo(28) } @Test fun leakingInstanceWithInstance() { @@ -145,8 +145,8 @@ class RetainedSizeTest { val retainedSize = retainedInstances() .firstRetainedSize() - // 4 byte reference * 3, 2 ints - assertThat(retainedSize).isEqualTo(20) + // 4 byte reference * 3, 2 ints, 12 byte array header + assertThat(retainedSize).isEqualTo(32) } @Test fun leakingInstanceWithObjectArray() { @@ -161,8 +161,8 @@ class RetainedSizeTest { val retainedSize = retainedInstances() .firstRetainedSize() - // 4 byte reference, 4 bytes per object entry - assertThat(retainedSize).isEqualTo(12) + // 4 byte reference, 4 bytes per object entry, 12 byte array header + assertThat(retainedSize).isEqualTo(24) } @Test fun leakingInstanceWithDeepRetainedObjects() { @@ -181,8 +181,8 @@ class RetainedSizeTest { val retainedSize = retainedInstances() .firstRetainedSize() - // 4 byte reference * 3, string (4 array ref + 4 int + 2 byte per char) - assertThat(retainedSize).isEqualTo(24) + // 4 byte reference * 3, string (4 array ref + 4 int + 12 byte array header + 2 byte per char) + assertThat(retainedSize).isEqualTo(36) } @Test fun leakingInstanceNotDominating() { @@ -340,8 +340,8 @@ class RetainedSizeTest { val retainedInstances = analysis.applicationLeaks val retainedSize = retainedInstances.firstRetainedSize() - // LongArray(3), 8 bytes per long - assertThat(retainedSize).isEqualTo(3 * 8) + // LongArray(3), 8 bytes per long, 16 byte array header (long component_size == 8B) + assertThat(retainedSize).isEqualTo(3 * 8 + 16) } private fun retainedInstances(): List { diff --git a/shark/shark/src/test/java/shark/internal/ShallowSizeCalculatorTest.kt b/shark/shark/src/test/java/shark/internal/ShallowSizeCalculatorTest.kt index ee45a4905f..1226def52b 100644 --- a/shark/shark/src/test/java/shark/internal/ShallowSizeCalculatorTest.kt +++ b/shark/shark/src/test/java/shark/internal/ShallowSizeCalculatorTest.kt @@ -11,7 +11,7 @@ import shark.ValueHolder.LongHolder import shark.dump import java.io.File -private const val EMPTY_CLASS_SIZE = 42 +private const val EMPTY_CLASS_SIZE = 0 class ShallowSizeCalculatorTest { @@ -96,12 +96,12 @@ class ShallowSizeCalculatorTest { calculator.computeShallowSize(graph.findClassByName("SomeClass")!!.objectId) } - val bytesForFieldId = 4 - val bytesForFieldType = 1 - val bytesForFieldValue = 4 + val bytesForStaticIntFieldValue = 4 + // sizeof(ArtField): declaring_class_ GcRoot + access_flags_ + field_dex_idx_ + offset_ = 4*4 + val bytesForArtField = 16 assertThat(classSize).isEqualTo( - EMPTY_CLASS_SIZE + bytesForFieldId + bytesForFieldType + bytesForFieldValue + EMPTY_CLASS_SIZE + bytesForStaticIntFieldValue + bytesForArtField ) } } \ No newline at end of file