diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt index a8e3ab38..6233d83c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt @@ -125,6 +125,7 @@ open class ZiplinePluginService( } private val lifecycleMutex = Mutex() private var ziplineLoader: ZiplineLoader + private var ziplineInstance: Zipline? = null private val serviceRegistry = mutableMapOf, ZiplineService>() init { @@ -271,6 +272,7 @@ open class ZiplinePluginService( return } + logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" } withContext(ziplineDispatcher.dispatcher) { trace("start(): inside zipline dispatcher before loadOnce") val result = ziplineLoader.loadOnce( @@ -280,6 +282,8 @@ open class ZiplinePluginService( ) when (result) { is LoadResult.Success -> { + logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" } + ziplineInstance = result.zipline // Now we consume the initializer val initializer = result.zipline.take(Initializer_SERVICE_NAME) // Bind host services before initialization, so plugins can use them in their initializer @@ -310,10 +314,19 @@ open class ZiplinePluginService( override suspend fun stop() { lifecycleMutex.withLock { trace("stop(): entered") - for (service in serviceRegistry.values) { - trace("stop(): closing service ${service::class.simpleName}") - service.close() + withContext(ziplineDispatcher.dispatcher) { + ziplineInstance?.close() + ziplineInstance = null + for (service in serviceRegistry.values) { + try { + trace("stop(): closing service ${service::class.simpleName}") + service.close() + } catch (_: Exception) { + trace("stop(): error closing service ${service::class.simpleName}") + } + } } + serviceRegistry.clear() scope.cancel() loggedInStateFlow.value = false ziplineDispatcher.close() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt index 767959d5..78cb8881 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginManager.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.IO import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation @@ -147,9 +148,13 @@ class PluginManager( @OptIn(ExperimentalCoroutinesApi::class) val ziplineServices = state .filterIsInstance() - .map { it.selectedPlugins } + .map { it.selectedPlugins to it.generation } + // Paired with generation so same-version replaces (structurally equal + // PluginEntry) still trigger a restart. Without it the DataStore skips + // the emission (identical JSON) and the flow stays alive with stale code. .distinctUntilChanged() - .flatMapLatest { plugins -> + .flatMapLatest { (plugins, gen) -> + logger.d { "ziplineServices: flow restarting with generation=$gen, selectedPlugins=${plugins.keys}" } flow { val ziplineServices: MutableMap = mutableMapOf() @@ -166,6 +171,7 @@ class PluginManager( ziplineServicesByPluginID[plugin.id] = service service.start() } else { + logger.d { "ziplineServices: creating new ZiplinePluginService for ${plugin.name} (${plugin.id})" } val service = ZiplinePluginService( applicationName = plugin.name, manifestUrl = "http://localhost?path=${(pluginsDir / plugin.id.toPath() / "manifest.zipline.json")}", @@ -180,12 +186,16 @@ class PluginManager( // Keep the flow alive until the next plugin is selected. awaitCancellation() } finally { - // Stop services that are no longer selected - val selectedPluginIDs = plugins.values.map { it.id }.toSet() - ziplineServicesByPluginID.forEach { (pluginID, service) -> - if (!selectedPluginIDs.contains(pluginID)) { - service.stop() + withContext(NonCancellable) { + logger.d { "ziplineServices: finally block stopping ${ziplineServicesByPluginID.size} services" } + ziplineServicesByPluginID.forEach { (_, service) -> + try { + service.stop() + } catch (_: Exception) { + logger.e { "ziplineServices: error stopping service" } + } } + logger.d { "ziplineServices: all services stopped" } } } } @@ -495,7 +505,12 @@ class PluginManager( currentState.selectedPlugins.mapValues { (_, selectedPlugin) -> if (selectedPlugin.id == plugin.id) plugin else selectedPlugin } - val newState = PluginManagerStates.Data(updatedPlugins, updatedSelectedPlugins) + val newState = PluginManagerStates.Data( + plugins = updatedPlugins, + selectedPlugins = updatedSelectedPlugins, + generation = currentState.generation + 1 + ) + logger.d { "addPlugin: bumped generation to ${newState.generation} for plugin ${plugin.id}" } updatePluginsState(newState) } } @@ -510,7 +525,11 @@ class PluginManager( val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id } val updatedSelectedPlugins = currentState.selectedPlugins.filterValues { it.id != plugin.id } - val newState = PluginManagerStates.Data(updatedPlugins, updatedSelectedPlugins) + val newState = PluginManagerStates.Data( + plugins = updatedPlugins, + selectedPlugins = updatedSelectedPlugins, + generation = currentState.generation + ) withContext(Dispatchers.IO) { val pluginDir = pluginsDir / plugin.id.toPath() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt index e9d4cd33..5e2d1fdf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginModels.kt @@ -55,7 +55,12 @@ sealed class PluginManagerStates { @Serializable data class Data( val plugins: List, - val selectedPlugins: Map = emptyMap() + val selectedPlugins: Map = emptyMap(), + // Bumped on every addPlugin so the DataStore always detects a structural + // change — without it, same-version replaces produce identical JSON and + // the DataStore's internal distinctUntilChanged blocks the state emission, + // leaving the ziplineServices flow stuck with the old code. + val generation: Long = 0L ) : PluginManagerStates() data object Loading : PluginManagerStates()