PY-91090 Do not throw on corrupt/outdated persistent state value

(cherry picked from commit 34e5cbeb5a938dc7d4f44645e2f99b1540a6c638)

IJ-CR-216573

GitOrigin-RevId: 019909a092445fc39b07152e3e1a24505528d9c5
This commit is contained in:
Ilya Korennoy
2026-08-04 18:40:24 +00:00
committed by intellij-monorepo-bot
parent 775bbdc3dc
commit 6fddbd5e13
4 changed files with 111 additions and 15 deletions
@@ -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<String>()
override fun <T> getComponentValue(module: Module, componentName: String, componentClass: Class<T>): 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 <T> setComponentValue(module: Module, componentName: String, component: T) {
@@ -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<CustomImlComponentService>()
runBlocking {
componentService.setComponentValue(module, SecondTestModuleService.COMPONENT_NAME, CorruptTestState("not-a-number"))
}
assertNull(componentService.getComponentValue<SecondTestModuleService.State>(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 = "",
@@ -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;
}
}
}
@@ -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(
"""
<component name="PyDocumentationSettings">
<option name="analyzeDoctest" value="false" />
<option name="renderExternalDocumentation" value="true" />
<option name="myDocStringFormat" value="Epytext" />
<option name="format" value="EPYTEXT" />
</component>
""".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)
}
}