PY-88280 Add global SDK configuration mutex and wire into SDK setup flows

- Add PythonSdkConfigurationMutex with StateFlow-based lock observation
  and tryWithLock that throws SdkConfigurationAlreadyInProgressException
- Add suspended Module.findPythonSdk() extension that waits for project
  model to load before resolving
- Wrap setupSdk in the global mutex with progress text reporting
- Wire mutex into InterpreterFixExecutor, PythonLanguageRuntimeUI
- Simplify PyAsyncFileInspectionRunner to use the shared mutex state

(cherry picked from commit ace82cf69f1ce88b7a851d063af553f5b6f665d4)

GitOrigin-RevId: 02f58779d332886406393410c582d94458c1db65
This commit is contained in:
Vitaly Legchilkin
2026-03-18 17:22:13 +00:00
committed by intellij-monorepo-bot
parent 1dc7eb5123
commit 4bf4e78ab1
9 changed files with 131 additions and 59 deletions
@@ -9,6 +9,7 @@ import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.roots.ModuleRootModificationUtil
import com.jetbrains.python.module.PyModuleService
import com.jetbrains.python.sdk.legacy.PythonSdkUtil
import org.jetbrains.annotations.ApiStatus
/**
* Returns the Python SDK configured for this module, or `null` if none is set.
@@ -28,7 +29,9 @@ suspend fun Module.findPythonSdk(): Sdk? {
* Prefer the suspended [findPythonSdk] extension in coroutine contexts.
*/
var Module.pythonSdk: Sdk?
@ApiStatus.Obsolete
get() = PythonSdkUtil.findPythonSdk(this)
@ApiStatus.Internal
set(newSdk) {
val prevSdk = pythonSdk
thisLogger.info("Setting PythonSDK $newSdk to module $this")
@@ -39,5 +42,4 @@ var Module.pythonSdk: Sdk?
ApplicationManager.getApplication().messageBus.syncPublisher(PySdkListener.TOPIC).moduleSdkUpdated(this, prevSdk, newSdk)
}
private val thisLogger = fileLogger()
@@ -0,0 +1,53 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.sdk
import com.jetbrains.python.Result
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import org.jetbrains.annotations.ApiStatus
/**
* A [Mutex] wrapper that exposes lock state as an observable [StateFlow].
*/
@ApiStatus.Internal
class ObservableMutex {
private val delegate = Mutex()
private val _isLocked = MutableStateFlow(false)
/** Observable lock state. `true` while the mutex is held. */
val isLocked: StateFlow<Boolean> = _isLocked.asStateFlow()
/** Acquires the lock, runs [action], and releases the lock. Suspends if already held. */
suspend fun <T> withLock(action: suspend () -> T): T {
delegate.lock()
try {
_isLocked.value = true
return action()
}
finally {
_isLocked.value = false
delegate.unlock()
}
}
/**
* Runs [action] under the lock if it's not already held.
* Returns [Result.Failure] with [AlreadyLocked] if the lock is busy.
*/
suspend fun <T> tryWithLock(action: suspend () -> T): Result<T, AlreadyLocked> {
if (!delegate.tryLock()) return Result.failure(AlreadyLocked)
try {
_isLocked.value = true
return Result.success(action())
}
finally {
_isLocked.value = false
delegate.unlock()
}
}
/** Sentinel error returned by [tryWithLock] when the lock is already held. */
data object AlreadyLocked
}
@@ -0,0 +1,16 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.sdk
import org.jetbrains.annotations.ApiStatus
/**
* Global mutex that serializes all Python SDK configuration operations — both
* auto-configuration on startup and manual SDK creation from the UI.
*
* This is intentionally global (not per-module) because setting an SDK on one module
* can affect others via inherited project SDK in multi-module workspaces.
*
* Observe [isLocked][ObservableMutex.isLocked] to track whether an SDK configuration is running.
*/
@ApiStatus.Internal
val pythonSdkConfigurationMutex: ObservableMutex = ObservableMutex()
@@ -8,23 +8,29 @@ import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.components.serviceIfCreated
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.application.ApplicationManager
import com.jetbrains.python.PythonPluginDisposable
import com.intellij.openapi.module.Module
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.project.Project
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.NlsContexts
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.platform.util.coroutines.sync.OverflowSemaphore
import com.intellij.psi.PsiFile
import com.intellij.ui.EditorNotifications
import com.intellij.ui.components.ActionLink
import com.jetbrains.python.inspections.interpreter.InterpreterFix
import com.jetbrains.python.inspections.interpreter.BusyGuardExecutor
import com.jetbrains.python.inspections.interpreter.InterpreterFix
import com.jetbrains.python.orLogException
import com.jetbrains.python.sdk.PySdkListener
import com.jetbrains.python.sdk.pythonSdkConfigurationMutex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import com.intellij.platform.util.coroutines.sync.OverflowSemaphore
import kotlinx.coroutines.future.asCompletableFuture
import kotlinx.coroutines.launch
import org.jetbrains.annotations.ApiStatus
@@ -44,11 +50,30 @@ class PyAsyncFileInspectionRunner(
cacheTtl: Duration = 20.seconds,
private val cacheLoader: suspend (Module) -> InspectionRunnerResult,
) {
init {
// Invalidate cached inspection results when a module's SDK changes, so the notification
// panel re-evaluates immediately instead of waiting for the cache TTL to expire.
// PythonPluginDisposable is used as parent so the connection is cleaned up on plugin unload.
ApplicationManager.getApplication().messageBus.connect(PythonPluginDisposable.getInstance()).subscribe(PySdkListener.TOPIC, object : PySdkListener {
override fun moduleSdkUpdated(module: Module, prevSdk: Sdk?, newSdk: Sdk?) {
cache.invalidate(module)
}
})
}
private val cache: LoadingCache<Module, Deferred<InspectionRunnerResult>> = Caffeine.newBuilder()
.refreshAfterWrite(cacheTtl.toJavaDuration())
.weakKeys()
.evictionListener<Module, Deferred<InspectionRunnerResult>> { _, value, _ -> value?.cancel() }
.build(object : CacheLoader<Module, Deferred<InspectionRunnerResult>> {
override fun load(key: Module): Deferred<InspectionRunnerResult> = startComputation(key)
override fun load(key: Module): Deferred<InspectionRunnerResult> {
// Eagerly remove the cache entry when the module is disposed to break strong reference
// chains from cache values (e.g., CreateSdkInfo.sdkCreator capturing Module) that would
// otherwise prevent GC of disposed projects.
@Suppress("IncorrectParentDisposable")
Disposer.register(key) { cache.invalidate(key) }
return startComputation(key)
}
/**
* On refresh, the old (completed) [Deferred] is served to callers while the new one is loading.
@@ -135,9 +160,7 @@ private class CacheEvictingFix(
@ApiStatus.Internal
@Service(Service.Level.PROJECT)
class InterpreterFixExecutor(private val project: Project, internal val scope: CoroutineScope) : BusyGuardExecutor {
private val semaphore = OverflowSemaphore(permits = 1, overflow = BufferOverflow.DROP_LATEST)
private val _isBusy = MutableStateFlow(false)
override val isBusy: StateFlow<Boolean> = _isBusy
override val isBusy: StateFlow<Boolean> = pythonSdkConfigurationMutex.isLocked
init {
scope.launch {
@@ -147,19 +170,7 @@ class InterpreterFixExecutor(private val project: Project, internal val scope: C
override fun execute(action: suspend () -> Unit) {
scope.launch {
semaphore.withPermit {
_isBusy.value = true
try {
action()
}
finally {
_isBusy.value = false
}
}
}.invokeOnCompletion { cause ->
if (cause != null && _isBusy.value) {
LOG.warn("Interpreter fix submission discarded: another fix is already in progress")
}
pythonSdkConfigurationMutex.tryWithLock { action() }.orLogException(LOG)
}
}
@@ -10,6 +10,8 @@ import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.sdk.ModuleOrProject
import com.jetbrains.python.sdk.add.collector.PythonNewInterpreterAddedCollector
import com.jetbrains.python.sdk.configuration.CreateSdkInfoWithTool
import com.jetbrains.python.sdk.moduleIfExists
import com.jetbrains.python.sdk.pythonSdk
import com.jetbrains.python.sdk.service.PySdkService.Companion.pySdkService
import com.jetbrains.python.venvReader.VirtualEnvReader
import kotlinx.coroutines.Deferred
@@ -51,7 +53,6 @@ class PythonAddLocalInterpreterPresenter(
return
}
is Result.Success -> {
moduleOrProject.project.pySdkService.persistSdk(r.result)
val isPreviouslyConfigured = addEnvironment.createStatisticsInfo(PythonInterpreterCreationTargets.LOCAL_MACHINE).previouslyConfigured
PythonNewInterpreterAddedCollector.logPythonNewInterpreterAdded(r.result, isPreviouslyConfigured)
_sdkShared.emit(r.result)
@@ -6,7 +6,6 @@ import com.intellij.notification.NotificationAction
import com.intellij.notification.NotificationGroupManager
import com.intellij.notification.NotificationType
import com.intellij.notification.NotificationsManager
import com.intellij.openapi.application.writeAction
import com.intellij.openapi.diagnostic.getOrLogException
import com.intellij.openapi.help.HelpManager
import com.intellij.openapi.module.Module
@@ -14,7 +13,6 @@ import com.intellij.openapi.observable.properties.AtomicProperty
import com.intellij.openapi.observable.properties.ObservableProperty
import com.intellij.openapi.project.Project
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil
import com.intellij.openapi.ui.ValidationInfo
import com.intellij.openapi.ui.validation.DialogValidationRequestor
import com.intellij.openapi.ui.validation.WHEN_PROPERTY_CHANGED
@@ -26,6 +24,7 @@ import com.intellij.platform.ide.progress.ModalTaskOwner
import com.intellij.platform.ide.progress.TaskCancellation
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.platform.ide.progress.withModalProgress
import com.intellij.platform.util.progress.withProgressText
import com.intellij.python.common.tools.ToolId
import com.intellij.python.community.execService.Args
import com.intellij.python.community.execService.BinaryToExec
@@ -52,10 +51,10 @@ import com.jetbrains.python.psi.LanguageLevel
import com.jetbrains.python.run.PythonInterpreterTargetEnvironmentFactory
import com.jetbrains.python.sdk.LOGGER
import com.jetbrains.python.sdk.ModuleOrProject
import com.jetbrains.python.sdk.PythonSdkType
import com.jetbrains.python.sdk.configuration.CONDA_TOOL_ID
import com.jetbrains.python.sdk.configuration.PIPENV_TOOL_ID
import com.jetbrains.python.sdk.configuration.VENV_TOOL_ID
import com.jetbrains.python.sdk.createSdk
import com.jetbrains.python.sdk.excludeInnerVirtualEnv
import com.jetbrains.python.sdk.flavors.PyFlavorAndData
import com.jetbrains.python.sdk.flavors.PyFlavorData
@@ -63,6 +62,9 @@ import com.jetbrains.python.sdk.flavors.VirtualEnvSdkFlavor
import com.jetbrains.python.sdk.installSdkIfNeeded
import com.jetbrains.python.sdk.moduleIfExists
import com.jetbrains.python.sdk.persist
import com.jetbrains.python.sdk.pythonSdk
import com.jetbrains.python.sdk.pythonSdkConfigurationMutex
import com.jetbrains.python.sdk.service.PySdkService.Companion.pySdkService
import com.jetbrains.python.sdk.setAssociationToModule
import com.jetbrains.python.sdk.suggestAssociatedSdkName
import com.jetbrains.python.statistics.InterpreterTarget
@@ -97,14 +99,18 @@ abstract class PythonAddEnvironment<P : PathHolder>(open val model: PythonAddInt
*/
protected abstract suspend fun getOrCreateSdk(moduleOrProject: ModuleOrProject): PyResult<Sdk>
protected suspend fun setupSdk(moduleOrProject: ModuleOrProject): PyResult<Sdk> {
protected suspend fun setupSdk(moduleOrProject: ModuleOrProject): PyResult<Sdk> = pythonSdkConfigurationMutex.withLock {
savePathToExecutableToProperties(null)
val sdk = getOrCreateSdk(moduleOrProject).getOr { return it }
val sdk = getOrCreateSdk(moduleOrProject).getOr { return@withLock it }
moduleOrProject.project.pySdkService.persistSdk(sdk)
moduleOrProject.project.excludeInnerVirtualEnv(sdk)
moduleOrProject.moduleIfExists?.let { sdk.setAssociationToModule(it) }
moduleOrProject.moduleIfExists?.let {
it.pythonSdk = sdk
sdk.setAssociationToModule(it)
}
return Result.success(sdk)
Result.success(sdk)
}
@ApiStatus.Internal
@@ -233,13 +239,11 @@ internal fun installBaseSdk(sdk: Sdk, existingSdks: List<Sdk>): Sdk? {
internal suspend fun <P : PathHolder> setupSdk(
project: Project?,
allSdks: List<Sdk>,
fileSystem: FileSystem<P>,
pythonBinaryPath: P,
languageLevel: LanguageLevel,
targetPanelExtension: TargetPanelExtension?,
): PyResult<Sdk> {
val sdkType = PythonSdkType.getInstance()
val (additionalData, customSdkSuggestedName) = when (fileSystem) {
is FileSystem.Eel -> null to suggestAssociatedSdkName(pythonBinaryPath.toString(), project?.basePath)
@@ -257,28 +261,15 @@ internal suspend fun <P : PathHolder> setupSdk(
}
}
val sdk = SdkConfigurationUtil.createSdk(
allSdks,
pythonBinaryPath.toString(),
sdkType,
additionalData,
customSdkSuggestedName
return createSdk(
pythonBinaryPath,
customSdkSuggestedName,
additionalData
)
sdk.sdkModificator.let { modifiableSdk ->
modifiableSdk.versionString = languageLevel.toPythonVersion()
writeAction {
modifiableSdk.commitChanges()
}
}
sdkType.setupSdkPaths(sdk)
return PyResult.success(sdk)
}
internal suspend fun <P : PathHolder> PythonSelectableInterpreter<P>.setupSdk(
moduleOrProject: ModuleOrProject,
allSdks: List<Sdk>,
fileSystem: FileSystem<P>,
targetPanelExtension: TargetPanelExtension?,
isAssociateWithModule: Boolean,
@@ -289,7 +280,6 @@ internal suspend fun <P : PathHolder> PythonSelectableInterpreter<P>.setupSdk(
val newSdk = setupSdk(
project = moduleOrProject.project,
allSdks = allSdks,
fileSystem = fileSystem,
pythonBinaryPath = homePath!!,
languageLevel = pythonInfo.languageLevel,
@@ -51,7 +51,6 @@ class PythonExistingEnvironmentSelector<P : PathHolder>(model: PythonAddInterpre
// todo error handling, nullability issues
val sdk = model.state.selectedInterpreter.get()!!.setupSdk(
moduleOrProject = moduleOrProject,
allSdks = model.existingSdks,
fileSystem = model.fileSystem,
targetPanelExtension = model.state.targetPanelExtension.get(),
isAssociateWithModule = true,
@@ -34,7 +34,6 @@ suspend fun <P : PathHolder> PythonMutableTargetAddInterpreterModel<P>.setupVirt
moduleOrProject = moduleOrProject,
pathToBasePython = baseSdkPath,
pathToVenvHome = venvFolder,
existingSdks = existingSdks
).getOr { return it }
return PyResult.success(newSdk.sdk)
@@ -44,7 +43,6 @@ private suspend fun <P : PathHolder> PythonAddInterpreterModel<P>.createSdkFromB
moduleOrProject: ModuleOrProject,
pathToBasePython: P,
pathToVenvHome: P,
existingSdks: List<Sdk>,
): PyResult<SdkWrapper<P>> {
val basePython = fileSystem.getBinaryToExec(pathToBasePython)
val inheritSitePackages = venvViewModel.inheritSitePackages.get()
@@ -57,7 +55,6 @@ private suspend fun <P : PathHolder> PythonAddInterpreterModel<P>.createSdkFromB
val sdkResult = detectedSelectableInterpreter.setupSdk(
moduleOrProject = moduleOrProject,
allSdks = existingSdks,
fileSystem = fileSystem,
targetPanelExtension = state.targetPanelExtension.get(),
isAssociateWithModule = !venvViewModel.makeAvailableForAllProjects.get()
@@ -32,6 +32,7 @@ import com.jetbrains.python.sdk.add.v2.PythonAddCustomInterpreter
import com.jetbrains.python.sdk.add.v2.PythonInterpreterSelectionMode
import com.jetbrains.python.sdk.add.v2.PythonLocalAddInterpreterModel
import com.jetbrains.python.sdk.configurePythonSdk
import com.jetbrains.python.sdk.pythonSdkConfigurationMutex
import com.jetbrains.python.sdk.service.PySdkService.Companion.pySdkService
import com.jetbrains.python.util.ShowingMessageErrorSync
import kotlinx.coroutines.CompletableDeferred
@@ -116,12 +117,14 @@ class PythonLanguageRuntimeUI(
val sdk = runWithModalProgressBlocking(project, message("python.sdk.progress.setting.up.environment")) {
withContext(TraceContext(message("trace.context.add.remote.python.sdk.dialog", targetSupplier.get().getTargetType().displayName))) {
sdkManager.getOrCreateSdkWithModal(ModuleOrProject.ModuleAndProject(module)).onFailure {
errorSink.emit(it)
}.successOrNull?.also {
configurePythonSdk(project, module, it)
project.pySdkService.persistSdk(it)
PythonNewInterpreterAddedCollector.logPythonNewInterpreterAdded(it, false)
pythonSdkConfigurationMutex.withLock {
sdkManager.getOrCreateSdkWithModal(ModuleOrProject.ModuleAndProject(module)).onFailure {
errorSink.emit(it)
}.successOrNull?.also {
configurePythonSdk(project, module, it)
project.pySdkService.persistSdk(it)
PythonNewInterpreterAddedCollector.logPythonNewInterpreterAdded(it, false)
}
}
}
}