Skip to content

Commit 8e1f3bb

Browse files
authored
Merge pull request #2959 from square/harden-hprof-stripping
Strip heap dumps that Android wrote, arrays too big for an Int, and gzipped ones
2 parents 8a176f3 + c7a1a0e commit 8e1f3bb

13 files changed

Lines changed: 493 additions & 66 deletions

File tree

docs/changelog.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ Releases before 2.8.1 predate these markers.
106106
* 🔨 The LeakCanary task in Android Studio's Profiler keeps working on LeakCanary 3. Studio drives LeakCanary through a bridge library it injects into the app, [`com.android.tools.studio.leakcanary:leakcanary`](https://android.googlesource.com/platform/tools/base/+/refs/heads/mirror-goog-studio-main/studio-leakcanary/), which has no compile time dependency on LeakCanary and instead resolves every class, method and field it needs reflectively, by name, giving up on the whole integration at the first failed lookup — at which point Studio reports LeakCanary as missing from the app. All of those names survived LeakCanary 3 except the default `GcTrigger`, which used to be an object nested in `GcTrigger` and compiled to `leakcanary.GcTrigger$Default`, and is now `GcTrigger.inProcess()`. A class under the old name is published again for that lookup alone, deprecated so that no source reaches for it and documented as un-deletable. `AndroidStudioProfilerContractTest` replays the bridge's lookups against the `leakcanary-android` artifact, so the next rename fails there rather than silently in the IDE.
107107
* ⚠️ The hash that identifies a leak is now called its **leak fingerprint**: `LeakTrace.signature` and `Leak.signature` are `LeakTrace.leakFingerprint` and `Leak.leakFingerprint`, and `EventListener.Event.HeapAnalysisDone.HeapAnalysisSucceeded.unreadLeakSignatures` is `unreadLeakFingerprints`. The hash itself is unchanged — same input, same SHA-1 — so a leak keeps the string it already had, and a bug report or a dashboard grouped by it still lines up across the upgrade. The rename is about the word: *signature* is what a Kotlin or Java developer meeting it for the first time reads as a method signature, or as the signature an APK is signed with, and both of those also appear in LeakCanary's own code and change log. It also never said what the hash is *for*, which is that two leaks with the same one are caused by the same bug.
108108
* 🔀 A heap analysis printed to Logcat or shared as text now reads `Leak fingerprint: <hash>` where it read `Signature: <hash>`, and *"Displaying only 1 leak trace out of N with the same leak fingerprint"* where it read *"…with the same signature"*. Anything parsing that text needs updating.
109+
* 🐛 `HprofPrimitiveArrayStripper` left most primitive wrapper instances wrapping their real value when stripping a heap dump taken from Android, so `HeapAnalysisConfig(stripHeapDump = true)` and `shark-cli strip-hprof` both fell back to zeroing primitive arrays alone. Zeroing a wrapped value needs the field layout of the wrapper class, which the stripper learned from that class's `CLASS_DUMP` record, so it could only strip the instances dumped after it — and ART walks the heap in memory order, which puts the class dump of `java.lang.Integer` after most of the `Integer` instances. On the Android heap dumps in our test resources, 1175 of the 1291 wrapper instances of `leak_asynctask_o.hprof` and 3289 of the 3365 of `leak_asynctask_m.hprof` kept their value. HotSpot writes every class dump ahead of any instance, so heap dumps taken from a JVM — which is all the test covering this used — were stripped correctly. The class id of each wrapper now comes from its `LOAD_CLASS` record, which both ART and HotSpot write before the heap dump starts, and the value is zeroed at the start of the instance field values, which is where the single field a wrapper declares sits: a class declares its own fields ahead of the ones it inherits. The `CLASS_DUMP` record is still read, to check that layout and fail loudly rather than write a heap dump that looks stripped and isn't.
110+
* 💥 [#2777](https://github.com/square/leakcanary/issues/2777) `HprofPrimitiveArrayStripper` failed with a `NegativeArraySizeException` on a heap dump holding a primitive array whose content takes more than 2 GB, which is a `long` or `double` array of more than 268435455 elements. Fixing the parser for that issue covered the object array side of the stripper and left the primitive array side alone: the bytes written over an array's content were allocated as one array sized `arrayLength * elementByteSize` in `Int` arithmetic, which wraps negative that far out, so a `long[268435456]` asked for an array of -2147483648 bytes. Those bytes are now written by repeating an 8192 byte pattern over the content, so nothing is sized from the array at all. Verified on a 2.15 GB heap dump taken from a JVM holding a `long[268435456]`, which strips in 3 seconds.
111+
* 🔨 Not allocating a copy of every array also takes the memory stripping needs from a function of the largest array in the heap dump down to a constant, which matters on Android, where `HeapAnalysisConfig(stripHeapDump = true)` strips in the app's own process. Stripping a 110 MB heap dump holding a `byte[104857600]` used to need a 216 MB heap and completes in 8 MB now. On a 294 MB heap dump pulled from a real app, the bytes allocated go from 374 MB to 34 MB, and stripping is about 5% faster.
112+
* 💥 Reading or stripping a heap dump with 8 byte identifiers that holds a heap dump info record failed with *"Unknown tag 0x00"*. That record holds an `Int` heap id and then a string id, and both the reader skipping it and the stripper copying it over treated the heap id as an id too, so with 8 byte identifiers they moved 4 bytes too far and read the middle of whatever came next as a record tag. Heap dump info records only appear in heap dumps written by Android, which always uses 4 byte identifiers — where the two sizes are the same and the bug can't show — so this is about a heap dump written by `HprofWriter`.
113+
*`shark-cli strip-hprof` and `HprofPrimitiveArrayStripper.stripPrimitiveArrays(File)` now handle gzip on both ends: a heap dump whose content is gzipped is read gzipped whatever it's named, and the output is written gzipped when its name ends with ".gz", which is what the default output name of a ".hprof.gz" input already gave you — so "app.hprof.gz" strips to a gzipped "app-stripped.hprof.gz". The Android heap dumps in our test resources compress 3.9x to 4.4x, and 5.6x to 5.8x once stripped, so a heap dump that's been shared is usually gzipped by the time you get it, and stripping it meant gunzipping it first and gzipping the result again.
114+
*`StreamingSourceProvider.gunzipIfGzipped()` and `StreamingSinkProvider.gzip()` are what do that. They compose onto any source or sink, so a caller of the `stripPrimitiveArrays` overload that takes a source and a sink can opt into the same behavior, or into only one half of it.
115+
*`HprofPrimitiveArrayStripper` and `shark-cli strip-hprof` now say what stripping leaves behind. Everything that isn't a primitive array or a wrapped primitive is copied over unchanged, and that includes the string records holding the class, field and method names the rest of the heap dump refers to. Those hold no runtime data in a heap dump from Android, but a heap dump from a JVM also holds every string constant of every loaded class in them, so stripping a JVM heap dump leaves the constants written in the code behind.
109116

110117
## Version 3.0 Alpha 9 (2026-06-25)
111118

shark/shark-android/src/test/java/shark/LegacyHprofTest.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ import shark.LegacyHprofTest.WRAPS_ACTIVITY.DESTROYED
1313
import shark.LegacyHprofTest.WRAPS_ACTIVITY.NOT_ACTIVITY
1414
import shark.LegacyHprofTest.WRAPS_ACTIVITY.NOT_DESTROYED
1515
import shark.SharkLog.Logger
16+
import shark.ValueHolder.BooleanHolder
17+
import shark.ValueHolder.ByteHolder
18+
import shark.ValueHolder.CharHolder
19+
import shark.ValueHolder.DoubleHolder
20+
import shark.ValueHolder.FloatHolder
21+
import shark.ValueHolder.IntHolder
22+
import shark.ValueHolder.LongHolder
23+
import shark.ValueHolder.ShortHolder
1624

1725
class LegacyHprofTest {
1826

@@ -62,6 +70,31 @@ class LegacyHprofTest {
6270
}
6371
}
6472

73+
@Test fun androidOStrippedWrapsZeroes() {
74+
val stripper = HprofPrimitiveArrayStripper()
75+
val sourceHprof = "leak_asynctask_o.hprof".classpathFile()
76+
val strippedHprof = stripper.stripPrimitiveArrays(sourceHprof)
77+
78+
assertThat(readWrappedPrimitives(sourceHprof)).anyMatch { (className, value) ->
79+
value != ZERO_VALUE_BY_WRAPPER_CLASS_NAME.getValue(className)
80+
}
81+
assertThat(readWrappedPrimitives(strippedHprof)).isNotEmpty.allMatch { (className, value) ->
82+
value == ZERO_VALUE_BY_WRAPPER_CLASS_NAME.getValue(className)
83+
}
84+
}
85+
86+
private fun readWrappedPrimitives(hprofFile: File): List<Pair<String, ValueHolder>> {
87+
return hprofFile.openHeapGraph().use { graph ->
88+
ZERO_VALUE_BY_WRAPPER_CLASS_NAME.keys.flatMap { className ->
89+
val wrapperClass = graph.findClassByName(className) ?: return@flatMap emptyList()
90+
wrapperClass.instances.map { instance ->
91+
className to instance[className, "value"]!!.value.holder
92+
}
93+
.toList()
94+
}
95+
}
96+
}
97+
6598
private fun readThreadNames(hprofFile: File): List<String> {
6699
return hprofFile.openHeapGraph().use { graph ->
67100
graph.findClassByName("java.lang.Thread")!!.instances.map { instance ->
@@ -273,4 +306,18 @@ class LegacyHprofTest {
273306
println(analysis)
274307
return analysis as HeapAnalysisSuccess
275308
}
309+
310+
companion object {
311+
private val ZERO_VALUE_BY_WRAPPER_CLASS_NAME: Map<String, ValueHolder> =
312+
mapOf(
313+
"java.lang.Boolean" to BooleanHolder(false),
314+
"java.lang.Byte" to ByteHolder(0),
315+
"java.lang.Character" to CharHolder(0.toChar()),
316+
"java.lang.Short" to ShortHolder(0),
317+
"java.lang.Integer" to IntHolder(0),
318+
"java.lang.Long" to LongHolder(0),
319+
"java.lang.Float" to FloatHolder(0f),
320+
"java.lang.Double" to DoubleHolder(0.0)
321+
)
322+
}
276323
}

shark/shark-cli/src/main/java/shark/StripHprofCommand.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,18 @@ import shark.SharkCliCommand.Companion.sharkCliParams
66

77
class StripHprofCommand : CliktCommand(
88
name = "strip-hprof",
9-
help = "Replace all primitive arrays from the provided heap dump with arrays of zeroes and generate a new \"-stripped.hprof\" file."
9+
help = """
10+
|Replace all primitive arrays from the provided heap dump with arrays of zeroes and generate a new "-stripped.hprof" file.
11+
|
12+
|Primitive wrapper instances are updated to wrap 0 as well. Everything else is copied over
13+
|unchanged, which includes the string records holding the class, field and method names the rest
14+
|of the heap dump refers to. Those hold no runtime data in a heap dump from Android, but a heap
15+
|dump from a JVM also holds every string constant of every loaded class in them, so stripping a
16+
|JVM heap dump leaves the constants written in the code behind.
17+
|
18+
|A gzipped heap dump is read gzipped and written back gzipped, so "app.hprof.gz" gives you
19+
|"app-stripped.hprof.gz".
20+
""".trimMargin()
1021
) {
1122

1223
override fun run() {

shark/shark-graph/src/test/java/shark/HprofPrimitiveArrayStripperTest.kt

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,38 @@ class HprofPrimitiveArrayStripperTest {
5252
assertThat(strippedBuffer.readByteArray()).isEqualTo(expectedByteArray)
5353
}
5454

55+
@Test
56+
fun `wrapped primitive stripped when the wrapper class is dumped after its instances`() {
57+
val sourceByteArray =
58+
Buffer()
59+
.apply {
60+
writeRawTestHprof(
61+
secretLongArray = longArrayOf(0xCAFE, 0xDAD),
62+
secretCharArray = charArrayOf('P', 'Y'),
63+
secretWrappedLong = 42,
64+
wrapperClassDumpedAfterInstance = true,
65+
)
66+
}
67+
.readByteArray()
68+
69+
val strippedBuffer = Buffer()
70+
val stripper = HprofPrimitiveArrayStripper()
71+
stripper.stripPrimitiveArrays(ByteArraySourceProvider(sourceByteArray), { strippedBuffer })
72+
73+
val expectedByteArray =
74+
Buffer()
75+
.apply {
76+
writeRawTestHprof(
77+
secretLongArray = longArrayOf(0, 0),
78+
secretCharArray = charArrayOf('?', '?'),
79+
secretWrappedLong = 0,
80+
wrapperClassDumpedAfterInstance = true,
81+
)
82+
}
83+
.readByteArray()
84+
assertThat(strippedBuffer.readByteArray()).isEqualTo(expectedByteArray)
85+
}
86+
5587
@Test
5688
fun `ByteArray based String content is replaced with question marks`() {
5789
val hprofFolder = testFolder.newFolder()
@@ -114,6 +146,11 @@ class HprofPrimitiveArrayStripperTest {
114146
secretLongArray: LongArray,
115147
secretCharArray: CharArray,
116148
secretWrappedLong: Byte,
149+
/**
150+
* Android walks the heap in memory order when it dumps it, so a class is often dumped after
151+
* instances of that class.
152+
*/
153+
wrapperClassDumpedAfterInstance: Boolean = false,
117154
) {
118155
HprofWriter.openWriterFor(
119156
this,
@@ -152,7 +189,7 @@ class HprofPrimitiveArrayStripperTest {
152189
fields = emptyList(),
153190
)
154191
)
155-
writer.write(
192+
val wrapperClassDump =
156193
ClassDumpRecord(
157194
id = 2,
158195
stackTraceSerialNumber = 0,
@@ -164,7 +201,9 @@ class HprofPrimitiveArrayStripperTest {
164201
staticFields = emptyList(),
165202
fields = listOf(FieldRecord(3, PrimitiveType.LONG.hprofType)),
166203
)
167-
)
204+
if (!wrapperClassDumpedAfterInstance) {
205+
writer.write(wrapperClassDump)
206+
}
168207
writer.write(LongArrayDump(id = 4, stackTraceSerialNumber = 0, array = secretLongArray))
169208
writer.write(
170209
InstanceDumpRecord(
@@ -174,6 +213,9 @@ class HprofPrimitiveArrayStripperTest {
174213
fieldValues = byteArrayOf(0, 0, 0, 0, 0, 0, 0, secretWrappedLong),
175214
)
176215
)
216+
if (wrapperClassDumpedAfterInstance) {
217+
writer.write(wrapperClassDump)
218+
}
177219
writer.write(CharArrayDump(id = 6, stackTraceSerialNumber = 0, array = secretCharArray))
178220
}
179221
}

shark/shark-hprof/api/shark-hprof.api

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public final class shark/CopyingSource {
4242
public final fun getBytesRead ()J
4343
public final fun indexOf (B)J
4444
public final fun overwrite ([B)V
45+
public final fun overwriteRepeating (J[B)V
4546
public final fun transfer (I)V
4647
public final fun transfer (J)V
4748
public final fun transferByte ()B
@@ -628,10 +629,18 @@ public abstract interface class shark/StreamingSinkProvider {
628629
public abstract fun openStreamingSink ()Lokio/BufferedSink;
629630
}
630631

632+
public final class shark/StreamingSinkProviderKt {
633+
public static final fun gzip (Lshark/StreamingSinkProvider;)Lshark/StreamingSinkProvider;
634+
}
635+
631636
public abstract interface class shark/StreamingSourceProvider {
632637
public abstract fun openStreamingSource ()Lokio/BufferedSource;
633638
}
634639

640+
public final class shark/StreamingSourceProviderKt {
641+
public static final fun gunzipIfGzipped (Lshark/StreamingSourceProvider;)Lshark/StreamingSourceProvider;
642+
}
643+
635644
public abstract class shark/ValueHolder {
636645
public static final field Companion Lshark/ValueHolder$Companion;
637646
public static final field NULL_REFERENCE J

shark/shark-hprof/src/main/java/shark/CopyingSource.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,24 @@ class CopyingSource(
9090
sink.write(byteArray)
9191
}
9292

93+
/**
94+
* Skips [byteCount] bytes of the source and writes [pattern] over and over in their place, until
95+
* [byteCount] bytes have been written. This makes it possible to replace bytes without first
96+
* allocating a byte array to hold all of them, however many there are.
97+
*
98+
* The last repetition is cut short when [byteCount] isn't a multiple of the size of [pattern], so
99+
* a [pattern] longer than one byte should be one whose repetition divides [byteCount].
100+
*/
101+
fun overwriteRepeating(byteCount: Long, pattern: ByteArray) {
102+
bytesRead += byteCount
103+
source.skip(byteCount)
104+
var remainingByteCount = byteCount
105+
while (remainingByteCount > 0) {
106+
val writeByteCount = minOf(remainingByteCount, pattern.size.toLong()).toInt()
107+
sink.write(pattern, 0, writeByteCount)
108+
remainingByteCount -= writeByteCount
109+
}
110+
}
111+
93112
fun exhausted() = source.exhausted()
94113
}

0 commit comments

Comments
 (0)