how to implement a stateless listener (XDebugSessionListener/XDebuggerManagerListener)

Answered

Hello Community,

The problem I am trying to solve

In my Python plugin I want to create an XDebugSessionListener for every Python debug session. So that I can evaluate some information (e.g. version of an installed Python package) and store the result in project.putUserData. This information is necessary to decide whether some of my debug-actions can be activated in the debugger tool window. The evaluation of this data can't be done inside the AnAction::update where I have to decide if my action is visible or not.

My current PoC seem to work as expected. But my code violates at least one rule which is described here: plugin-listeners

Listener implementations must be stateless and may not implement life-cycle (e.g., Disposable).

My current implementation:

plugin.xml

<projectListeners>
<listener class="MyDebuggerListener"
topic="com.intellij.xdebugger.XDebuggerManagerListener"
/>
</projectListeners>

MyDebuggerListener (Kotlin code):

import com.intellij.xdebugger.XDebugSession
import com.intellij.xdebugger.XDebuggerManagerListener
import com.jetbrains.python.debugger.PyDebugProcess

class MyDebuggerListener : XDebuggerManagerListener {
private var currentSessionListener: MyDebugSessionListener? = null

override fun currentSessionChanged(previousSession: XDebugSession?, currentSession: XDebugSession?) {
if (previousSession != null) {
currentSessionListener?.let {
currentSessionListener = null
previousSession.removeSessionListener(it)
}
}
if (currentSession?.debugProcess is PyDebugProcess) {
// MyDebugSessionListener needs the `currentSession` to evaluate a simple expression as soon as the session is paused.
currentSessionListener = MyDebugSessionListener(currentSession).also {
currentSession.addSessionListener(it)
}
}
}
}


My questions are:

1) What would be the correct way to clean up an XDebugSessionListener (MyDebugSessionListener in my code example) when it isn't allowed to store a reference to the last registered one inside the used XDebuggerManagerListener?
A possible solution should also take into account, that an active XDebugSessionListener instance has to be removed in case the user unloads my plugin. 

2) What generally happens to active projectListeners instances, defined in the plugin.xml, when a user unloads a plugin? Are these listeners automatically unsubscribed?

 

0
8 comments

I noticed today that a user can start more than one debug process. Therefore I need to evaluate the version for each of the started debug processes.


My updated code now looks like this:

plugin.xml (simplified)

<extensions defaultExtensionNs="com.intellij">
<projectService serviceImplementation="ParentDisposableService"/>
</extensions>

<projectListeners>
<listener class="MyDebuggerListener"
topic="com.intellij.xdebugger.XDebuggerManagerListener"
/>
</projectListeners>

Code:

val VERSION_LISTENER_MAP: Key<ConcurrentHashMap<XDebugSession, EvalVersionDebugSessionListener>> = Key.create("cms.rendner.versionStr")

private class RemoveSessionFromUserData(private val session: XDebugSession): Disposable {
override fun dispose() {
session.project.getUserData(VERSION_LISTENER_MAP)?.remove(session)
}
}

class MyDebuggerListener: XDebuggerManagerListener {

/**
* A user can start more than one debugProcess.
* Therefore, the session is used as key to store the result.
*/
override fun processStarted(debugProcess: XDebugProcess) {
if (debugProcess is PyDebugProcess) {
           debugProcess.session.let { session ->
val listener = EvalVersionDebugSessionListener(session)
val cleaner = RemoveSessionFromUserData(session)
session.addSessionListener(listener, cleaner)
session.project.let {project ->
Disposer.register(project.service<ParentDisposableService>(), cleaner)
var map = project.getUserData(VERSION_LISTENER_MAP)
if (map == null) {
map = ConcurrentHashMap()
project.putUserData(VERSION_LISTENER_MAP, map)
}
map.put(session, listener)
}
}
}
}

override fun processStopped(debugProcess: XDebugProcess) {
if (debugProcess is PyDebugProcess) {
           debugProcess.session.let { session ->
session.project.getUserData(VERSION_LISTENER_MAP)?.remove(session)?.let { listener ->
session.removeSessionListener(listener)
}
}
}
}
}

