diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt index 1fb0ebe0dd..03e42340e7 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt @@ -424,7 +424,7 @@ class PluginManager private constructor( loadPlugin(pluginFile) } catch (e: CancellationException) { throw e - } catch (e: Exception) { + } catch (e: Throwable) { Result.failure(e) } result.onFailure { error -> recordLoadFailure(pluginFile, error) } @@ -652,9 +652,7 @@ class PluginManager private constructor( logger.debug("Initializing plugin: ${manifest.id}") val initResult = - try { - plugin.initialize(pluginContext) - } catch (e: Exception) { + initializePlugin(plugin, pluginContext).getOrElse { e -> logger.error("Plugin initialization threw exception for: ${manifest.id}", e) if (manifest.sidebarItems > 0) { SidebarSlotManager.releasePluginSlots(manifest.id) @@ -697,7 +695,7 @@ class PluginManager private constructor( activateLoadedPlugin(loadedPlugin) Result.success(plugin) - } catch (e: Exception) { + } catch (e: Throwable) { reservedSlotsPluginId?.let { pluginId -> SidebarSlotManager.releasePluginSlots(pluginId) } @@ -783,7 +781,7 @@ class PluginManager private constructor( } else { logger.warn("Failed to remove documentation for plugin: $pluginId") } - } catch (e: Exception) { + } catch (e: Throwable) { logger.error("Error removing documentation for plugin: $pluginId", e) } @@ -1994,3 +1992,13 @@ fun LoadedPlugin.toPluginMetadata(): PluginMetadata = iconDayPath = iconDayPath, iconNightPath = iconNightPath, ) + +/** + * Runs [IPlugin.initialize], catching Errors too: a plugin built against a newer plugin API throws + * [LinkageError] (e.g. NoClassDefFoundError) on an older CoGo, and that must disable the plugin, not + * crash the app. + */ +internal fun initializePlugin( + plugin: IPlugin, + context: PluginContext, +): Result = runCatching { plugin.initialize(context) } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/documentation/PluginDocumentationManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/documentation/PluginDocumentationManager.kt index 4b42ae1a39..1d6a402ee3 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/documentation/PluginDocumentationManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/documentation/PluginDocumentationManager.kt @@ -136,7 +136,7 @@ class PluginDocumentationManager( withContext(Dispatchers.IO) { try { plugin?.onDocumentationUninstall() - } catch (e: Exception) { + } catch (e: Throwable) { if (e is CancellationException) throw e Log.e(TAG, "Plugin onDocumentationUninstall() threw during removal: $pluginId", e) } @@ -675,7 +675,7 @@ class PluginDocumentationManager( recreatedCount++ } } - } catch (e: Exception) { + } catch (e: Throwable) { if (e is CancellationException) throw e Log.e(TAG, "Failed to verify/recreate documentation for $pluginId", e) } diff --git a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/core/PluginLinkageErrorTest.kt b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/core/PluginLinkageErrorTest.kt new file mode 100644 index 0000000000..5b3ab26de6 --- /dev/null +++ b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/core/PluginLinkageErrorTest.kt @@ -0,0 +1,64 @@ +package com.itsaky.androidide.plugins.manager.core + +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.extensions.DocumentationExtension +import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry +import com.itsaky.androidide.plugins.manager.documentation.PluginDocumentationManager +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.lang.reflect.Proxy + +/** + * A plugin built against a newer plugin API, loaded by an older CoGo, throws NoClassDefFoundError + * when it first touches a missing class (GlitchTip CODEONTHEGO-10B, ADFA-6672). It must fail that + * plugin, not crash the app. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28]) +class PluginLinkageErrorTest { + @Test + fun initializeThrowingNoClassDefFoundErrorFailsThePlugin() { + val result = initializePlugin(NewerApiPlugin(), stubContext()) + + assertTrue(result.exceptionOrNull() is NoClassDefFoundError) + } + + @Test + fun documentationUninstallThrowingNoClassDefFoundErrorDoesNotEscape() { + val manager = PluginDocumentationManager(RuntimeEnvironment.getApplication()) + + val removed = runBlocking { manager.removePluginDocumentation("newer-api", NewerApiPlugin()) } + + assertFalse(removed) + } + + private fun stubContext(): PluginContext = + Proxy.newProxyInstance(javaClass.classLoader, arrayOf(PluginContext::class.java)) { _, _, _ -> + null + } as PluginContext + + private class NewerApiPlugin : DocumentationExtension { + private fun missingClass(): Nothing = + throw NoClassDefFoundError("Failed resolution of: Lcom/itsaky/androidide/plugins/ai/LlmBackendRegistration;") + + override fun initialize(context: PluginContext): Boolean = missingClass() + + override fun onDocumentationUninstall() = missingClass() + + override fun activate() = true + + override fun deactivate() = true + + override fun dispose() {} + + override fun getTooltipCategory() = "newer-api" + + override fun getTooltipEntries() = emptyList() + } +}