blob: 90ce38907e7a9adafc0dee26dfc6043f16d9040a [file] [log] [blame]
package kotlinx.coroutines.debug
import java.io.*
import kotlin.test.*
public fun String.trimStackTrace(): String =
trimIndent()
// Remove source line
.replace(Regex(":[0-9]+"), "")
// Remove coroutine id
.replace(Regex("#[0-9]+"), "")
// Remove trace prefix: "[email protected]/java.lang.Thread.sleep" => "java.lang.Thread.sleep"
.replace(Regex("(?<=\tat )[^\n]*/"), "")
.replace(Regex("\t"), "")
.replace("sun.misc.Unsafe.", "jdk.internal.misc.Unsafe.") // JDK8->JDK11
public fun verifyStackTrace(e: Throwable, traces: List<String>) {
val stacktrace = toStackTrace(e)
val trimmedStackTrace = stacktrace.trimStackTrace()
traces.forEach {
assertTrue(
trimmedStackTrace.contains(it.trimStackTrace()),
"\nExpected trace element:\n$it\n\nActual stacktrace:\n$stacktrace"
)
}
val causes = stacktrace.count("Caused by")
assertNotEquals(0, causes)
assertEquals(causes, traces.map { it.count("Caused by") }.sum())
}
public fun toStackTrace(t: Throwable): String {
val sw = StringWriter()
t.printStackTrace(PrintWriter(sw))
return sw.toString()
}
public fun String.count(substring: String): Int = split(substring).size - 1
public fun verifyDump(vararg traces: String, ignoredCoroutine: String? = null, finally: () -> Unit) {
try {
verifyDump(*traces, ignoredCoroutine = ignoredCoroutine)
} finally {
finally()
}
}
/** Clean the stacktraces from artifacts of BlockHound instrumentation
*
* BlockHound works by switching a native call by a class generated with ByteBuddy, which, if the blocking
* call is allowed in this context, in turn calls the real native call that is now available under a
* different name.
*
* The traces thus undergo the following two changes when the execution is instrumented:
* - The original native call is replaced with a non-native one with the same FQN, and
* - An additional native call is placed on top of the stack, with the original name that also has
* `$$BlockHound$$_` prepended at the last component.
*/
private fun cleanBlockHoundTraces(frames: List<String>): List<String> {
val result = mutableListOf<String>()
val blockHoundSubstr = "\$\$BlockHound\$\$_"
var i = 0
while (i < frames.size) {
result.add(frames[i].replace(blockHoundSubstr, ""))
if (frames[i].contains(blockHoundSubstr)) {
i += 1
}
i += 1
}
return result
}
/**
* Removes all frames that contain "java.util.concurrent" in it.
*
* We do leverage Java's locks for proper rendezvous and to fix the coroutine stack's state,
* but this API doesn't have (nor expected to) stable stacktrace, so we are filtering all such
* frames out.
*
* See https://github.com/Kotlin/kotlinx.coroutines/issues/3700 for the example of failure
*/
private fun removeJavaUtilConcurrentTraces(frames: List<String>): List<String> =
frames.filter { !it.contains("java.util.concurrent") }
private data class CoroutineDump(
val header: CoroutineDumpHeader,
val coroutineStackTrace: List<String>,
val threadStackTrace: List<String>,
val originDump: String,
val originHeader: String,
) {
companion object {
private val COROUTINE_CREATION_FRAME_REGEX =
"at _COROUTINE\\._CREATION\\._\\(.*\\)".toRegex()
fun parse(dump: String, traceCleaner: ((List<String>) -> List<String>)? = null): CoroutineDump {
val lines = dump
.trimStackTrace()
.split("\n")
val header = CoroutineDumpHeader.parse(lines[0])
val traceLines = lines.slice(1 until lines.size)
val cleanedTraceLines = if (traceCleaner != null) {
traceCleaner(traceLines)
} else {
traceLines
}
val coroutineStackTrace = mutableListOf<String>()
val threadStackTrace = mutableListOf<String>()
var trace = coroutineStackTrace
for (line in cleanedTraceLines) {
if (line.isEmpty()) {
continue
}
if (line.matches(COROUTINE_CREATION_FRAME_REGEX)) {
require(trace !== threadStackTrace) {
"Found more than one coroutine creation frame"
}
trace = threadStackTrace
continue
}
trace.add(line)
}
return CoroutineDump(header, coroutineStackTrace, threadStackTrace, dump, lines[0])
}
}
fun verify(expected: CoroutineDump) {
assertEquals(
expected.header, header,
"Coroutine stacktrace headers are not matched:\n\t- ${expected.originHeader}\n\t+ ${originHeader}\n"
)
verifyStackTrace("coroutine stack", coroutineStackTrace, expected.coroutineStackTrace)
verifyStackTrace("thread stack", threadStackTrace, expected.threadStackTrace)
}
private fun verifyStackTrace(traceName: String, actualStackTrace: List<String>, expectedStackTrace: List<String>) {
// It is possible there are more stack frames in a dump than we check
for ((ix, expectedLine) in expectedStackTrace.withIndex()) {
val actualLine = actualStackTrace[ix]
assertEquals(
expectedLine, actualLine,
"Following lines from $traceName are not matched:\n\t- ${expectedLine}\n\t+ ${actualLine}\nActual dump:\n$originDump\n\n"
)
}
}
}
private data class CoroutineDumpHeader(
val name: String?,
val className: String,
val state: String,
) {
companion object {
/**
* Parses following strings:
*
* - Coroutine "coroutine#10":DeferredCoroutine{Active}@66d87651, state: RUNNING
* - Coroutine DeferredCoroutine{Active}@66d87651, state: RUNNING
*
* into:
*
* - `CoroutineDumpHeader(name = "coroutine", className = "DeferredCoroutine", state = "RUNNING")`
* - `CoroutineDumpHeader(name = null, className = "DeferredCoroutine", state = "RUNNING")`
*/
fun parse(header: String): CoroutineDumpHeader {
val (identFull, stateFull) = header.split(", ", limit = 2)
val nameAndClassName = identFull.removePrefix("Coroutine ").split('@', limit = 2)[0]
val (name, className) = nameAndClassName.split(':', limit = 2).let { parts ->
val (quotedName, classNameWithState) = if (parts.size == 1) {
null to parts[0]
} else {
parts[0] to parts[1]
}
val name = quotedName?.removeSurrounding("\"")?.split('#', limit = 2)?.get(0)
val className = classNameWithState.replace("\\{.*\\}".toRegex(), "")
name to className
}
val state = stateFull.removePrefix("state: ")
return CoroutineDumpHeader(name, className, state)
}
}
}
public fun verifyDump(vararg expectedTraces: String, ignoredCoroutine: String? = null) {
val baos = ByteArrayOutputStream()
DebugProbes.dumpCoroutines(PrintStream(baos))
val wholeDump = baos.toString()
val traces = wholeDump.split("\n\n")
assertTrue(traces[0].startsWith("Coroutines dump"))
val dumps = traces
// Drop "Coroutine dump" line
.drop(1)
// Parse dumps and filter out ignored coroutines
.mapNotNull { trace ->
val dump = CoroutineDump.parse(trace, {
removeJavaUtilConcurrentTraces(cleanBlockHoundTraces(it))
})
if (dump.header.className == ignoredCoroutine) {
null
} else {
dump
}
}
assertEquals(expectedTraces.size, dumps.size)
dumps.zip(expectedTraces.map { CoroutineDump.parse(it, ::removeJavaUtilConcurrentTraces) })
.forEach { (dump, expectedDump) ->
dump.verify(expectedDump)
}
}
public fun String.trimPackage() = replace("kotlinx.coroutines.debug.", "")
public fun verifyPartialDump(createdCoroutinesCount: Int, vararg frames: String) {
val baos = ByteArrayOutputStream()
DebugProbes.dumpCoroutines(PrintStream(baos))
val dump = baos.toString()
val trace = dump.split("\n\n")
val matches = frames.all { frame ->
trace.any { tr -> tr.contains(frame) }
}
assertEquals(createdCoroutinesCount, DebugProbes.dumpCoroutinesInfo().size)
assertTrue(matches)
}