This solves my problem described in Question 1, listener are now stateless, except that EvalVersionDebugSessionListener requires the session to evaluate the info.
Is this approach OK or is there something simpler to remove listeners that are no longer needed?

 

0

> The evaluation of this data can't be done inside the AnAction::update where I have to decide if my action is visible or not.
Could you please provide more details on why this is the case?

0

Hi Yann,

the evaluation, if a specific Python module is installed, takes some time and can't be done inside the EDT. So it is done in another thread. But the update method of my action is not called again after I got the result.

Here is a minimal example (without proper error handling) to demo the problem:

plugin.xml (simplified)

<actions>
<group id="debugActionGroup">
<action class="ShowDialogIfModuleIsInstalledAction"
text="Open Custom Dialog">
</action>
<add-to-group group-id="XDebugger.ValueGroup" relative-to-action="Debugger.ShowReferring" anchor="before"/>
</group>
</actions>

the action:

import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.util.Key
import com.intellij.xdebugger.XDebuggerManager
import com.intellij.xdebugger.evaluation.XDebuggerEvaluator
import com.intellij.xdebugger.frame.XValue
import com.jetbrains.python.debugger.PyDebugValue


val MODULE_IS_INSTALLED: Key<Boolean> = Key.create("cms.rendner.moduleIsInstalled")

class ShowDialogIfModuleIsInstalledAction: AnAction() {

override fun update(event: AnActionEvent) {
println("::update called")
super.update(event)
event.presentation.isEnabledAndVisible = event.project != null && hasModuleInstalled(event, "os")
}

override fun actionPerformed(e: AnActionEvent) {
println("Python module is installed - would open dialog")
}

private fun hasModuleInstalled(event: AnActionEvent, module: String): Boolean {
val project = event.project ?: return false
val session = XDebuggerManager.getInstance(project).currentSession ?: return false
val isInstalled = session.project.getUserData(MODULE_IS_INSTALLED)
if (isInstalled == null) {
println("hasModuleInstalled -> check started")
session.debugProcess.evaluator?.evaluate(
"__import__('$module')",
object : XDebuggerEvaluator.XEvaluationCallback {
override fun errorOccurred(errorMessage: String) {
session.project.putUserData(MODULE_IS_INSTALLED, false)
}

override fun evaluated(result: XValue) {
if (result is PyDebugValue && result.value != null) {
session.project.putUserData(MODULE_IS_INSTALLED, result.value != "None")
println("hasModuleInstalled -> check done")
}
}
},
null,
)
return false
}
return isInstalled
}
}

I test my action with a simple Python code like this one:

a = 0
breakpoint()

When the debugger hits the breakpoint I select the variable a in the debugger tool window and open the context menu (right click on a). If everything would work the context menu should include the entry Open Custom Dialog.

0

I guess I found an acceptable way to solve my problems. Instead of storing the debug sessions in a map, I now use some kind of fingerprint to store my information. This helps to reduce the risk of potential memory leaks.

From my side, all questions are answered or no longer relevant.

 

Here is my current solution:

plugin.xml (simplified)

<extensions defaultExtensionNs="com.intellij">
<projectService serviceImplementation="ParentDisposableService"/>
</extensions>

<projectListeners>
<listener
class
="MyDebuggerListener
topic="com.intellij.xdebugger.XDebuggerManagerListener"
/>
</projectListeners>

The code:

import com.intellij.openapi.components.service
import com.intellij.xdebugger.XDebugProcess
import com.intellij.xdebugger.XDebuggerManagerListener
import com.jetbrains.python.debugger.PyDebugProcess

