xref: /aosp_15_r20/external/kotlinx.coroutines/kotlinx-coroutines-debug/test/StacktraceUtils.kt (revision 7a7160fed73afa6648ef8aa100d4a336fe921d9a)

<lambda>null1 package kotlinx.coroutines.debug
2 
3 import java.io.*
4 import kotlin.test.*
5 
6 public fun String.trimStackTrace(): String =
7     trimIndent()
8         // Remove source line
9         .replace(Regex(":[0-9]+"), "")
10         // Remove coroutine id
11         .replace(Regex("#[0-9]+"), "")
12         // Remove trace prefix: "[email protected]/java.lang.Thread.sleep" => "java.lang.Thread.sleep"
13         .replace(Regex("(?<=\tat )[^\n]*/"), "")
14         .replace(Regex("\t"), "")
15         .replace("sun.misc.Unsafe.", "jdk.internal.misc.Unsafe.") // JDK8->JDK11
16 
17 public fun verifyStackTrace(e: Throwable, traces: List<String>) {
18     val stacktrace = toStackTrace(e)
19     val trimmedStackTrace = stacktrace.trimStackTrace()
20     traces.forEach {
21         assertTrue(
22             trimmedStackTrace.contains(it.trimStackTrace()),
23             "\nExpected trace element:\n$it\n\nActual stacktrace:\n$stacktrace"
24         )
25     }
26 
27     val causes = stacktrace.count("Caused by")
28     assertNotEquals(0, causes)
29     assertEquals(causes, traces.map { it.count("Caused by") }.sum())
30 }
31 
toStackTracenull32 public fun toStackTrace(t: Throwable): String {
33     val sw = StringWriter()
34     t.printStackTrace(PrintWriter(sw))
35     return sw.toString()
36 }
37 
countnull38 public fun String.count(substring: String): Int = split(substring).size - 1
39 
40 public fun verifyDump(vararg traces: String, ignoredCoroutine: String? = null, finally: () -> Unit) {
41     try {
42         verifyDump(*traces, ignoredCoroutine = ignoredCoroutine)
43     } finally {
44         finally()
45     }
46 }
47 
48 /** Clean the stacktraces from artifacts of BlockHound instrumentation
49  *
50  * BlockHound works by switching a native call by a class generated with ByteBuddy, which, if the blocking
51  * call is allowed in this context, in turn calls the real native call that is now available under a
52  * different name.
53  *
54  * The traces thus undergo the following two changes when the execution is instrumented:
55  *   - The original native call is replaced with a non-native one with the same FQN, and
56  *   - An additional native call is placed on top of the stack, with the original name that also has
57  *     `$$BlockHound$$_` prepended at the last component.
58  */
cleanBlockHoundTracesnull59 private fun cleanBlockHoundTraces(frames: List<String>): List<String> {
60     val result = mutableListOf<String>()
61     val blockHoundSubstr = "\$\$BlockHound\$\$_"
62     var i = 0
63     while (i < frames.size) {
64         result.add(frames[i].replace(blockHoundSubstr, ""))
65         if (frames[i].contains(blockHoundSubstr)) {
66             i += 1
67         }
68         i += 1
69     }
70     return result
71 }
72 
73 /**
74  * Removes all frames that contain "java.util.concurrent" in it.
75  *
76  * We do leverage Java's locks for proper rendezvous and to fix the coroutine stack's state,
77  * but this API doesn't have (nor expected to) stable stacktrace, so we are filtering all such
78  * frames out.
79  *
80  * See https://github.com/Kotlin/kotlinx.coroutines/issues/3700 for the example of failure
81  */
removeJavaUtilConcurrentTracesnull82 private fun removeJavaUtilConcurrentTraces(frames: List<String>): List<String> =
83     frames.filter { !it.contains("java.util.concurrent") }
84 
85 private data class CoroutineDump(
86     val header: CoroutineDumpHeader,
87     val coroutineStackTrace: List<String>,
88     val threadStackTrace: List<String>,
89     val originDump: String,
90     val originHeader: String,
91 ) {
92     companion object {
93         private val COROUTINE_CREATION_FRAME_REGEX =
94             "at _COROUTINE\\._CREATION\\._\\(.*\\)".toRegex()
95 
parsenull96         fun parse(dump: String, traceCleaner: ((List<String>) -> List<String>)? = null): CoroutineDump {
97             val lines = dump
98                 .trimStackTrace()
99                 .split("\n")
100             val header = CoroutineDumpHeader.parse(lines[0])
101             val traceLines = lines.slice(1 until lines.size)
102             val cleanedTraceLines = if (traceCleaner != null) {
103                 traceCleaner(traceLines)
104             } else {
105                 traceLines
106             }
107             val coroutineStackTrace = mutableListOf<String>()
108             val threadStackTrace = mutableListOf<String>()
109             var trace = coroutineStackTrace
110             for (line in cleanedTraceLines) {
111                 if (line.isEmpty()) {
112                     continue
113                 }
114                 if (line.matches(COROUTINE_CREATION_FRAME_REGEX)) {
115                     require(trace !== threadStackTrace) {
116                         "Found more than one coroutine creation frame"
117                     }
118                     trace = threadStackTrace
119                     continue
120                 }
121                 trace.add(line)
122             }
123             return CoroutineDump(header, coroutineStackTrace, threadStackTrace, dump, lines[0])
124         }
125     }
126 
verifynull127     fun verify(expected: CoroutineDump) {
128         assertEquals(
129             expected.header, header,
130             "Coroutine stacktrace headers are not matched:\n\t- ${expected.originHeader}\n\t+ ${originHeader}\n"
131         )
132         verifyStackTrace("coroutine stack", coroutineStackTrace, expected.coroutineStackTrace)
133         verifyStackTrace("thread stack", threadStackTrace, expected.threadStackTrace)
134     }
135 
verifyStackTracenull136     private fun verifyStackTrace(traceName: String, actualStackTrace: List<String>, expectedStackTrace: List<String>) {
137         // It is possible there are more stack frames in a dump than we check
138         for ((ix, expectedLine) in expectedStackTrace.withIndex()) {
139             val actualLine = actualStackTrace[ix]
140             assertEquals(
141                 expectedLine, actualLine,
142                 "Following lines from $traceName are not matched:\n\t- ${expectedLine}\n\t+ ${actualLine}\nActual dump:\n$originDump\n\n"
143             )
144         }
145     }
146 }
147 
148 private data class CoroutineDumpHeader(
149     val name: String?,
150     val className: String,
151     val state: String,
152 ) {
153     companion object {
154         /**
155          * Parses following strings:
156          *
157          * - Coroutine "coroutine#10":DeferredCoroutine{Active}@66d87651, state: RUNNING
158          * - Coroutine DeferredCoroutine{Active}@66d87651, state: RUNNING
159          *
160          * into:
161          *
162          * - `CoroutineDumpHeader(name = "coroutine", className = "DeferredCoroutine", state = "RUNNING")`
163          * - `CoroutineDumpHeader(name = null, className = "DeferredCoroutine", state = "RUNNING")`
164          */
parsenull165         fun parse(header: String): CoroutineDumpHeader {
166             val (identFull, stateFull) = header.split(", ", limit = 2)
167             val nameAndClassName = identFull.removePrefix("Coroutine ").split('@', limit = 2)[0]
168             val (name, className) = nameAndClassName.split(':', limit = 2).let { parts ->
169                 val (quotedName, classNameWithState) = if (parts.size == 1) {
170                     null to parts[0]
171                 } else {
172                     parts[0] to parts[1]
173                 }
174                 val name = quotedName?.removeSurrounding("\"")?.split('#', limit = 2)?.get(0)
175                 val className = classNameWithState.replace("\\{.*\\}".toRegex(), "")
176                 name to className
177             }
178             val state = stateFull.removePrefix("state: ")
179             return CoroutineDumpHeader(name, className, state)
180         }
181     }
182 }
183 
verifyDumpnull184 public fun verifyDump(vararg expectedTraces: String, ignoredCoroutine: String? = null) {
185     val baos = ByteArrayOutputStream()
186     DebugProbes.dumpCoroutines(PrintStream(baos))
187     val wholeDump = baos.toString()
188     val traces = wholeDump.split("\n\n")
189     assertTrue(traces[0].startsWith("Coroutines dump"))
190 
191     val dumps = traces
192         // Drop "Coroutine dump" line
193         .drop(1)
194         // Parse dumps and filter out ignored coroutines
195         .mapNotNull { trace ->
196             val dump = CoroutineDump.parse(trace, {
197                 removeJavaUtilConcurrentTraces(cleanBlockHoundTraces(it))
198             })
199             if (dump.header.className == ignoredCoroutine) {
200                 null
201             } else {
202                 dump
203             }
204         }
205 
206     assertEquals(expectedTraces.size, dumps.size)
207     dumps.zip(expectedTraces.map { CoroutineDump.parse(it, ::removeJavaUtilConcurrentTraces) })
208         .forEach { (dump, expectedDump) ->
209             dump.verify(expectedDump)
210         }
211 }
212 
trimPackagenull213 public fun String.trimPackage() = replace("kotlinx.coroutines.debug.", "")
214 
215 public fun verifyPartialDump(createdCoroutinesCount: Int, vararg frames: String) {
216     val baos = ByteArrayOutputStream()
217     DebugProbes.dumpCoroutines(PrintStream(baos))
218     val dump = baos.toString()
219     val trace = dump.split("\n\n")
220     val matches = frames.all { frame ->
221         trace.any { tr -> tr.contains(frame) }
222     }
223 
224     assertEquals(createdCoroutinesCount, DebugProbes.dumpCoroutinesInfo().size)
225     assertTrue(matches)
226 }
227