Skip to content

Commit 70a7487

Browse files
authored
Extract debugger (#1905)
This is debug agent machinery rework in order to prepare for IDEA integration * Extract internal DebugProbesImpl to kotlinx-coroutines-core * Introduce AgentPremain that works without ByteBuddy to kotlinx-coroutines-core, so it now can be used as Java agent and all debug info can be extracted via reflection or JDWP * Reflective lookup of ByteBuddy attach to resolve cyclic dependency between core and debug modules * Reduce public API surface, introduce JDWP-specific API * Introduce a mechanism to produce a DebugProbesKt.bin and verify them against the golden value
1 parent bd7ac85 commit 70a7487

File tree

19 files changed

+414
-145
lines changed

19 files changed

+414
-145
lines changed

integration-testing/build.gradle

+23-2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ sourceSets {
2525
compileClasspath += sourceSets.test.runtimeClasspath
2626
runtimeClasspath += sourceSets.test.runtimeClasspath
2727
}
28+
29+
coreAgentTest {
30+
kotlin
31+
compileClasspath += sourceSets.test.runtimeClasspath
32+
runtimeClasspath += sourceSets.test.runtimeClasspath
33+
}
34+
}
35+
36+
compileDebugAgentTestKotlin {
37+
kotlinOptions {
38+
freeCompilerArgs += ["-Xallow-kotlin-package"]
39+
}
2840
}
2941

