From 76796255a400051555c4ce7ece67b65e90ff4500 Mon Sep 17 00:00:00 2001 From: Maria Sokolova Date: Thu, 10 Apr 2025 13:34:14 +0200 Subject: [PATCH] [debugger] Asynchronously compute dumps of coroutines and Java threads and show in ThreadDumpPanel as soon as ready IDEA-369744 IDEA-369956 GitOrigin-RevId: 72b33d78951b8fbea68d5b1c6b4ee8b72fd6136b --- .../debugger/actions/ThreadDumpAction.kt | 89 +++++++++++------ .../debugger/engine/EvaluationUtils.kt | 99 +++++++------------ .../impl/ThreadDumpItemsProviderFactory.kt | 4 + .../messages/JavaDebuggerBundle.properties | 4 +- .../stepping/CoroutineBreakpointFacility.kt | 2 +- .../KotlinLambdaAsyncMethodFilter.kt | 2 +- .../view/CoroutinesDumpAsyncProvider.kt | 3 + 7 files changed, 108 insertions(+), 95 deletions(-) diff --git a/java/debugger/impl/src/com/intellij/debugger/actions/ThreadDumpAction.kt b/java/debugger/impl/src/com/intellij/debugger/actions/ThreadDumpAction.kt index 97c59a76486f..26eb0d4f686b 100644 --- a/java/debugger/impl/src/com/intellij/debugger/actions/ThreadDumpAction.kt +++ b/java/debugger/impl/src/com/intellij/debugger/actions/ThreadDumpAction.kt @@ -2,16 +2,11 @@ package com.intellij.debugger.actions import com.intellij.debugger.DebuggerManagerEx -import com.intellij.debugger.JavaDebuggerBundle import com.intellij.debugger.engine.* import com.intellij.debugger.engine.MethodInvokeUtils.getMethodHandlesImplLookup import com.intellij.debugger.engine.evaluation.EvaluateException import com.intellij.debugger.engine.evaluation.EvaluationContextImpl -import com.intellij.debugger.impl.DebuggerContextImpl -import com.intellij.debugger.impl.DebuggerUtilsEx -import com.intellij.debugger.impl.DebuggerUtilsImpl -import com.intellij.debugger.impl.ThreadDumpItemsProvider -import com.intellij.debugger.impl.ThreadDumpItemsProviderFactory +import com.intellij.debugger.impl.* import com.intellij.debugger.jdi.VirtualMachineProxyImpl import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent @@ -32,9 +27,8 @@ import com.intellij.unscramble.JavaThreadDumpItem import com.intellij.util.lang.JavaVersion import com.jetbrains.jdi.ThreadReferenceImpl import com.sun.jdi.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* import org.jetbrains.annotations.NonNls import java.lang.Long as JLong import java.util.concurrent.CancellationException @@ -45,8 +39,13 @@ import kotlin.checkNotNull import kotlin.let import kotlin.time.Duration.Companion.milliseconds import kotlin.to +import com.intellij.openapi.project.Project +import org.jetbrains.annotations.ApiStatus +import com.intellij.debugger.JavaDebuggerBundle class ThreadDumpAction : DumbAwareAction() { + + @OptIn(ExperimentalCoroutinesApi::class) override fun actionPerformed(e: AnActionEvent) { val project = e.project if (project == null) { @@ -58,12 +57,12 @@ class ThreadDumpAction : DumbAwareAction() { val managerThread = context.managerThread!! if (session != null && session.isAttached) { executeOnDMT(managerThread) { - val dumpItems = buildThreadDump(context) - withContext(Dispatchers.EDT) { - val xSession = session.xDebugSession - if (xSession != null) { - DebuggerUtilsEx.addDumpItems(project, dumpItems, xSession.ui, session.searchScope) - } + // Pass parts of the dump to the ThreadDumpPanel via a channel as soon as they are computed + val dumpItemsChannel = produce(capacity = Channel.BUFFERED) { + buildThreadDump(context, channel) + } + launch(Dispatchers.EDT) { + collectAndShowDumpItems(project, session, dumpItemsChannel) } } } @@ -99,31 +98,46 @@ class ThreadDumpAction : DumbAwareAction() { "(" + DebuggerUtilsEx.getSourceName(location, "Unknown Source") + ":" + DebuggerUtilsEx.getLineNumber(location, false) + ")" } - private suspend fun buildThreadDump(context: DebuggerContextImpl): List { - fun fallback() = - buildJavaPlatformThreadDump(context).map(::JavaThreadDumpItem) + @ApiStatus.Internal + suspend fun buildThreadDump(context: DebuggerContextImpl, dumpItemsChannel: SendChannel>) { + + suspend fun fallback() = + dumpItemsChannel.send( + buildJavaPlatformThreadDump(context).map(::JavaThreadDumpItem) + ) if (!Registry.`is`("debugger.thread.dump.extended")) { - return fallback() + fallback() + return } - - return try { + try { val providers = extendedProviders.extensionList.map { it.getProvider(context) } - suspend fun getAllItems(suspendContext: SuspendContextImpl?) = - withBackgroundProgress(context.project, JavaDebuggerBundle.message("thread.dump.extended.progress")) { - providers - .flatMap { it.getItems(suspendContext) } - .sortedWith(DumpItem.BY_INTEREST) + suspend fun getAllItems(suspendContext: SuspendContextImpl?) { + coroutineScope { + // Compute parts of the dump asynchronously + providers.map { p -> + launch { + withBackgroundProgress(context.project, p.progressText) { + try { + val items = p.getItems(suspendContext).sortedWith(DumpItem.BY_INTEREST) + dumpItemsChannel.send(items) + } + catch (e: CancellationException) { + thisLogger().debug("${p.progressText} was cancelled by user.") + throw e + } + } + } + } } + } if (providers.any { it.requiresEvaluation }) { val timeout = Registry.intValue("debugger.thread.dump.suspension.timeout.ms", 500).milliseconds try { suspendAllAndEvaluate(context, timeout) { suspendContext -> - withDebugContext(suspendContext) { - getAllItems(suspendContext) - } + getAllItems(suspendContext) } } catch (_: TimeoutCancellationException) { @@ -135,7 +149,7 @@ class ThreadDumpAction : DumbAwareAction() { val vm = context.debugProcess!!.virtualMachineProxy vm.suspend() try { - return getAllItems(null) + getAllItems(null) } finally { vm.resume() @@ -165,6 +179,17 @@ class ThreadDumpAction : DumbAwareAction() { vm.resume() } } + + private suspend fun collectAndShowDumpItems(project: Project, session: DebuggerSession, dumpItemsChannel: ReceiveChannel>) { + val xSession = session.xDebugSession + if (xSession != null) { + val threadDumpPanel = DebuggerUtilsEx.createThreadDumpPanel(project, emptyList(), xSession.ui, session.searchScope) + + for (items in dumpItemsChannel) { + threadDumpPanel.addDumpItems(items) + } + } + } } } @@ -486,6 +511,10 @@ private class JavaThreadsProvider : ThreadDumpItemsProviderFactory() { // Check if VirtualThread class is at least loaded. vm.classesByName("java.lang.VirtualThread").isNotEmpty() + override val progressText: String get() = JavaDebuggerBundle.message( + if (shouldDumpVirtualThreads) "thread.dump.platform.and.virtual.threads.progress" else "thread.dump.platform.threads.progress" + ) + override val requiresEvaluation get() = shouldDumpVirtualThreads override fun getItems(suspendContext: SuspendContextImpl?): List { diff --git a/java/debugger/impl/src/com/intellij/debugger/engine/EvaluationUtils.kt b/java/debugger/impl/src/com/intellij/debugger/engine/EvaluationUtils.kt index 48dfde62fb14..3f040e7a9624 100644 --- a/java/debugger/impl/src/com/intellij/debugger/engine/EvaluationUtils.kt +++ b/java/debugger/impl/src/com/intellij/debugger/engine/EvaluationUtils.kt @@ -5,8 +5,10 @@ import com.intellij.debugger.engine.events.SuspendContextCommandImpl import com.intellij.debugger.impl.DebuggerContextImpl import com.intellij.debugger.settings.DebuggerSettings import com.intellij.debugger.ui.breakpoints.FilteredRequestor +import com.intellij.openapi.progress.runBlockingCancellable import com.sun.jdi.event.LocatableEvent import com.sun.jdi.request.EventRequest +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.withTimeout @@ -29,8 +31,7 @@ internal suspend fun suspendAllAndEvaluate( val suspendContext = context.suspendContext return if (suspendContext == null) { // Not suspended at all. - tryToBreakOnAnyMethodAndEvaluate(context, process, timeToSuspend, action) - + tryToBreakOnAnyMethodAndEvaluate(context, process, null, timeToSuspend, action) } else if (process.isEvaluationPossible(suspendContext)) { if (suspendContext.suspendPolicy == EventRequest.SUSPEND_EVENT_THREAD) { @@ -50,27 +51,36 @@ internal suspend fun suspendAllAndEvaluate( } else { // We are on a pause, cannot evaluate. - tryToResumeThenBreakOnAnyMethodAndEvaluate(context, process, suspendContext, timeToSuspend, action) + tryToBreakOnAnyMethodAndEvaluate(context, process, suspendContext, timeToSuspend, action) } - } -// FIXME: too much copypasted, compare and merge -private suspend fun tryToBreakOnAnyMethodAndEvaluate( +private suspend fun tryToBreakOnAnyMethodAndEvaluate ( context: DebuggerContextImpl, process: DebugProcessImpl, + pauseSuspendContext: SuspendContextImpl?, timeToSuspend: Duration, - action: suspend (SuspendContextImpl) -> R + block: suspend (SuspendContextImpl) -> R ): R { - val evaluatableContextResult = Channel(capacity = 1) + val onPause = pauseSuspendContext != null + + val actionResult = Channel(capacity = 1) + val evaluatableContextObtained = CompletableDeferred() // Create a request which suspends all the threads and gets the suspendContext. val requestor = object : FilteredRequestor { override fun processLocatableEvent(action: SuspendContextCommandImpl, event: LocatableEvent?): Boolean { - process.requestsManager.deleteRequest(this) - val evaluatableContext = action.suspendContext!! - evaluatableContextResult.trySend(evaluatableContext).also { assert(it.isSuccess) } - return true + val requestor = this + runBlockingCancellable { + process.requestsManager.deleteRequest(requestor) + val suspendContext = action.suspendContext!! + evaluatableContextObtained.complete(Unit) + actionResult.send(block(suspendContext)) + } + // Note: in case the context was not originally suspended, return false, + // so that suspendContext is resumed when action is computed, + // thus no suspension will be visible in the UI + return onPause } override fun getSuspendPolicy(): String = DebuggerSettings.SUSPEND_ALL @@ -80,66 +90,31 @@ private suspend fun tryToBreakOnAnyMethodAndEvaluate( request.setSuspendPolicy(EventRequest.SUSPEND_ALL) request.isEnabled = true - val evaluatableContext = try { - withTimeout(timeToSuspend) { - evaluatableContextResult.receive() - } - } - finally { - process.requestsManager.deleteRequest(requestor) - } - - try { - return action(evaluatableContext) - } - finally { + // If the context was on pause, it should be resume first to hit the breakpoint + if (onPause) { context.managerThread!! - .invokeNow(process.createResumeCommand(evaluatableContext)) - } -} - -private suspend fun tryToResumeThenBreakOnAnyMethodAndEvaluate( - context: DebuggerContextImpl, - process: DebugProcessImpl, - pauseSuspendContext: SuspendContextImpl, - timeToSuspend: Duration, - action: suspend (SuspendContextImpl) -> R -): R { - val evaluatableContextResult = Channel(capacity = 1) - - // Create a request which suspends all the threads and gets the suspendContext. - val requestor = object : FilteredRequestor { - override fun processLocatableEvent(action: SuspendContextCommandImpl, event: LocatableEvent?): Boolean { - process.requestsManager.deleteRequest(this) - val evaluatableContext = action.suspendContext!! - evaluatableContextResult.trySend(evaluatableContext).also { assert(it.isSuccess) } - return true - } - - override fun getSuspendPolicy(): String = DebuggerSettings.SUSPEND_ALL + .invokeNow(process.createResumeCommand(pauseSuspendContext)) } - val request = process.requestsManager.createMethodEntryRequest(requestor) - request.setSuspendPolicy(EventRequest.SUSPEND_ALL) - request.isEnabled = true - - context.managerThread!! - .invokeNow(process.createResumeCommand(pauseSuspendContext)) - - val evaluatableContext = try { + // Check that we hit the breakpoint within the specified timeout + try { withTimeout(timeToSuspend) { - evaluatableContextResult.receive() + evaluatableContextObtained.await() } } catch (e: TimeoutCancellationException) { - // FIXME: get preferred thread from pauseSuspendContext - context.managerThread!! - .invokeNow(process.createPauseCommand(null)) - throw e + if (onPause) { + // FIXME: get preferred thread from pauseSuspendContext + // If the context was originally on pause, but after resume did not hit a breakpoint within a timeout, + // then it should be paused again + context.managerThread!! + .invokeNow(process.createPauseCommand(null)) + throw e + } } finally { process.requestsManager.deleteRequest(requestor) } - return action(evaluatableContext) + return actionResult.receive() } diff --git a/java/debugger/impl/src/com/intellij/debugger/impl/ThreadDumpItemsProviderFactory.kt b/java/debugger/impl/src/com/intellij/debugger/impl/ThreadDumpItemsProviderFactory.kt index 8c5882bb7d25..c5467c878201 100644 --- a/java/debugger/impl/src/com/intellij/debugger/impl/ThreadDumpItemsProviderFactory.kt +++ b/java/debugger/impl/src/com/intellij/debugger/impl/ThreadDumpItemsProviderFactory.kt @@ -3,6 +3,7 @@ package com.intellij.debugger.impl import com.intellij.unscramble.DumpItem import com.intellij.debugger.engine.SuspendContextImpl +import com.intellij.openapi.util.NlsContexts import org.jetbrains.annotations.ApiStatus /** @@ -16,6 +17,9 @@ abstract class ThreadDumpItemsProviderFactory { @ApiStatus.Internal interface ThreadDumpItemsProvider { + @get:NlsContexts.ProgressTitle + val progressText: String + /** * Returns whether this provider requires [SuspendContextImpl] which can be used to evaluate some information to provide dump items. */ diff --git a/java/debugger/openapi/resources/messages/JavaDebuggerBundle.properties b/java/debugger/openapi/resources/messages/JavaDebuggerBundle.properties index 1e1fc078173f..07ee743099bc 100644 --- a/java/debugger/openapi/resources/messages/JavaDebuggerBundle.properties +++ b/java/debugger/openapi/resources/messages/JavaDebuggerBundle.properties @@ -472,7 +472,9 @@ import.capture.points=Import Capture Points please.select.a.file.to.import=Please select a file to import. waiting.for.debugger.response=Waiting for the process to finish gracefully thread.dump=Thread Dump -thread.dump.extended.progress=Collecting extended thread dump +thread.dump.platform.threads.progress=Dumping Java platform threads\u2026 +thread.dump.platform.and.virtual.threads.progress=Dumping Java platform threads and virtual threads\u2026 +thread.dump.coroutines.progress=Dumping Coroutines\u2026 collection.history=Collection history cancel.emulation=Cancel emulation thread.operation.interrupt.is.not.supported.by.vm=Thread operation 'interrupt' is not supported by VM diff --git a/plugins/kotlin/jvm-debugger/core/src/org/jetbrains/kotlin/idea/debugger/core/stepping/CoroutineBreakpointFacility.kt b/plugins/kotlin/jvm-debugger/core/src/org/jetbrains/kotlin/idea/debugger/core/stepping/CoroutineBreakpointFacility.kt index 3e4ad0d58d00..011ecbe2b935 100644 --- a/plugins/kotlin/jvm-debugger/core/src/org/jetbrains/kotlin/idea/debugger/core/stepping/CoroutineBreakpointFacility.kt +++ b/plugins/kotlin/jvm-debugger/core/src/org/jetbrains/kotlin/idea/debugger/core/stepping/CoroutineBreakpointFacility.kt @@ -72,7 +72,7 @@ object CoroutineBreakpointFacility { override fun isRestoreBreakpoints(): Boolean = false override fun stopOnlyInBaseClass(): Boolean = true - override fun processLocatableEvent(action: SuspendContextCommandImpl, event: LocatableEvent): Boolean { + override fun processLocatableEvent(action: SuspendContextCommandImpl, event: LocatableEvent?): Boolean { thisLogger().debug { "Hit the resume breakpoint at ${context.location}" } val result = super.processLocatableEvent(action, event) if (result) { diff --git a/plugins/kotlin/jvm-debugger/core/src/org/jetbrains/kotlin/idea/debugger/stepping/smartStepInto/KotlinLambdaAsyncMethodFilter.kt b/plugins/kotlin/jvm-debugger/core/src/org/jetbrains/kotlin/idea/debugger/stepping/smartStepInto/KotlinLambdaAsyncMethodFilter.kt index 453bc9276663..7414262f0f3f 100644 --- a/plugins/kotlin/jvm-debugger/core/src/org/jetbrains/kotlin/idea/debugger/stepping/smartStepInto/KotlinLambdaAsyncMethodFilter.kt +++ b/plugins/kotlin/jvm-debugger/core/src/org/jetbrains/kotlin/idea/debugger/stepping/smartStepInto/KotlinLambdaAsyncMethodFilter.kt @@ -159,7 +159,7 @@ class KotlinLambdaAsyncMethodFilter( private val lambdaReference: ObjectReference, lambdaMethod: Method ) : StepIntoMethodBreakpoint(lambdaMethod.declaringType().name(), lambdaMethod.name(), lambdaMethod.signature(), context.debugProcess.project) { - override fun processLocatableEvent(action: SuspendContextCommandImpl, event: LocatableEvent): Boolean { + override fun processLocatableEvent(action: SuspendContextCommandImpl, event: LocatableEvent?): Boolean { super.processLocatableEvent(action, event).also { stopped -> if (!stopped) return false context.debugProcess.requestsManager.deleteRequest(this) diff --git a/plugins/kotlin/jvm-debugger/coroutines/src/org/jetbrains/kotlin/idea/debugger/coroutine/view/CoroutinesDumpAsyncProvider.kt b/plugins/kotlin/jvm-debugger/coroutines/src/org/jetbrains/kotlin/idea/debugger/coroutine/view/CoroutinesDumpAsyncProvider.kt index 0a9883aa6eeb..9f99dbf1dcbd 100644 --- a/plugins/kotlin/jvm-debugger/coroutines/src/org/jetbrains/kotlin/idea/debugger/coroutine/view/CoroutinesDumpAsyncProvider.kt +++ b/plugins/kotlin/jvm-debugger/coroutines/src/org/jetbrains/kotlin/idea/debugger/coroutine/view/CoroutinesDumpAsyncProvider.kt @@ -1,6 +1,7 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package org.jetbrains.kotlin.idea.debugger.coroutine.view +import com.intellij.debugger.JavaDebuggerBundle import com.intellij.debugger.actions.ThreadDumpAction import com.intellij.debugger.engine.SuspendContextImpl import com.intellij.debugger.impl.DebuggerContextImpl @@ -28,6 +29,8 @@ import com.intellij.unscramble.MergeableToken @ApiStatus.Internal class CoroutinesDumpAsyncProvider : ThreadDumpItemsProviderFactory() { override fun getProvider(context: DebuggerContextImpl): ThreadDumpItemsProvider = object : ThreadDumpItemsProvider { + override val progressText: String get() = JavaDebuggerBundle.message("thread.dump.coroutines.progress") + private val vm = context.debugProcess!!.virtualMachineProxy private val enabled =