From 6fddbd5e134f40b89efd3d121e2cd5e71a63283d Mon Sep 17 00:00:00 2001 From: Ilya Korennoy Date: Mon, 27 Jul 2026 15:56:31 +0400 Subject: [PATCH] PY-91090 Do not throw on corrupt/outdated persistent state value (cherry picked from commit 34e5cbeb5a938dc7d4f44645e2f99b1540a6c638) IJ-CR-216573 GitOrigin-RevId: 019909a092445fc39b07152e3e1a24505528d9c5 --- .../CustomImlComponentServiceImpl.kt | 19 ++++++- .../CustomImlComponentServiceTest.kt | 33 ++++++++++++ .../PyDocumentationSettings.java | 21 +++----- ...yDocumentationSettingsSerializationTest.kt | 53 +++++++++++++++++++ 4 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 python/testSrc/com/intellij/python/junit5Tests/unit/alsoWin/PyDocumentationSettingsSerializationTest.kt diff --git a/platform/projectModel-api/src/com/intellij/openapi/components/CustomImlComponentServiceImpl.kt b/platform/projectModel-api/src/com/intellij/openapi/components/CustomImlComponentServiceImpl.kt index c6b50a400a12..784cd81332ed 100644 --- a/platform/projectModel-api/src/com/intellij/openapi/components/CustomImlComponentServiceImpl.kt +++ b/platform/projectModel-api/src/com/intellij/openapi/components/CustomImlComponentServiceImpl.kt @@ -4,6 +4,8 @@ package com.intellij.openapi.components import com.intellij.configurationStore.deserialize import com.intellij.configurationStore.jdomSerializer import com.intellij.configurationStore.serializeObjectInto +import com.intellij.openapi.diagnostic.rethrowControlFlowException +import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.module.Module import com.intellij.openapi.project.Project import com.intellij.openapi.util.JDOMUtil @@ -17,6 +19,8 @@ import com.intellij.platform.workspace.storage.MutableEntityStorage import com.intellij.util.xmlb.Constants import com.intellij.workspaceModel.ide.legacyBridge.WorkspaceModelLegacyBridge import org.jdom.Element +import java.util.concurrent.CancellationException +import java.util.concurrent.ConcurrentHashMap private const val COMPONENT_ELEMENT: String = "component" @@ -26,11 +30,24 @@ internal class CustomImlComponentServiceImpl( private val legacyBridge = WorkspaceModelLegacyBridge.getInstance(project) + /** Guards against log spam: [getComponentValue] re-deserializes on every read, so a corrupt component is reported once. */ + private val reportedDeserializationFailures = ConcurrentHashMap.newKeySet() + override fun getComponentValue(module: Module, componentName: String, componentClass: Class): T? { val moduleEntity = legacyBridge.findModuleEntity(module) ?: return null val entity = moduleEntity.customImlComponent ?: return null val component = entity.components[componentName] ?: return null - return JDOMUtil.load(component).deserialize(componentClass) + // A corrupt or outdated stored value must not break every reader. + return try { + JDOMUtil.load(component).deserialize(componentClass) + } + catch (e: Exception) { + rethrowControlFlowException(e) + if (reportedDeserializationFailures.add("${module.name}#$componentName")) { + thisLogger().warn("Cannot deserialize custom iml component '$componentName' for module '${module.name}'; falling back to default", e) + } + null + } } override suspend fun setComponentValue(module: Module, componentName: String, component: T) { diff --git a/platform/workspace/jps/tests/testSrc/com/intellij/workspaceModel/ide/impl/jps/serialization/CustomImlComponentServiceTest.kt b/platform/workspace/jps/tests/testSrc/com/intellij/workspaceModel/ide/impl/jps/serialization/CustomImlComponentServiceTest.kt index e9028e23c3c0..9caa599eedac 100644 --- a/platform/workspace/jps/tests/testSrc/com/intellij/workspaceModel/ide/impl/jps/serialization/CustomImlComponentServiceTest.kt +++ b/platform/workspace/jps/tests/testSrc/com/intellij/workspaceModel/ide/impl/jps/serialization/CustomImlComponentServiceTest.kt @@ -20,6 +20,7 @@ import com.intellij.testFramework.junit5.TestDisposable import com.intellij.testFramework.registerOrReplaceServiceInstance import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import java.nio.file.Files @@ -150,6 +151,32 @@ class CustomImlComponentServiceTest { } } + @Test + fun `returns null instead of throwing when stored component cannot be deserialized`() { + registerContributor(SecondTestModuleService.COMPONENT_NAME, disposable) + + val projectPath = tempDir.newPath("corrupt-component-project") + Files.createDirectories(projectPath) + + openProject(projectPath) { project, _ -> + runWriteAction { + ModuleManager.getInstance(project).newModule(projectPath.resolve(MODULE_FILE_NAME), EmptyModuleType.EMPTY_MODULE) + } + saveProject(project) + } + + openProject(projectPath) { project, _ -> + val module = ModuleManager.getInstance(project).modules.single() + val componentService = project.service() + + runBlocking { + componentService.setComponentValue(module, SecondTestModuleService.COMPONENT_NAME, CorruptTestState("not-a-number")) + } + + assertNull(componentService.getComponentValue(module, SecondTestModuleService.COMPONENT_NAME)) + } + } + private fun registerContributor(componentName: String, disposable: Disposable) { BaseIdeSerializationContext.CUSTOM_IML_COMPONENT_NAME_CONTRIBUTOR_EP.point .registerExtension(TestCustomImlComponentNameContributor(componentName), disposable) @@ -194,6 +221,12 @@ private class TestCustomImlComponentNameContributor( override val componentName: String, ) : CustomImlComponentNameContributor +/** + * Serializes a `count` that is not an `Int`, so deserializing it back into [SecondTestModuleService.State] + * (whose `count` is an `Int`) fails — mimicking a stored value incompatible with the current state schema. + */ +internal data class CorruptTestState(var count: String = "") + internal class FirstTestModuleService(private val module: Module) { data class State( var value: String = "", diff --git a/python/python-syntax-core/src/com/jetbrains/python/documentation/PyDocumentationSettings.java b/python/python-syntax-core/src/com/jetbrains/python/documentation/PyDocumentationSettings.java index 9ece7380d907..21912b8bfa7f 100644 --- a/python/python-syntax-core/src/com/jetbrains/python/documentation/PyDocumentationSettings.java +++ b/python/python-syntax-core/src/com/jetbrains/python/documentation/PyDocumentationSettings.java @@ -11,6 +11,7 @@ import com.intellij.psi.PsiFile; import com.intellij.psi.impl.source.PsiFileImpl; import com.intellij.psi.stubs.StubElement; import com.intellij.util.xmlb.annotations.OptionTag; +import com.intellij.util.xmlb.annotations.Transient; import com.jetbrains.python.PyNames; import com.jetbrains.python.ast.PyAstClass; import com.jetbrains.python.ast.PyAstElsePart; @@ -186,20 +187,12 @@ public abstract class PyDocumentationSettings return myDocStringFormat; } - public void setFormat(@NotNull DocStringFormat format) { - myDocStringFormat = format; - } - - // Legacy name of the field to preserve settings format - @SuppressWarnings("unused") - @OptionTag("myDocStringFormat") - public @NotNull String getFormatName() { - return myDocStringFormat.getName(); - } - - @SuppressWarnings("unused") - public void setFormatName(@NotNull String name) { - myDocStringFormat = DocStringFormat.fromNameOrPlain(name); + public void setFormat(@Nullable DocStringFormat format) { + if (format == null) { + myDocStringFormat = DocStringFormat.PLAIN; + } else { + myDocStringFormat = format; + } } } diff --git a/python/testSrc/com/intellij/python/junit5Tests/unit/alsoWin/PyDocumentationSettingsSerializationTest.kt b/python/testSrc/com/intellij/python/junit5Tests/unit/alsoWin/PyDocumentationSettingsSerializationTest.kt new file mode 100644 index 000000000000..6488dba36928 --- /dev/null +++ b/python/testSrc/com/intellij/python/junit5Tests/unit/alsoWin/PyDocumentationSettingsSerializationTest.kt @@ -0,0 +1,53 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.python.junit5Tests.unit.alsoWin + +import com.intellij.openapi.util.JDOMUtil +import com.intellij.testFramework.junit5.TestApplication +import com.intellij.util.xmlb.XmlSerializer +import com.jetbrains.python.documentation.PyDocumentationSettings.ServiceState +import com.jetbrains.python.documentation.docstrings.DocStringFormat +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Regression test for PY-91090: a stored docstring-format value that no valid build recognizes anymore + * (e.g. `format="EPYTEXT"`, removed by PY-52574) must not break deserialization of [ServiceState]. + */ +@TestApplication +internal class PyDocumentationSettingsSerializationTest { + @Test + fun `legacy state with removed docstring format deserializes to default instead of failing`() { + // Component as written by an older build: the removed EPYTEXT enum constant leaked into the "format" option + // (from the getFormat()/setFormat() accessor pair). Reading it used to call setFormat(null) and throw. + val element = JDOMUtil.load( + """ + + + """.trimIndent() + ) + + val state = XmlSerializer.deserialize(element, ServiceState::class.java) + + // The removed value falls back to PLAIN via setFormatName(); the redundant "format" option is ignored. + assertEquals(DocStringFormat.PLAIN, state.format) + assertFalse(state.myAnalyzeDoctest) + assertTrue(state.myRenderExternalDocumentation) + } + + @Test + fun `format is no longer serialized redundantly`() { + val state = ServiceState(DocStringFormat.NUMPY) + + val serialized = JDOMUtil.write(XmlSerializer.serialize(state)) + + // Only the legacy "myDocStringFormat" option persists the format; the fragile "format" option is gone. + assertTrue(serialized.contains("myDocStringFormat"), serialized) + assertFalse(serialized.contains("name=\"format\""), serialized) + } +}