Skip to content

Commit c7a1a0e

Browse files
pyricauclaude
andcommitted
Strip a gzipped heap dump without unpacking it first
A heap dump that's been shared has almost always been gzipped on the way — the Android heap dumps in our test resources compress 3.9x to 4.4x, and 5.6x to 5.8x once stripped — so stripping one meant gunzipping it, stripping it, and gzipping the result again by hand. The file overload now reads gzipped content gzipped, deciding from the first two bytes rather than from the name, and writes gzipped output when the output name ends with ".gz", which is what the default output name of a ".hprof.gz" input already produced. The two halves are separate extensions on the source and sink providers, so the streaming overload can take either one on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 43c979a commit c7a1a0e

7 files changed

Lines changed: 129 additions & 3 deletions

File tree

docs/changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ Releases before 2.8.1 predate these markers.
110110
* 💥 [#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.
111111
* 🔨 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.
112112
* 💥 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.
113115
*`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.
114116

115117
## Version 3.0 Alpha 9 (2026-06-25)

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ class StripHprofCommand : CliktCommand(
1414
|of the heap dump refers to. Those hold no runtime data in a heap dump from Android, but a heap
1515
|dump from a JVM also holds every string constant of every loaded class in them, so stripping a
1616
|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".
1720
""".trimMargin()
1821
) {
1922

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,10 +629,18 @@ public abstract interface class shark/StreamingSinkProvider {
629629
public abstract fun openStreamingSink ()Lokio/BufferedSink;
630630
}
631631

632+
public final class shark/StreamingSinkProviderKt {
633+
public static final fun gzip (Lshark/StreamingSinkProvider;)Lshark/StreamingSinkProvider;
634+
}
635+
632636
public abstract interface class shark/StreamingSourceProvider {
633637
public abstract fun openStreamingSource ()Lokio/BufferedSource;
634638
}
635639

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

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,13 @@ import shark.PrimitiveType.SHORT
5454
*/
5555
class HprofPrimitiveArrayStripper {
5656

57-
/** @see HprofPrimitiveArrayStripper */
57+
/**
58+
* [inputHprofFile] is read gzipped when its content is gzipped, and [outputHprofFile] is written
59+
* gzipped when its name ends with ".gz", so stripping "app.hprof.gz" writes a gzipped
60+
* "app-stripped.hprof.gz" by default.
61+
*
62+
* @see HprofPrimitiveArrayStripper
63+
*/
5864
fun stripPrimitiveArrays(
5965
inputHprofFile: File,
6066
/**
@@ -75,9 +81,17 @@ class HprofPrimitiveArrayStripper {
7581
),
7682
deleteInputHprofFile: Boolean = false
7783
): File {
84+
val fileSinkProvider = StreamingSinkProvider {
85+
outputHprofFile.outputStream().sink().buffer()
86+
}
7887
stripPrimitiveArrays(
79-
hprofSourceProvider = FileSourceProvider(inputHprofFile),
80-
hprofSinkProvider = { outputHprofFile.outputStream().sink().buffer() },
88+
hprofSourceProvider = FileSourceProvider(inputHprofFile).gunzipIfGzipped(),
89+
hprofSinkProvider =
90+
if (outputHprofFile.name.endsWith(GZIP_FILE_EXTENSION)) {
91+
fileSinkProvider.gzip()
92+
} else {
93+
fileSinkProvider
94+
},
8195
onDoneOpeningNewSources = {
8296
if (deleteInputHprofFile) {
8397
// Using the Unix trick of deleting the file as soon as all readers have opened it.
@@ -433,3 +447,5 @@ private const val REPLACEMENT_PATTERN_BYTE_SIZE = 8192
433447

434448
/** '?', in UTF-8 and in the low byte of a UTF-16BE character alike. */
435449
private const val QUESTION_MARK_BYTE: Byte = 63
450+
451+
private const val GZIP_FILE_EXTENSION = ".gz"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
package shark
22

33
import okio.BufferedSink
4+
import okio.GzipSink
5+
import okio.buffer
46

57
/**
68
* Can open [BufferedSink] instances.
79
*/
810
fun interface StreamingSinkProvider {
911
fun openStreamingSink(): BufferedSink
1012
}
13+
14+
/**
15+
* Returns a [StreamingSinkProvider] that gzips what's written to it before handing it to a sink this
16+
* one opens. The gzip trailer is written when the sink is closed, so what's written to it has to be
17+
* closed for the result to be a complete gzip stream.
18+
*/
19+
fun StreamingSinkProvider.gzip(): StreamingSinkProvider = StreamingSinkProvider {
20+
GzipSink(openStreamingSink()).buffer()
21+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,36 @@
11
package shark
22

33
import okio.BufferedSource
4+
import okio.GzipSource
45
import okio.Source
6+
import okio.buffer
57

68
/**
79
* Can open [Source] instances.
810
*/
911
fun interface StreamingSourceProvider {
1012
fun openStreamingSource(): BufferedSource
1113
}
14+
15+
/**
16+
* Returns a [StreamingSourceProvider] that hands out the content of the sources this one opens,
17+
* decompressed when that content is gzipped and as is when it isn't. Which one it is comes from the
18+
* content itself rather than from a file name, so a heap dump that was gzipped after it was written
19+
* reads the same as one that never was.
20+
*/
21+
fun StreamingSourceProvider.gunzipIfGzipped(): StreamingSourceProvider = StreamingSourceProvider {
22+
val source = openStreamingSource()
23+
if (source.startsWithGzipMagicNumber()) {
24+
GzipSource(source).buffer()
25+
} else {
26+
source
27+
}
28+
}
29+
30+
private fun BufferedSource.startsWithGzipMagicNumber(): Boolean {
31+
return request(GZIP_MAGIC_NUMBER.size.toLong()) &&
32+
GZIP_MAGIC_NUMBER.withIndex().all { (index, byte) -> buffer[index.toLong()] == byte }
33+
}
34+
35+
/** The two bytes every gzip stream starts with. */
36+
private val GZIP_MAGIC_NUMBER = byteArrayOf(0x1f, 0x8b.toByte())
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package shark
2+
3+
import java.io.File
4+
import okio.Buffer
5+
import okio.GzipSink
6+
import okio.GzipSource
7+
import okio.buffer
8+
import okio.sink
9+
import okio.source
10+
import org.assertj.core.api.Assertions.assertThat
11+
import org.junit.Rule
12+
import org.junit.Test
13+
import org.junit.rules.TemporaryFolder
14+
import shark.HprofRecord.HeapDumpRecord.ObjectRecord.PrimitiveArrayDumpRecord.LongArrayDump
15+
16+
class GzipStripHprofTest {
17+
18+
@get:Rule
19+
var testFolder = TemporaryFolder()
20+
21+
@Test fun `gzipped heap dump strips to the same bytes as that heap dump uncompressed`() {
22+
val strippedFromHprof = HprofPrimitiveArrayStripper().stripPrimitiveArrays(hprofFile())
23+
val strippedFromGzip =
24+
HprofPrimitiveArrayStripper()
25+
.stripPrimitiveArrays(gzippedHprofFile(), File(testFolder.root, "from-gzip.hprof"))
26+
27+
assertThat(strippedFromGzip.readBytes()).isEqualTo(strippedFromHprof.readBytes())
28+
}
29+
30+
@Test fun `stripping a gzipped heap dump writes a gzipped heap dump`() {
31+
val strippedFromHprof = HprofPrimitiveArrayStripper().stripPrimitiveArrays(hprofFile())
32+
33+
val strippedFromGzip = HprofPrimitiveArrayStripper().stripPrimitiveArrays(gzippedHprofFile())
34+
35+
assertThat(strippedFromGzip.name).isEqualTo("app-stripped.hprof.gz")
36+
assertThat(strippedFromGzip.length()).isLessThan(strippedFromHprof.length())
37+
val gunzipped =
38+
GzipSource(strippedFromGzip.source()).buffer().use { source -> source.readByteArray() }
39+
assertThat(gunzipped).isEqualTo(strippedFromHprof.readBytes())
40+
}
41+
42+
private fun hprofFile() =
43+
File(testFolder.newFolder(), "app.hprof").apply { writeBytes(heapDumpWithALongArray()) }
44+
45+
private fun gzippedHprofFile() =
46+
File(testFolder.newFolder(), "app.hprof.gz").apply {
47+
GzipSink(sink()).buffer().use { sink -> sink.write(heapDumpWithALongArray()) }
48+
}
49+
50+
/** Holds a long array of values that all differ from the zeroes stripping replaces them with. */
51+
private fun heapDumpWithALongArray(): ByteArray {
52+
val buffer = Buffer()
53+
HprofWriter.openWriterFor(buffer, hprofHeader = HprofHeader(heapDumpTimestamp = 42)).use { writer
54+
->
55+
writer.write(
56+
LongArrayDump(id = 1, stackTraceSerialNumber = 0, array = LongArray(1024) { 0xCAFE })
57+
)
58+
}
59+
return buffer.readByteArray()
60+
}
61+
}

0 commit comments

Comments
 (0)