3042
task npmTest(type: Test) {
@@ -62,19 +74,28 @@ task debugAgentTest(type: Test) {
6274
classpath = sourceSet.runtimeClasspath
6375
}
6476

77+
task coreAgentTest(type: Test) {
78+
def sourceSet = sourceSets.coreAgentTest
79+
dependsOn(project(':kotlinx-coroutines-core').jvmJar)
80+
jvmArgs ('-javaagent:' + project(':kotlinx-coroutines-core').jvmJar.outputs.files.getFiles()[0])
81+
testClassesDirs = sourceSet.output.classesDirs
82+
classpath = sourceSet.runtimeClasspath
83+
}
84+
6585
dependencies {
6686
testCompile "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
6787
testCompile 'junit:junit:4.12'
6888
npmTestCompile 'org.apache.commons:commons-compress:1.18'
6989
npmTestCompile 'com.google.code.gson:gson:2.8.5'
7090
debugAgentTestCompile project(':kotlinx-coroutines-core')
7191
debugAgentTestCompile project(':kotlinx-coroutines-debug')
92+
coreAgentTestCompile project(':kotlinx-coroutines-core')
7293
}
7394

7495
compileTestKotlin {
7596
kotlinOptions.jvmTarget = "1.8"
7697
}
7798

78-
test {
79-
dependsOn([npmTest, mavenTest, debugAgentTest])
99+
check {
100+
dependsOn([npmTest, mavenTest, debugAgentTest, coreAgentTest])
80101
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright 2016-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
import org.junit.*
5+
import kotlinx.coroutines.*
6+
import kotlinx.coroutines.debug.internal.*
7+
import org.junit.Test
8+
import java.io.*
9+
10+
class CoreAgentTest {
11+
12+
@Test
13+
fun testAgentDumpsCoroutines() = runBlocking {
14+
val baos = ByteArrayOutputStream()
15+
@Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
16+
DebugProbesImpl.dumpCoroutines(PrintStream(baos))
17+
// if the agent works, then dumps should contain something,
18+
// at least the fact that this test is running.
19+
Assert.assertTrue(baos.toString().contains("testAgentDumpsCoroutines"))
20+
}
21+
22+
}

integration-testing/src/debugAgentTest/kotlin/DebugAgentTest.kt

+3-2
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,18 @@
44
import org.junit.*
55
import kotlinx.coroutines.*
66
import kotlinx.coroutines.debug.*
7+
import org.junit.Test
78
import java.io.*
89

910
class DebugAgentTest {
1011

1112
@Test
12-
fun agentDumpsCoroutines() = runBlocking {
13+
fun testAgentDumpsCoroutines() = runBlocking {
1314
val baos = ByteArrayOutputStream()
1415
DebugProbes.dumpCoroutines(PrintStream(baos))
1516
// if the agent works, then dumps should contain something,
1617
// at least the fact that this test is running.
17-
Assert.assertTrue(baos.toString().contains("agentDumpsCoroutines"))
18+
Assert.assertTrue(baos.toString().contains("testAgentDumpsCoroutines"))
1819
}
1920

2021
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Copyright 2016-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
5+
package kotlin.coroutines.jvm.internal
6+
7+
import kotlinx.coroutines.debug.internal.*
8+
import kotlin.coroutines.*
9+
10+
internal fun <T> probeCoroutineCreated(completion: Continuation<T>): Continuation<T> = DebugProbesImpl.probeCoroutineCreated(completion)
11+
12+
internal fun probeCoroutineResumed(frame: Continuation<*>) = DebugProbesImpl.probeCoroutineResumed(frame)
13+
14+
internal fun probeCoroutineSuspended(frame: Continuation<*>) = DebugProbesImpl.probeCoroutineSuspended(frame)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2016-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
import org.junit.Test
5+
import java.io.*
6+
import kotlin.test.*
7+
8+
/*
9+
* This is intentionally put here instead of coreAgentTest to avoid accidental classpath replacing
10+
* and ruining core agent test.
11+
*/
12+
class PrecompiledDebugProbesTest {
13+
14+
private val overwrite = java.lang.Boolean.getBoolean("overwrite.probes")
15+
16+
@Test
17+
fun testClassFileContent() {
18+
val clz = Class.forName("kotlin.coroutines.jvm.internal.DebugProbesKt")
19+
val className: String = clz.getName()
20+
val classFileResourcePath = className.replace(".", "/") + ".class"
21+
val stream = clz.classLoader.getResourceAsStream(classFileResourcePath)!!
22+
val array = stream.readBytes()
23+
val binFile = clz.classLoader.getResourceAsStream("DebugProbesKt.bin")!!
24+
val binContent = binFile.readBytes()
25+
if (overwrite) {
26+
val url = clz.classLoader.getResource("DebugProbesKt.bin")!!
27+
val base = url.toExternalForm().toString().removePrefix("jar:file:").substringBefore("/build")
28+
val probes = File(base, "jvm/resources/DebugProbesKt.bin")
29+
FileOutputStream(probes).use { it.write(array) }
30+
println("Content was successfully overwritten!")
31+
} else {
32+
assertTrue(
33+
array.contentEquals(binContent),
34+
"Compiled DebugProbesKt.class does not match the file shipped as a resource in kotlinx-coroutines-core. " +
35+
"Typically it happens because of the Kotlin version update (-> binary metadata). In that case, run the same test with -Poverwrite.probes=true and " +
36+
"ensure that classfile has major version equal to 50 (Java 6 compliance)")
37+
}
38+
}
39+
}

kotlinx-coroutines-core/api/kotlinx-coroutines-core.api

+4
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,10 @@ public final class kotlinx/coroutines/channels/ValueOrClosed {
832832
public final synthetic fun unbox-impl ()Ljava/lang/Object;
833833
}
834834

835+
public synthetic class kotlinx/coroutines/debug/internal/DebugProbesImplSequenceNumberRefVolatile {
836+
public fun <init> (J)V
837+
}
838+
835839
public abstract class kotlinx/coroutines/flow/AbstractFlow : kotlinx/coroutines/flow/Flow {
836840
public fun <init> ()V
837841
public final fun collect (Lkotlinx/coroutines/flow/FlowCollector;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;

kotlinx-coroutines-core/build.gradle

+7
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ jvmTest {
9292
systemProperty 'kotlinx.coroutines.scheduler.keep.alive.sec', '100000' // any unpark problem hangs test
9393
}
9494

95+
jvmJar {
96+
manifest {
97+
attributes "Premain-Class": "kotlinx.coroutines.debug.AgentPremain"
98+
attributes "Can-Retransform-Classes": "true"
99+
}
100+
}
101+
95102
task jvmStressTest(type: Test, dependsOn: compileTestKotlinJvm) {
96103
classpath = files { jvmTest.classpath }
97104
testClassesDirs = files { jvmTest.testClassesDirs }
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright 2016-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines.debug
6+
7+
import kotlinx.coroutines.debug.internal.DebugProbesImpl
8+
import sun.misc.*
9+
import java.lang.instrument.*
10+
import java.lang.instrument.ClassFileTransformer
11+
import java.security.*
12+
13+
@Suppress("unused")
14+
internal object AgentPremain {
15+
16+
public var isInstalledStatically = false
17+
18+
private val enableCreationStackTraces =
19+
System.getProperty("kotlinx.coroutines.debug.enable.creation.stack.trace")?.toBoolean()
20+
?: DebugProbesImpl.enableCreationStackTraces
21+
22+
@JvmStatic
23+
public fun premain(args: String?, instrumentation: Instrumentation) {
24+
isInstalledStatically = true
25+
instrumentation.addTransformer(DebugProbesTransformer)
26+
DebugProbesImpl.enableCreationStackTraces = enableCreationStackTraces
27+
DebugProbesImpl.install()
28+
installSignalHandler()
29+
}
30+
31+
internal object DebugProbesTransformer : ClassFileTransformer {
32+
override fun transform(
33+
loader: ClassLoader,
34+
className: String,
35+
classBeingRedefined: Class<*>?,
36+
protectionDomain: ProtectionDomain,
37+
classfileBuffer: ByteArray?
38+
): ByteArray? {
39+
if (className != "kotlin/coroutines/jvm/internal/DebugProbesKt") {
40+
return null
41+
}
42+
/*
43+
* DebugProbesKt.bin contains `kotlin.coroutines.jvm.internal.DebugProbesKt` class
44+
* with method bodies that delegate all calls directly to their counterparts in
45+
* kotlinx.coroutines.debug.DebugProbesImpl. This is done to avoid classfile patching
46+
* on the fly (-> get rid of ASM dependency).
47+
* You can verify its content either by using javap on it or looking at out integration test module.
48+
*/
49+
isInstalledStatically = true
50+
return loader.getResourceAsStream("DebugProbesKt.bin").readBytes()
51+
}
52+
}
53+
54+
private fun installSignalHandler() {
55+
try {
56+
Signal.handle(Signal("TRAP")) { // kill -5
57+
if (DebugProbesImpl.isInstalled) {
58+
// Case with 'isInstalled' changed between this check-and-act is not considered
59+
// a real debug probes use-case, thus is not guarded against.
60+
DebugProbesImpl.dumpCoroutines(System.out)
61+
} else {
62+
println("Cannot perform coroutines dump, debug probes are disabled")
63+
}
64+
}
65+
} catch (t: Throwable) {
66+
System.err.println("Failed to install signal handler: $t")
67+
}
68+
}
69+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright 2016-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines.debug.internal
6+
7+
import kotlin.coroutines.*
8+
import kotlin.coroutines.jvm.internal.*
9+
10+
internal const val CREATED = "CREATED"
11+
internal const val RUNNING = "RUNNING"
12+
internal const val SUSPENDED = "SUSPENDED"
13+
14+
internal class DebugCoroutineInfo(
15+
public val context: CoroutineContext,
16+
public val creationStackBottom: CoroutineStackFrame?,
17+
@JvmField internal val sequenceNumber: Long
18+
) {
19+
20+
public val creationStackTrace: List<StackTraceElement> get() = creationStackTrace()
21+
22+
/**
23+
* Last observed state of the coroutine.
24+
* Can be CREATED, RUNNING, SUSPENDED.
25+
*/
26+
public val state: String get() = _state
27+
private var _state: String = CREATED
28+
29+
@JvmField
30+
internal var lastObservedThread: Thread? = null
31+
@JvmField
32+
internal var lastObservedFrame: CoroutineStackFrame? = null
33+
34+
public fun copy(): DebugCoroutineInfo = DebugCoroutineInfo(
35+
context,
36+
creationStackBottom,
37+
sequenceNumber
38+
).also {
39+
it._state = _state
40+
it.lastObservedFrame = lastObservedFrame
41+
it.lastObservedThread = lastObservedThread
42+
}
43+
44+
/**
45+
* Last observed stacktrace of the coroutine captured on its suspension or resumption point.
46+
* It means that for [running][State.RUNNING] coroutines resulting stacktrace is inaccurate and
47+
* reflects stacktrace of the resumption point, not the actual current stacktrace.
48+
*/
49+
public fun lastObservedStackTrace(): List<StackTraceElement> {
50+
var frame: CoroutineStackFrame? = lastObservedFrame ?: return emptyList()
51+
val result = ArrayList<StackTraceElement>()
52+
while (frame != null) {
53+
frame.getStackTraceElement()?.let { result.add(it) }
54+
frame = frame.callerFrame
55+
}
56+
return result
57+
}
58+
59+
private fun creationStackTrace(): List<StackTraceElement> {
60+
val bottom = creationStackBottom ?: return emptyList()
61+
// Skip "Coroutine creation stacktrace" frame
62+
return sequence<StackTraceElement> { yieldFrames(bottom.callerFrame) }.toList()
63+
}
64+
65+
private tailrec suspend fun SequenceScope<StackTraceElement>.yieldFrames(frame: CoroutineStackFrame?) {
66+
if (frame == null) return
67+
frame.getStackTraceElement()?.let { yield(it) }
68+
val caller = frame.callerFrame
69+
if (caller != null) {
70+
yieldFrames(caller)
71+
}
72+
}
73+
74+
internal fun updateState(state: String, frame: Continuation<*>) {
75+
// Propagate only duplicating transitions to running for KT-29997
76+
if (_state == state && state == SUSPENDED && lastObservedFrame != null) return
77+
_state = state
78+
lastObservedFrame = frame as? CoroutineStackFrame
79+
lastObservedThread = if (state == RUNNING) {
80+
Thread.currentThread()
81+
} else {
82+
null
83+
}
84+
}
85+
86+
override fun toString(): String = "DebugCoroutineInfo(state=$state,context=$context)"
87+
}

0 commit comments

Comments
 (0)