class MyDebuggerListener: XDebuggerManagerListener {

override fun processStarted(debugProcess: XDebugProcess) {
if (debugProcess is PyDebugProcess && !debugProcess.project.isDisposed) {
     debugProcess.session.let {
it.addSessionListener(
MyEvalModuleAvailableDebugSessionListener(it),
it.project.service<ParentDisposableService>(),
)
}
}
}
}
import com.intellij.xdebugger.XDebugSession
import com.intellij.xdebugger.XDebugSessionListener
import com.intellij.xdebugger.evaluation.XDebuggerEvaluator
import com.intellij.xdebugger.frame.XValue
import com.jetbrains.python.debugger.PyDebugValue
import java.util.concurrent.atomic.AtomicBoolean

class MyEvalModuleAvailableDebugSessionListener(private val session: XDebugSession): XDebugSessionListener {
/**
* AtomicBoolean because [sessionResumed] and [sessionPaused] are called from different threads.
*/
private var pendingEval = AtomicBoolean(false)

override fun sessionStopped() {
session.removeSessionListener(this)
MyModuleAvailableProvider.remove(session)
}

override fun sessionResumed() {
if (!MyModuleAvailableProvider.isAvailable(session)) {
// reset if pending, to retry next time
pendingEval.compareAndSet(true, false)
}
}

override fun sessionPaused() {
session.debugProcess.evaluator?.let {
if (!pendingEval.compareAndSet(false, true)) return
it.evaluate(
"__import__('MY_MODULE')",
object : XDebuggerEvaluator.XEvaluationCallback {
override fun errorOccurred(errorMessage: String) {
// Only a paused session can evaluate expressions (doesn't matter if current session or not).
// If not paused, ignore error.
if (session.isPaused) {
// "errorMessage" should be something like: "{ModuleNotFoundError}No module named ..."
pendingEval.set(false)
}
}

override fun evaluated(result: XValue) {
if (result is PyDebugValue) {
pendingEval.set(false)
MyModuleAvailableProvider.setAvailable(session)
}
}
},
null,
)
}
}
}
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Key
import com.intellij.xdebugger.XDebugSession
import java.util.concurrent.ConcurrentHashMap

class MyModuleAvailableProvider {
companion object {
// A user can start more than one debug process in parallel per project.
// Therefore, a map is used to store the result.
private val KEY: Key<MutableMap<String, Boolean?>> = Key.create("moduleAvailableInSession")

fun init(project: Project) {
project.putUserData(KEY, ConcurrentHashMap())
}

fun cleanup(project: Project) {
project.putUserData(KEY, null)
}

fun remove(session: XDebugSession) {
session.project.getUserData(KEY)?.remove(createSessionFingerprint(session))
}

fun setAvailable(session: XDebugSession) {
session.project.getUserData(KEY)?.put(createSessionFingerprint(session), true)
}

fun isAvailable(session: XDebugSession): Boolean {
return session.project.getUserData(KEY)?.get(createSessionFingerprint(session)) == true
}

/**
* Creates a fingerprint to identify a session without having to store a reference (to prevent memory leaks).
* (expects the name and hash code of a session not to change)
*/
private fun createSessionFingerprint(session: XDebugSession): String {
return "${session.sessionName}_${session.hashCode()}"
}
}
}
import com.intellij.openapi.Disposable
import com.intellij.openapi.project.Project

class ParentDisposableService(private val project: Project): Disposable {

init {
       MyModuleAvailableProvider.init(project)
}

override fun dispose() {
       MyModuleAvailableProvider.cleanup(project)
}
}
0

Hi, you can try using BGT action update thread in your action: https://plugins.jetbrains.com/docs/intellij/basic-action-system.html#principal-implementation-overrides and do the logic right in the action update

0

Hello Egor,

that could actually be a good solution. At the moment I still support older IntelliJ versions, so I can't use this solution yet. But I will try it out as soon as I drop support for the old versions in my plugin.

Thank you for letting me know.

0

I was able to try it out today and it works perfectly (so much simpler now).

Thank you Egor Ushakov for the tip.

0

Please sign in to leave a comment.