From 9337ebb1788fcf465b0cefadb63a87ac72ea2e21 Mon Sep 17 00:00:00 2001 From: S-furi Date: Thu, 29 Jan 2026 10:54:47 +0100 Subject: [PATCH 1/6] fix: properly implement and call `dispose` on nodes and reactions --- .../kotlin/it/unibo/alchemist/model/Actionable.kt | 4 +++- .../src/main/kotlin/it/unibo/alchemist/model/Node.kt | 4 +++- .../model/environments/AbstractEnvironment.kt | 2 ++ .../it/unibo/alchemist/model/nodes/GenericNode.kt | 12 +++++++++++- .../model/sapere/reactions/SAPEREGradient.java | 7 +++++++ .../model/physics/reactions/PhysicsUpdate.kt | 8 ++++++++ .../it/unibo/alchemist/test/GlobalTestReaction.kt | 7 +++++++ 7 files changed, 41 insertions(+), 3 deletions(-) diff --git a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Actionable.kt b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Actionable.kt index da9027f8cd..20e0fa418a 100644 --- a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Actionable.kt +++ b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Actionable.kt @@ -9,6 +9,7 @@ package it.unibo.alchemist.model +import it.unibo.alchemist.model.observation.Disposable import it.unibo.alchemist.model.observation.Observable import java.io.Serializable import org.danilopianini.util.ListSet @@ -18,7 +19,8 @@ import org.danilopianini.util.ListSet */ sealed interface Actionable : Comparable>, - Serializable { + Serializable, + Disposable { /** * @return true if the reaction can be executed (namely, all the conditions * are satisfied). diff --git a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Node.kt b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Node.kt index 2acc820121..ddd11b27c7 100644 --- a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Node.kt +++ b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Node.kt @@ -9,6 +9,7 @@ package it.unibo.alchemist.model import arrow.core.Option +import it.unibo.alchemist.model.observation.Disposable import it.unibo.alchemist.model.observation.Observable import it.unibo.alchemist.model.observation.ObservableMap import java.io.Serializable @@ -25,7 +26,8 @@ import kotlin.reflect.jvm.jvmErasure interface Node : Serializable, Iterable>, - Comparable> { + Comparable>, + Disposable { /** * Adds a reaction to this node. * The reaction is added only in the node, diff --git a/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/environments/AbstractEnvironment.kt b/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/environments/AbstractEnvironment.kt index 315d3e47c2..73f4fc09e5 100644 --- a/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/environments/AbstractEnvironment.kt +++ b/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/environments/AbstractEnvironment.kt @@ -27,6 +27,7 @@ import it.unibo.alchemist.model.Position import it.unibo.alchemist.model.SupportedIncarnations import it.unibo.alchemist.model.TerminationPredicate import it.unibo.alchemist.model.linkingrules.NoLinks +import it.unibo.alchemist.model.observation.Disposable import it.unibo.alchemist.model.observation.Observable import it.unibo.alchemist.model.observation.ObservableMutableMap import it.unibo.alchemist.model.observation.ObservableMutableSet @@ -444,6 +445,7 @@ abstract class AbstractEnvironment> protected constructor( updateRegionObservers(node, null, null) ifEngineAvailable { it.nodeRemoved(node, neigh) } nodeRemoved(node, neigh) + node.dispose() } private fun runQuery(center: P, range: Double): List> = spatialIndex diff --git a/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/nodes/GenericNode.kt b/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/nodes/GenericNode.kt index ad66473c35..0a387a8698 100644 --- a/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/nodes/GenericNode.kt +++ b/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/nodes/GenericNode.kt @@ -18,6 +18,7 @@ import it.unibo.alchemist.model.Node import it.unibo.alchemist.model.NodeProperty import it.unibo.alchemist.model.Reaction import it.unibo.alchemist.model.Time +import it.unibo.alchemist.model.observation.Disposable import it.unibo.alchemist.model.observation.Observable import it.unibo.alchemist.model.observation.ObservableMutableMap import java.util.Collections @@ -111,7 +112,9 @@ constructor( } final override fun removeReaction(reactionToRemove: Reaction) { - reactions.remove(reactionToRemove) + if (reactions.remove(reactionToRemove)) { + reactionToRemove.dispose() + } } override fun setConcentration(molecule: Molecule, concentration: T) { @@ -136,6 +139,13 @@ constructor( override fun toString(): String = "Node$id{ properties: $properties, molecules: ${observableContents.current}}" + override fun dispose() { + reactions.forEach(Disposable::dispose) + reactions.clear() + observableContents.dispose() + observeMoleculeCount.dispose() + } + private companion object { private const val serialVersionUID = 2496775909028222278L diff --git a/alchemist-incarnation-sapere/src/main/java/it/unibo/alchemist/model/sapere/reactions/SAPEREGradient.java b/alchemist-incarnation-sapere/src/main/java/it/unibo/alchemist/model/sapere/reactions/SAPEREGradient.java index 53c3b6f827..b08e957154 100644 --- a/alchemist-incarnation-sapere/src/main/java/it/unibo/alchemist/model/sapere/reactions/SAPEREGradient.java +++ b/alchemist-incarnation-sapere/src/main/java/it/unibo/alchemist/model/sapere/reactions/SAPEREGradient.java @@ -27,6 +27,7 @@ import it.unibo.alchemist.model.Time; import it.unibo.alchemist.model.TimeDistribution; import it.unibo.alchemist.model.maps.MapEnvironment; +import it.unibo.alchemist.model.observation.Disposable; import it.unibo.alchemist.model.observation.MutableObservable; import it.unibo.alchemist.model.observation.Observable; import it.unibo.alchemist.model.observation.ObservableMutableSet; @@ -366,6 +367,12 @@ public double getRate() { return canRun ? getTimeDistribution().getRate() : 0; } + @Override + public void dispose() { + fakeconds.forEach(Disposable::dispose); + super.dispose(); + } + @Override protected void updateInternalStatus( final Time currentTime, diff --git a/alchemist-physics/src/main/kotlin/it/unibo/alchemist/model/physics/reactions/PhysicsUpdate.kt b/alchemist-physics/src/main/kotlin/it/unibo/alchemist/model/physics/reactions/PhysicsUpdate.kt index e36af69146..cd8acbc30b 100644 --- a/alchemist-physics/src/main/kotlin/it/unibo/alchemist/model/physics/reactions/PhysicsUpdate.kt +++ b/alchemist-physics/src/main/kotlin/it/unibo/alchemist/model/physics/reactions/PhysicsUpdate.kt @@ -110,4 +110,12 @@ class PhysicsUpdate( override fun update(currentTime: Time, hasBeenExecuted: Boolean, environment: Environment) = Unit override fun initializationComplete(atTime: Time, environment: Environment) = Unit + + override fun dispose() { + subscriptions.forEach { it.stopWatching(this) } + subscriptions.clear() + validity.dispose() + conditions.forEach(Disposable::dispose) + rescheduleRequest.dispose() + } } diff --git a/alchemist-test/src/main/kotlin/it/unibo/alchemist/test/GlobalTestReaction.kt b/alchemist-test/src/main/kotlin/it/unibo/alchemist/test/GlobalTestReaction.kt index 1008afd22c..0b258bc419 100644 --- a/alchemist-test/src/main/kotlin/it/unibo/alchemist/test/GlobalTestReaction.kt +++ b/alchemist-test/src/main/kotlin/it/unibo/alchemist/test/GlobalTestReaction.kt @@ -57,4 +57,11 @@ class GlobalTestReaction(override val timeDistribution: TimeDistribution, override fun update(currentTime: Time, hasBeenExecuted: Boolean, environment: Environment) = Unit override fun initializationComplete(atTime: Time, environment: Environment) = Unit + + override fun dispose() { + observableConditions.dispose() + validity.dispose() + conditions.forEach(Condition::dispose) + rescheduleRequest.dispose() + } } From 0b4d87da946d118f95451ec3fef46781b122ca73 Mon Sep 17 00:00:00 2001 From: S-furi Date: Thu, 29 Jan 2026 11:02:36 +0100 Subject: [PATCH 2/6] chore: try avoid memory leaks for region observers --- .../model/environments/AbstractEnvironment.kt | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/environments/AbstractEnvironment.kt b/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/environments/AbstractEnvironment.kt index 73f4fc09e5..5a33fc60ca 100644 --- a/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/environments/AbstractEnvironment.kt +++ b/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/environments/AbstractEnvironment.kt @@ -250,7 +250,14 @@ abstract class AbstractEnvironment> protected constructor( .put(range, region) } - return initialNodes + return AutoDisposableObservableSet(initialNodes) { + regionObservers.remove(region) + if (node != null) { + regionNodeCenteredIndex[node.id]?.remove(range) + } else { + regionPositionCenteredIndex[actualCenter]?.remove(range) + } + } } override fun getDistanceBetweenNodes(n1: Node, n2: Node): Double = @@ -576,6 +583,28 @@ abstract class AbstractEnvironment> protected constructor( val visibleNodes: ObservableMutableSet>, ) + /** + * Simple [ObservableSet] that should prevent basic memory leaks. Simply calls [onDispose] when + * no observers observe this structure. + */ + private class AutoDisposableObservableSet( + private val delegate: ObservableSet, + private val onDispose: () -> Unit, + ) : ObservableSet by delegate { + + override fun stopWatching(registrant: Any) { + delegate.stopWatching(registrant) + if (delegate.observers.isEmpty()) { + onDispose() + } + } + + override fun dispose() { + delegate.dispose() + onDispose() + } + } + private data class Operation(val origin: Node, val destination: Node, val isAdd: Boolean) { override fun toString(): String = origin.toString() + (if (isAdd) " discovered " else " lost ") + destination } From d6194d74115200346b920d6884e1a1d1ed847689 Mon Sep 17 00:00:00 2001 From: S-furi Date: Thu, 29 Jan 2026 16:41:02 +0100 Subject: [PATCH 3/6] feat(api): improve lapsed listener problem avoidance with `Lifecycle` This idea is borrowed by Androidx, where by means of Lifecycle state machines we are able to bound the lifecycle of the dependencies to the lifecycle of the owner (i.e. the registrant), properly disposing and releasing observers references withtout using weak references, hence not impacting too much negatively performance. --- .../it/unibo/alchemist/model/Actionable.kt | 4 +- .../kotlin/it/unibo/alchemist/model/Node.kt | 4 +- .../alchemist/model/observation/Lifecycle.kt | 146 ++++++++++++++++++ .../model/observation/LifecycleTest.kt | 120 ++++++++++++++ .../kotlin/it/unibo/alchemist/core/Engine.kt | 4 +- .../model/reactions/AbstractReaction.java | 40 ++--- .../model/environments/AbstractEnvironment.kt | 79 +++++++--- .../alchemist/model/nodes/GenericNode.kt | 9 ++ .../model/protelis/ProtelisIncarnation.kt | 5 + .../model/physics/reactions/PhysicsUpdate.kt | 26 ++-- .../alchemist/test/GlobalTestReaction.kt | 9 +- 11 files changed, 387 insertions(+), 59 deletions(-) create mode 100644 alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/Lifecycle.kt create mode 100644 alchemist-api/src/test/kotlin/it/unibo/alchemist/model/observation/LifecycleTest.kt diff --git a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Actionable.kt b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Actionable.kt index 20e0fa418a..40c2df534d 100644 --- a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Actionable.kt +++ b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Actionable.kt @@ -10,6 +10,7 @@ package it.unibo.alchemist.model import it.unibo.alchemist.model.observation.Disposable +import it.unibo.alchemist.model.observation.LifecycleOwner import it.unibo.alchemist.model.observation.Observable import java.io.Serializable import org.danilopianini.util.ListSet @@ -20,7 +21,8 @@ import org.danilopianini.util.ListSet sealed interface Actionable : Comparable>, Serializable, - Disposable { + Disposable, + LifecycleOwner { /** * @return true if the reaction can be executed (namely, all the conditions * are satisfied). diff --git a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Node.kt b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Node.kt index ddd11b27c7..a1a0874a83 100644 --- a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Node.kt +++ b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Node.kt @@ -10,6 +10,7 @@ package it.unibo.alchemist.model import arrow.core.Option import it.unibo.alchemist.model.observation.Disposable +import it.unibo.alchemist.model.observation.LifecycleOwner import it.unibo.alchemist.model.observation.Observable import it.unibo.alchemist.model.observation.ObservableMap import java.io.Serializable @@ -27,7 +28,8 @@ interface Node : Serializable, Iterable>, Comparable>, - Disposable { + Disposable, + LifecycleOwner { /** * Adds a reaction to this node. * The reaction is added only in the node, diff --git a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/Lifecycle.kt b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/Lifecycle.kt new file mode 100644 index 0000000000..f396f867bd --- /dev/null +++ b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/Lifecycle.kt @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.model.observation + +import it.unibo.alchemist.model.observation.LifecycleState.DESTROYED +import it.unibo.alchemist.model.observation.LifecycleState.STARTED + +/** + * Represents the lifecycle state of a component. + */ +enum class LifecycleState { + + /** + * Destroyed state for a LifecycleOwner. After this state is reached, this Lifecycle will not emit any more events. + */ + DESTROYED, + + /** + * Initialized state for a LifecycleOwner. + */ + INITIALIZED, + + /** + * Started state for a LifecycleOwner. + */ + STARTED, + + ; + + /** + * Checks if the current state is at least the given [state]. + */ + fun isAtLeast(state: LifecycleState): Boolean = this >= state +} + +/** + * An object which has a [Lifecycle]. + */ +interface LifecycleOwner { + + /** + * The lifecycle of the provider. + */ + val lifecycle: Lifecycle +} + +/** + * Manages the state and listeners. + */ +interface Lifecycle { + + /** + * Returns the current state of the Lifecycle. + */ + val currentState: LifecycleState + + /** + * Adds a LifecycleObserver that will be notified when the LifecycleOwner changes state. + */ + fun addObserver(observer: (LifecycleState) -> Unit) + + /** + * Removes the given observer from the observers list. + */ + fun removeObserver(observer: (LifecycleState) -> Unit) +} + +/** + * A concrete implementation of [Lifecycle] that handles state transitions and observer notification. + */ +class LifecycleRegistry : Lifecycle { + + private val observers = mutableListOf<(LifecycleState) -> Unit>() + + override var currentState: LifecycleState = LifecycleState.INITIALIZED + private set + + /** + * Transitions the lifecycle to a new [state] and notifies all observers. + */ + fun markState(state: LifecycleState) { + currentState = state + observers.toList().forEach { it(state) } + } + + override fun addObserver(observer: (LifecycleState) -> Unit) { + observers.add(observer) + } + + override fun removeObserver(observer: (LifecycleState) -> Unit) { + observers.remove(observer) + } +} + +/** + * Observes this [Observable] within the context of a [LifecycleOwner]. This + * method is a memory-safe alternative to [Observable.onChange] in scenarios + * where it is crucial to avoid that the owner subscription leaks. When the + * registrant's state reaches [LifecycleState.DESTROYED], the subscription + * is automatically removed and cleared up. Moreover, the [callback] is only + * invoked if the lifecycle is in an active state ([STARTED]). Finally, + * when the lifecycle moves from an inactive state back to active, the + * [callback] is triggered immediately with the [Observable.current] value. + * + * @param lifecycleOwner The object controlling the lifecycle of this subscription. + * @param callback The action to perform when the observable emits a value. + */ +fun Observable.bindTo(lifecycleOwner: LifecycleOwner, callback: (T) -> Unit) { + if (lifecycleOwner.lifecycle.currentState == DESTROYED) return + + val dataListener: (T) -> Unit = { data -> + // avoid zombie callbacks + if (lifecycleOwner.lifecycle.currentState.isAtLeast(STARTED)) { + callback(data) + } + } + + var lifecycleObserver: ((LifecycleState) -> Unit)? = null + lifecycleObserver = { state -> + when (state) { + DESTROYED -> { + this.stopWatching(lifecycleOwner) + lifecycleObserver?.let { lifecycleOwner.lifecycle.removeObserver(it) } + } + STARTED -> callback(this.current) + else -> { /* No action needed for other states */ } + } + } + + lifecycleOwner.lifecycle.addObserver(lifecycleObserver) + + // set `invokeOnRegistration = false` because we handle the initial data emission manually below + // to strictly respect the `isAtLeast(STARTED)` check. + this.onChange(lifecycleOwner, invokeOnRegistration = false, callback = dataListener) + + if (lifecycleOwner.lifecycle.currentState.isAtLeast(STARTED)) { + callback(this.current) + } +} diff --git a/alchemist-api/src/test/kotlin/it/unibo/alchemist/model/observation/LifecycleTest.kt b/alchemist-api/src/test/kotlin/it/unibo/alchemist/model/observation/LifecycleTest.kt new file mode 100644 index 0000000000..0f331048ad --- /dev/null +++ b/alchemist-api/src/test/kotlin/it/unibo/alchemist/model/observation/LifecycleTest.kt @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.model.observation + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.shouldBe +import it.unibo.alchemist.model.observation.LifecycleState.DESTROYED +import it.unibo.alchemist.model.observation.LifecycleState.STARTED +import it.unibo.alchemist.model.observation.MutableObservable.Companion.observe + +class LifecycleTest : FunSpec({ + + class TestLifecycleOwner : LifecycleOwner { + private val registry = LifecycleRegistry() + override val lifecycle: Lifecycle get() = registry + + fun start() = registry.markState(STARTED) + fun destroy() = registry.markState(DESTROYED) + } + + context("Lifecycle binding tests") { + + test("bindTo should not invoke callback if Lifecycle is INITIALIZED") { + val owner = TestLifecycleOwner() + val observable = observe(10) + var lastValue = -1 + + observable.bindTo(owner) { lastValue = it } + + lastValue shouldBe -1 + observable.current = 20 + lastValue shouldBe -1 + } + + test("bindTo should invoke callback immediately if Lifecycle is STARTED") { + val owner = TestLifecycleOwner() + owner.start() + val observable = observe(10) + var lastValue = -1 + + observable.bindTo(owner) { lastValue = it } + + lastValue shouldBe 10 + } + + test("bindTo should start receiving updates when Lifecycle moves to STARTED") { + val owner = TestLifecycleOwner() + val observable = observe(10) + var lastValue = -1 + + observable.bindTo(owner) { lastValue = it } + + observable.current = 20 + lastValue shouldBe -1 + + owner.start() + lastValue shouldBe 20 + + observable.current = 30 + lastValue shouldBe 30 + } + + test("bindTo should automatically unsubscribe when Lifecycle is DESTROYED") { + val owner = TestLifecycleOwner() + owner.start() + val observable = observe(10) + var lastValue = -1 + + observable.bindTo(owner) { lastValue = it } + + observable.observers shouldContain owner + + owner.destroy() + + observable.observers shouldNotContain owner + + observable.current = 40 + lastValue shouldBe 10 + } + + test("bindTo should not subscribe at all if Lifecycle is already DESTROYED") { + val owner = TestLifecycleOwner() + owner.destroy() + val observable = observe(10) + + observable.bindTo(owner) { } + + observable.observers shouldNotContain owner + } + + test("Multiple bindings to the same owner should work independently") { + val owner = TestLifecycleOwner() + owner.start() + val obs1 = observe(1) + val obs2 = observe(2) + var val1 = -1 + var val2 = -1 + + obs1.bindTo(owner) { val1 = it } + obs2.bindTo(owner) { val2 = it } + + val1 shouldBe 1 + val2 shouldBe 2 + + owner.destroy() + + obs1.observers shouldNotContain owner + obs2.observers shouldNotContain owner + } + } +}) diff --git a/alchemist-engine/src/main/kotlin/it/unibo/alchemist/core/Engine.kt b/alchemist-engine/src/main/kotlin/it/unibo/alchemist/core/Engine.kt index d26f873d42..fc280bf754 100644 --- a/alchemist-engine/src/main/kotlin/it/unibo/alchemist/core/Engine.kt +++ b/alchemist-engine/src/main/kotlin/it/unibo/alchemist/core/Engine.kt @@ -79,8 +79,10 @@ open class Engine>( } override fun nodeRemoved(node: Node, oldNeighborhood: Neighborhood) { + // copy of reactions due to how [GenericNode.dispose] clears the reactions + val reactions = ArrayList(node.reactions) schedule { - node.reactions.forEach { removeReaction(it) } + reactions.forEach { removeReaction(it) } } } diff --git a/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/reactions/AbstractReaction.java b/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/reactions/AbstractReaction.java index e7f4eca92d..332448220c 100644 --- a/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/reactions/AbstractReaction.java +++ b/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/reactions/AbstractReaction.java @@ -24,6 +24,11 @@ import it.unibo.alchemist.model.TimeDistribution; import it.unibo.alchemist.model.observation.Disposable; import it.unibo.alchemist.model.observation.EventObservable; +import it.unibo.alchemist.model.observation.Lifecycle; +import it.unibo.alchemist.model.observation.LifecycleKt; +import it.unibo.alchemist.model.observation.LifecycleOwner; +import it.unibo.alchemist.model.observation.LifecycleRegistry; +import it.unibo.alchemist.model.observation.LifecycleState; import it.unibo.alchemist.model.observation.MutableObservable; import it.unibo.alchemist.model.observation.Observable; import it.unibo.alchemist.model.observation.ObservableExtensions; @@ -56,7 +61,7 @@ * * @param concentration type */ -public abstract class AbstractReaction implements Reaction, Disposable { +public abstract class AbstractReaction implements Reaction, Disposable, LifecycleOwner { /** * How bigger should be the StringBuffer with respect to the previous @@ -78,13 +83,13 @@ public abstract class AbstractReaction implements Reaction, Disposable { private final TimeDistribution timeDistribution; private final Node node; + private final LifecycleRegistry lifecycleRegistry = new LifecycleRegistry(); private final EventObservable rescheduleRequest = new EventObservable(); private Observable validity = MutableObservable.Companion.observe(true); - private final List> subscriptions = new ArrayList<>(); private Option canExecute = none(); /** - * Builds a new reaction, starting at time t. + * Builds a new reaction, starting at time t. * * @param node the node this reaction belongs to * @param timeDistribution the time distribution this reaction should follow @@ -143,6 +148,13 @@ public final boolean equals(final Object o) { return this == o; } + @NotNull + @Override + @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "This is intentional") + public final Lifecycle getLifecycle() { + return lifecycleRegistry; + } + /** * The default execution iterates all the actions in order and executes them. Override to change the behavior. */ @@ -244,6 +256,7 @@ public final int hashCode() { @Override public final void initializationComplete(@Nonnull final Time atTime, @Nonnull final Environment environment) { initializeObservableConditions(); + lifecycleRegistry.markState(LifecycleState.STARTED); onInitializationComplete(atTime, environment); } @@ -344,22 +357,16 @@ public void setConditions(@Nonnull final List> conditions * trigger some environment query that may fail in the set-up phase. */ protected final void initializeObservableConditions() { - if (!subscriptions.isEmpty()) { - subscriptions.forEach(s -> s.stopWatching(this)); - subscriptions.clear(); - } - validity.dispose(); conditions.forEach(condition -> { final var merged = ObservableExtensions.ObservableSetExtensions.mergeObservables( condition.observeInboundDependencies() ); - merged.onChange(this, it -> { + LifecycleKt.bindTo(merged, this, it -> { rescheduleRequest.emit(); - return null; + return Unit.INSTANCE; }); - subscriptions.add(merged); }); if (!conditions.isEmpty()) { @@ -368,15 +375,11 @@ protected final void initializeObservableConditions() { it -> it.stream().allMatch(b -> b) ).map(it -> getOrElse(it, () -> true)); - // need at least one observer to track validity updates - validity.onChange(this, it -> { + LifecycleKt.bindTo(validity, this, it -> { canExecute = Option.fromNullable(it); - return null; + return Unit.INSTANCE; }); - - subscriptions.add(validity); } - rescheduleRequest.emit(); } @@ -437,10 +440,9 @@ public final void update( */ @Override public void dispose() { - subscriptions.forEach(it -> it.stopWatching(this)); + lifecycleRegistry.markState(LifecycleState.DESTROYED); conditions.forEach(Disposable::dispose); conditions.clear(); - subscriptions.clear(); } /** diff --git a/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/environments/AbstractEnvironment.kt b/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/environments/AbstractEnvironment.kt index 5a33fc60ca..b4344bc2c1 100644 --- a/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/environments/AbstractEnvironment.kt +++ b/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/environments/AbstractEnvironment.kt @@ -27,7 +27,6 @@ import it.unibo.alchemist.model.Position import it.unibo.alchemist.model.SupportedIncarnations import it.unibo.alchemist.model.TerminationPredicate import it.unibo.alchemist.model.linkingrules.NoLinks -import it.unibo.alchemist.model.observation.Disposable import it.unibo.alchemist.model.observation.Observable import it.unibo.alchemist.model.observation.ObservableMutableMap import it.unibo.alchemist.model.observation.ObservableMutableSet @@ -235,29 +234,50 @@ abstract class AbstractEnvironment> protected constructor( centerProvider = centerProvider, radius = range, visibleNodes = initialNodes, - ).apply { regionObservers.add(this) } - - if (node != null) { - var radiusMap = regionNodeCenteredIndex[node.id] - if (radiusMap == null) { - radiusMap = TDoubleObjectHashMap() - regionNodeCenteredIndex.put(node.id, radiusMap) - } - radiusMap.put(range, region) - } else { - regionPositionCenteredIndex - .computeIfAbsent(actualCenter) { TDoubleObjectHashMap() } - .put(range, region) + ) + + val addRegion = { + runCatching { + val currentCenter = centerProvider() + val currentNodes = getAllNodesInRange(currentCenter, range) + initialNodes.clearAndAddAll(currentNodes.toSet()) + + regionObservers.add(region) + if (node != null) { + var radiusMap = regionNodeCenteredIndex[node.id] + if (radiusMap == null) { + radiusMap = TDoubleObjectHashMap() + regionNodeCenteredIndex.put(node.id, radiusMap) + } + radiusMap.put(range, region) + } else { + regionPositionCenteredIndex + .computeIfAbsent(actualCenter) { TDoubleObjectHashMap() } + .put(range, region) + } + }.onFailure { initialNodes.clearAndAddAll(emptySet()) } } - return AutoDisposableObservableSet(initialNodes) { + val removeRegion = { regionObservers.remove(region) if (node != null) { regionNodeCenteredIndex[node.id]?.remove(range) + if (regionNodeCenteredIndex[node.id]?.isEmpty == true) { + regionNodeCenteredIndex.remove(node.id) + } } else { regionPositionCenteredIndex[actualCenter]?.remove(range) + if (regionPositionCenteredIndex[actualCenter]?.isEmpty == true) { + regionPositionCenteredIndex.remove(actualCenter) + } } } + + return RefCountObservableSet( + delegate = initialNodes, + onActive = { addRegion() }, + onInactive = { removeRegion() }, + ) } override fun getDistanceBetweenNodes(n1: Node, n2: Node): Double = @@ -584,24 +604,39 @@ abstract class AbstractEnvironment> protected constructor( ) /** - * Simple [ObservableSet] that should prevent basic memory leaks. Simply calls [onDispose] when - * no observers observe this structure. + * Simple wrapper for [ObservableSet] that manages reference counting to track observers and + * invoke specified callbacks when observers are registered or deregistered. + * It serves as both a way to avoid leaks through the [onInactive] callback (which should clear + * the backing caches), and a lazy evaluation of [onActive] when observers are registered. + * + * + * @param onActive the callback to be invoked when the first observer is added + * @param onInactive the callback to be invoked when the last observer is removed, which + * should clear backing caches and resources. */ - private class AutoDisposableObservableSet( - private val delegate: ObservableSet, - private val onDispose: () -> Unit, + private class RefCountObservableSet( + private val delegate: ObservableMutableSet, + private val onActive: () -> Unit, + private val onInactive: () -> Unit, ) : ObservableSet by delegate { + override fun onChange(registrant: Any, invokeOnRegistration: Boolean, callback: (Set) -> Unit) { + if (delegate.observers.isEmpty()) { + onActive() + } + delegate.onChange(registrant, invokeOnRegistration, callback) + } + override fun stopWatching(registrant: Any) { delegate.stopWatching(registrant) if (delegate.observers.isEmpty()) { - onDispose() + onInactive() } } override fun dispose() { delegate.dispose() - onDispose() + onInactive() } } diff --git a/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/nodes/GenericNode.kt b/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/nodes/GenericNode.kt index 0a387a8698..b0be62a93f 100644 --- a/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/nodes/GenericNode.kt +++ b/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/nodes/GenericNode.kt @@ -19,6 +19,8 @@ import it.unibo.alchemist.model.NodeProperty import it.unibo.alchemist.model.Reaction import it.unibo.alchemist.model.Time import it.unibo.alchemist.model.observation.Disposable +import it.unibo.alchemist.model.observation.LifecycleRegistry +import it.unibo.alchemist.model.observation.LifecycleState import it.unibo.alchemist.model.observation.Observable import it.unibo.alchemist.model.observation.ObservableMutableMap import java.util.Collections @@ -57,6 +59,12 @@ constructor( environment: Environment, ) : this(environment.incarnation, environment) + override val lifecycle: LifecycleRegistry = LifecycleRegistry() + + init { + lifecycle.markState(LifecycleState.STARTED) + } + override val observableContents: ObservableMutableMap = ObservableMutableMap(molecules) override val observeMoleculeCount: Observable = observableContents.map { it.size } @@ -140,6 +148,7 @@ constructor( override fun toString(): String = "Node$id{ properties: $properties, molecules: ${observableContents.current}}" override fun dispose() { + lifecycle.markState(LifecycleState.DESTROYED) reactions.forEach(Disposable::dispose) reactions.clear() observableContents.dispose() diff --git a/alchemist-incarnation-protelis/src/main/kotlin/it/unibo/alchemist/model/protelis/ProtelisIncarnation.kt b/alchemist-incarnation-protelis/src/main/kotlin/it/unibo/alchemist/model/protelis/ProtelisIncarnation.kt index f0866dc11a..e54cc86b57 100644 --- a/alchemist-incarnation-protelis/src/main/kotlin/it/unibo/alchemist/model/protelis/ProtelisIncarnation.kt +++ b/alchemist-incarnation-protelis/src/main/kotlin/it/unibo/alchemist/model/protelis/ProtelisIncarnation.kt @@ -29,6 +29,7 @@ import it.unibo.alchemist.model.Time import it.unibo.alchemist.model.TimeDistribution import it.unibo.alchemist.model.molecules.SimpleMolecule import it.unibo.alchemist.model.nodes.GenericNode +import it.unibo.alchemist.model.observation.LifecycleRegistry import it.unibo.alchemist.model.observation.Observable import it.unibo.alchemist.model.observation.ObservableMap import it.unibo.alchemist.model.protelis.actions.RunProtelisProgram @@ -378,6 +379,8 @@ class ProtelisIncarnation

> : Incarnation { override val reactions: List> = emptyList() + override val lifecycle: LifecycleRegistry = LifecycleRegistry() + override fun iterator(): MutableIterator> = notImplemented() override fun compareTo(@Nonnull other: Node): Int = notImplemented() @@ -400,6 +403,8 @@ class ProtelisIncarnation

> : Incarnation { override fun setConcentration(molecule: Molecule, concentration: Any) = notImplemented() + override fun dispose() = notImplemented() + override fun equals(other: Any?): Boolean = other === this override fun hashCode(): Int = -1 diff --git a/alchemist-physics/src/main/kotlin/it/unibo/alchemist/model/physics/reactions/PhysicsUpdate.kt b/alchemist-physics/src/main/kotlin/it/unibo/alchemist/model/physics/reactions/PhysicsUpdate.kt index cd8acbc30b..792e766267 100644 --- a/alchemist-physics/src/main/kotlin/it/unibo/alchemist/model/physics/reactions/PhysicsUpdate.kt +++ b/alchemist-physics/src/main/kotlin/it/unibo/alchemist/model/physics/reactions/PhysicsUpdate.kt @@ -20,14 +20,16 @@ import it.unibo.alchemist.model.Time import it.unibo.alchemist.model.TimeDistribution import it.unibo.alchemist.model.observation.Disposable import it.unibo.alchemist.model.observation.EventObservable +import it.unibo.alchemist.model.observation.LifecycleRegistry +import it.unibo.alchemist.model.observation.LifecycleState import it.unibo.alchemist.model.observation.MutableObservable.Companion.observe import it.unibo.alchemist.model.observation.Observable import it.unibo.alchemist.model.observation.ObservableExtensions.ObservableSetExtensions.merge import it.unibo.alchemist.model.observation.ObservableExtensions.combineLatest +import it.unibo.alchemist.model.observation.bindTo import it.unibo.alchemist.model.physics.PhysicsDependency import it.unibo.alchemist.model.physics.environments.Dynamics2DEnvironment import it.unibo.alchemist.model.timedistributions.DiracComb -import java.util.ArrayList import org.danilopianini.util.ImmutableListSet import org.danilopianini.util.ListSet @@ -59,28 +61,24 @@ class PhysicsUpdate( override val rescheduleRequest: EventObservable = EventObservable() + override val lifecycle: LifecycleRegistry = LifecycleRegistry() + override var actions: List> = listOf() private var validity: Observable = observe(true) private var canExecute: Boolean = true - private val subscriptions: MutableList> = ArrayList() - override var conditions: List> = listOf() set(value) { field = value field.forEach(Disposable::dispose) - subscriptions.forEach { it.stopWatching(this) } - subscriptions.clear() - validity.dispose() value.forEach { condition -> - condition.observeInboundDependencies().merge().apply { - onChange(this@PhysicsUpdate) { rescheduleRequest.emit() } - subscriptions.add(this) + condition.observeInboundDependencies().merge().bindTo(this) { + rescheduleRequest.emit() } } @@ -89,8 +87,7 @@ class PhysicsUpdate( ?.combineLatest { validities -> validities.all { it } } ?.map { it.getOrElse { true } } // none means empty set of conditions i.e. always true. ?.apply { - onChange(this@PhysicsUpdate) { canExecute = it } - subscriptions.add(this) + bindTo(this@PhysicsUpdate) { canExecute = it } } ?: observe(true) rescheduleRequest.emit() @@ -109,11 +106,12 @@ class PhysicsUpdate( override fun update(currentTime: Time, hasBeenExecuted: Boolean, environment: Environment) = Unit - override fun initializationComplete(atTime: Time, environment: Environment) = Unit + override fun initializationComplete(atTime: Time, environment: Environment) { + lifecycle.markState(LifecycleState.STARTED) + } override fun dispose() { - subscriptions.forEach { it.stopWatching(this) } - subscriptions.clear() + lifecycle.markState(LifecycleState.DESTROYED) validity.dispose() conditions.forEach(Disposable::dispose) rescheduleRequest.dispose() diff --git a/alchemist-test/src/main/kotlin/it/unibo/alchemist/test/GlobalTestReaction.kt b/alchemist-test/src/main/kotlin/it/unibo/alchemist/test/GlobalTestReaction.kt index 0b258bc419..8553617d7a 100644 --- a/alchemist-test/src/main/kotlin/it/unibo/alchemist/test/GlobalTestReaction.kt +++ b/alchemist-test/src/main/kotlin/it/unibo/alchemist/test/GlobalTestReaction.kt @@ -18,6 +18,8 @@ import it.unibo.alchemist.model.GlobalReaction import it.unibo.alchemist.model.Time import it.unibo.alchemist.model.TimeDistribution import it.unibo.alchemist.model.observation.EventObservable +import it.unibo.alchemist.model.observation.LifecycleRegistry +import it.unibo.alchemist.model.observation.LifecycleState import it.unibo.alchemist.model.observation.Observable import it.unibo.alchemist.model.observation.ObservableExtensions.ObservableSetExtensions.combineLatest import it.unibo.alchemist.model.observation.ObservableMutableSet @@ -36,6 +38,8 @@ class GlobalTestReaction(override val timeDistribution: TimeDistribution, override var actions: List> = emptyList() + override val lifecycle: LifecycleRegistry = LifecycleRegistry() + override var conditions: List> = emptyList() set(value) { field = value @@ -56,9 +60,12 @@ class GlobalTestReaction(override val timeDistribution: TimeDistribution, override fun update(currentTime: Time, hasBeenExecuted: Boolean, environment: Environment) = Unit - override fun initializationComplete(atTime: Time, environment: Environment) = Unit + override fun initializationComplete(atTime: Time, environment: Environment) { + lifecycle.markState(LifecycleState.STARTED) + } override fun dispose() { + lifecycle.markState(LifecycleState.DESTROYED) observableConditions.dispose() validity.dispose() conditions.forEach(Condition::dispose) From ba4f2f3d2d23ed40aca682c230174554b654c1ba Mon Sep 17 00:00:00 2001 From: S-furi Date: Fri, 30 Jan 2026 16:23:37 +0100 Subject: [PATCH 4/6] chore(api): cleanup conditions if conditions are set mulitple times --- .../alchemist/model/observation/Lifecycle.kt | 53 ++++++++++--------- .../model/reactions/AbstractReaction.java | 13 +++-- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/Lifecycle.kt b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/Lifecycle.kt index f396f867bd..ed89be4395 100644 --- a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/Lifecycle.kt +++ b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/Lifecycle.kt @@ -111,36 +111,41 @@ class LifecycleRegistry : Lifecycle { * * @param lifecycleOwner The object controlling the lifecycle of this subscription. * @param callback The action to perform when the observable emits a value. + * @return a [Disposable] to manually dispose the subscription outside owner's lifecycle. */ -fun Observable.bindTo(lifecycleOwner: LifecycleOwner, callback: (T) -> Unit) { - if (lifecycleOwner.lifecycle.currentState == DESTROYED) return - - val dataListener: (T) -> Unit = { data -> - // avoid zombie callbacks - if (lifecycleOwner.lifecycle.currentState.isAtLeast(STARTED)) { - callback(data) +fun Observable.bindTo(lifecycleOwner: LifecycleOwner, callback: (T) -> Unit): Disposable? = + lifecycleOwner.takeIf { it.lifecycle.currentState != DESTROYED }?.let { + val dataListener: (T) -> Unit = { data -> + // avoid zombie callbacks + if (lifecycleOwner.lifecycle.currentState.isAtLeast(STARTED)) { + callback(data) + } } - } - var lifecycleObserver: ((LifecycleState) -> Unit)? = null - lifecycleObserver = { state -> - when (state) { - DESTROYED -> { - this.stopWatching(lifecycleOwner) - lifecycleObserver?.let { lifecycleOwner.lifecycle.removeObserver(it) } + var lifecycleObserver: ((LifecycleState) -> Unit)? = null + lifecycleObserver = { state -> + when (state) { + DESTROYED -> { + this.stopWatching(lifecycleOwner) + lifecycleObserver?.let { lifecycleOwner.lifecycle.removeObserver(it) } + } + STARTED -> callback(this.current) + else -> { /* No action needed for other states */ } } - STARTED -> callback(this.current) - else -> { /* No action needed for other states */ } } - } - lifecycleOwner.lifecycle.addObserver(lifecycleObserver) + lifecycleOwner.lifecycle.addObserver(lifecycleObserver) - // set `invokeOnRegistration = false` because we handle the initial data emission manually below - // to strictly respect the `isAtLeast(STARTED)` check. - this.onChange(lifecycleOwner, invokeOnRegistration = false, callback = dataListener) + // set `invokeOnRegistration = false` because we handle the initial data emission manually below + // to strictly respect the `isAtLeast(STARTED)` check. + this.onChange(lifecycleOwner, invokeOnRegistration = false, callback = dataListener) - if (lifecycleOwner.lifecycle.currentState.isAtLeast(STARTED)) { - callback(this.current) + if (lifecycleOwner.lifecycle.currentState.isAtLeast(STARTED)) { + callback(this.current) + } + + return Disposable { + this@bindTo.stopWatching(dataListener) + lifecycleOwner.lifecycle.removeObserver(lifecycleObserver) + } } -} diff --git a/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/reactions/AbstractReaction.java b/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/reactions/AbstractReaction.java index 332448220c..d580226b27 100644 --- a/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/reactions/AbstractReaction.java +++ b/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/reactions/AbstractReaction.java @@ -83,6 +83,7 @@ public abstract class AbstractReaction implements Reaction, Disposable, Li private final TimeDistribution timeDistribution; private final Node node; + private final List subscriptions = new ArrayList<>(0); private final LifecycleRegistry lifecycleRegistry = new LifecycleRegistry(); private final EventObservable rescheduleRequest = new EventObservable(); private Observable validity = MutableObservable.Companion.observe(true); @@ -357,16 +358,18 @@ public void setConditions(@Nonnull final List> conditions * trigger some environment query that may fail in the set-up phase. */ protected final void initializeObservableConditions() { + subscriptions.forEach(Disposable::dispose); + subscriptions.clear(); validity.dispose(); conditions.forEach(condition -> { final var merged = ObservableExtensions.ObservableSetExtensions.mergeObservables( condition.observeInboundDependencies() ); - LifecycleKt.bindTo(merged, this, it -> { + subscriptions.add(LifecycleKt.bindTo(merged, this, it -> { rescheduleRequest.emit(); return Unit.INSTANCE; - }); + })); }); if (!conditions.isEmpty()) { @@ -375,10 +378,10 @@ protected final void initializeObservableConditions() { it -> it.stream().allMatch(b -> b) ).map(it -> getOrElse(it, () -> true)); - LifecycleKt.bindTo(validity, this, it -> { + subscriptions.add(LifecycleKt.bindTo(validity, this, it -> { canExecute = Option.fromNullable(it); return Unit.INSTANCE; - }); + })); } rescheduleRequest.emit(); } @@ -441,8 +444,10 @@ public final void update( @Override public void dispose() { lifecycleRegistry.markState(LifecycleState.DESTROYED); + subscriptions.forEach(Disposable::dispose); conditions.forEach(Disposable::dispose); conditions.clear(); + subscriptions.clear(); } /** From cc5bfdbcebd723421610d7ef5c10d012e99366a2 Mon Sep 17 00:00:00 2001 From: S-furi Date: Tue, 10 Feb 2026 10:33:50 +0100 Subject: [PATCH 5/6] refactor(api): extract lifecycle components into multiple files --- .../it/unibo/alchemist/model/Actionable.kt | 2 +- .../kotlin/it/unibo/alchemist/model/Node.kt | 2 +- .../model/observation/lifecycle/Lifecycle.kt | 31 +++++++ .../LifecycleOwner.kt} | 92 ++----------------- .../lifecycle/LifecycleRegistry.kt | 37 ++++++++ .../observation/lifecycle/LifecycleState.kt | 38 ++++++++ .../model/observation/LifecycleTest.kt | 8 +- .../model/reactions/AbstractReaction.java | 14 +-- .../alchemist/model/nodes/GenericNode.kt | 4 +- .../model/protelis/ProtelisIncarnation.kt | 2 +- .../model/physics/reactions/PhysicsUpdate.kt | 6 +- .../alchemist/test/GlobalTestReaction.kt | 4 +- 12 files changed, 138 insertions(+), 102 deletions(-) create mode 100644 alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/Lifecycle.kt rename alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/{Lifecycle.kt => lifecycle/LifecycleOwner.kt} (52%) create mode 100644 alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleRegistry.kt create mode 100644 alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleState.kt diff --git a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Actionable.kt b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Actionable.kt index 40c2df534d..0b646f2dc4 100644 --- a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Actionable.kt +++ b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Actionable.kt @@ -10,8 +10,8 @@ package it.unibo.alchemist.model import it.unibo.alchemist.model.observation.Disposable -import it.unibo.alchemist.model.observation.LifecycleOwner import it.unibo.alchemist.model.observation.Observable +import it.unibo.alchemist.model.observation.lifecycle.LifecycleOwner import java.io.Serializable import org.danilopianini.util.ListSet diff --git a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Node.kt b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Node.kt index a1a0874a83..9a3ff3f640 100644 --- a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Node.kt +++ b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/Node.kt @@ -10,9 +10,9 @@ package it.unibo.alchemist.model import arrow.core.Option import it.unibo.alchemist.model.observation.Disposable -import it.unibo.alchemist.model.observation.LifecycleOwner import it.unibo.alchemist.model.observation.Observable import it.unibo.alchemist.model.observation.ObservableMap +import it.unibo.alchemist.model.observation.lifecycle.LifecycleOwner import java.io.Serializable import kotlin.reflect.KClass import kotlin.reflect.full.isSubclassOf diff --git a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/Lifecycle.kt b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/Lifecycle.kt new file mode 100644 index 0000000000..ebd24fa226 --- /dev/null +++ b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/Lifecycle.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.model.observation.lifecycle + +/** + * Manages the state and listeners. + */ +interface Lifecycle { + + /** + * Returns the current state of the Lifecycle. + */ + val currentState: LifecycleState + + /** + * Adds a LifecycleObserver that will be notified when the LifecycleOwner changes state. + */ + fun addObserver(observer: (LifecycleState) -> Unit) + + /** + * Removes the given observer from the observers list. + */ + fun removeObserver(observer: (LifecycleState) -> Unit) +} diff --git a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/Lifecycle.kt b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleOwner.kt similarity index 52% rename from alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/Lifecycle.kt rename to alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleOwner.kt index ed89be4395..bedff28e11 100644 --- a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/Lifecycle.kt +++ b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleOwner.kt @@ -7,38 +7,12 @@ * as described in the file LICENSE in the Alchemist distribution's top directory. */ -package it.unibo.alchemist.model.observation +package it.unibo.alchemist.model.observation.lifecycle -import it.unibo.alchemist.model.observation.LifecycleState.DESTROYED -import it.unibo.alchemist.model.observation.LifecycleState.STARTED - -/** - * Represents the lifecycle state of a component. - */ -enum class LifecycleState { - - /** - * Destroyed state for a LifecycleOwner. After this state is reached, this Lifecycle will not emit any more events. - */ - DESTROYED, - - /** - * Initialized state for a LifecycleOwner. - */ - INITIALIZED, - - /** - * Started state for a LifecycleOwner. - */ - STARTED, - - ; - - /** - * Checks if the current state is at least the given [state]. - */ - fun isAtLeast(state: LifecycleState): Boolean = this >= state -} +import it.unibo.alchemist.model.observation.Disposable +import it.unibo.alchemist.model.observation.Observable +import it.unibo.alchemist.model.observation.lifecycle.LifecycleState.DESTROYED +import it.unibo.alchemist.model.observation.lifecycle.LifecycleState.STARTED /** * An object which has a [Lifecycle]. @@ -52,66 +26,18 @@ interface LifecycleOwner { } /** - * Manages the state and listeners. - */ -interface Lifecycle { - - /** - * Returns the current state of the Lifecycle. - */ - val currentState: LifecycleState - - /** - * Adds a LifecycleObserver that will be notified when the LifecycleOwner changes state. - */ - fun addObserver(observer: (LifecycleState) -> Unit) - - /** - * Removes the given observer from the observers list. - */ - fun removeObserver(observer: (LifecycleState) -> Unit) -} - -/** - * A concrete implementation of [Lifecycle] that handles state transitions and observer notification. - */ -class LifecycleRegistry : Lifecycle { - - private val observers = mutableListOf<(LifecycleState) -> Unit>() - - override var currentState: LifecycleState = LifecycleState.INITIALIZED - private set - - /** - * Transitions the lifecycle to a new [state] and notifies all observers. - */ - fun markState(state: LifecycleState) { - currentState = state - observers.toList().forEach { it(state) } - } - - override fun addObserver(observer: (LifecycleState) -> Unit) { - observers.add(observer) - } - - override fun removeObserver(observer: (LifecycleState) -> Unit) { - observers.remove(observer) - } -} - -/** - * Observes this [Observable] within the context of a [LifecycleOwner]. This - * method is a memory-safe alternative to [Observable.onChange] in scenarios + * Observes this [it.unibo.alchemist.model.observation.Observable] within the context of a [LifecycleOwner]. This + * method is a memory-safe alternative to [it.unibo.alchemist.model.observation.Observable.onChange] in scenarios * where it is crucial to avoid that the owner subscription leaks. When the * registrant's state reaches [LifecycleState.DESTROYED], the subscription * is automatically removed and cleared up. Moreover, the [callback] is only * invoked if the lifecycle is in an active state ([STARTED]). Finally, * when the lifecycle moves from an inactive state back to active, the - * [callback] is triggered immediately with the [Observable.current] value. + * [callback] is triggered immediately with the [it.unibo.alchemist.model.observation.Observable.current] value. * * @param lifecycleOwner The object controlling the lifecycle of this subscription. * @param callback The action to perform when the observable emits a value. - * @return a [Disposable] to manually dispose the subscription outside owner's lifecycle. + * @return a [it.unibo.alchemist.model.observation.Disposable] to manually dispose the subscription outside owner's lifecycle. */ fun Observable.bindTo(lifecycleOwner: LifecycleOwner, callback: (T) -> Unit): Disposable? = lifecycleOwner.takeIf { it.lifecycle.currentState != DESTROYED }?.let { diff --git a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleRegistry.kt b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleRegistry.kt new file mode 100644 index 0000000000..17d6972dcf --- /dev/null +++ b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleRegistry.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.model.observation.lifecycle + +/** + * A concrete implementation of [Lifecycle] that handles state transitions and observer notification. + */ +class LifecycleRegistry : Lifecycle { + + private val observers = mutableListOf<(LifecycleState) -> Unit>() + + override var currentState: LifecycleState = LifecycleState.INITIALIZED + private set + + /** + * Transitions the lifecycle to a new [state] and notifies all observers. + */ + fun markState(state: LifecycleState) { + currentState = state + observers.toList().forEach { it(state) } + } + + override fun addObserver(observer: (LifecycleState) -> Unit) { + observers.add(observer) + } + + override fun removeObserver(observer: (LifecycleState) -> Unit) { + observers.remove(observer) + } +} diff --git a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleState.kt b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleState.kt new file mode 100644 index 0000000000..638c32aef0 --- /dev/null +++ b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleState.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2010-2026, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.model.observation.lifecycle + +/** + * Represents the lifecycle state of a component. + */ +enum class LifecycleState { + + /** + * Destroyed state for a LifecycleOwner. After this state is reached, this Lifecycle will not emit any more events. + */ + DESTROYED, + + /** + * Initialized state for a LifecycleOwner. + */ + INITIALIZED, + + /** + * Started state for a LifecycleOwner. + */ + STARTED, + + ; + + /** + * Checks if the current state is at least the given [state]. + */ + fun isAtLeast(state: LifecycleState): Boolean = this >= state +} diff --git a/alchemist-api/src/test/kotlin/it/unibo/alchemist/model/observation/LifecycleTest.kt b/alchemist-api/src/test/kotlin/it/unibo/alchemist/model/observation/LifecycleTest.kt index 0f331048ad..6110887c5c 100644 --- a/alchemist-api/src/test/kotlin/it/unibo/alchemist/model/observation/LifecycleTest.kt +++ b/alchemist-api/src/test/kotlin/it/unibo/alchemist/model/observation/LifecycleTest.kt @@ -13,9 +13,13 @@ import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldNotContain import io.kotest.matchers.shouldBe -import it.unibo.alchemist.model.observation.LifecycleState.DESTROYED -import it.unibo.alchemist.model.observation.LifecycleState.STARTED import it.unibo.alchemist.model.observation.MutableObservable.Companion.observe +import it.unibo.alchemist.model.observation.lifecycle.Lifecycle +import it.unibo.alchemist.model.observation.lifecycle.LifecycleOwner +import it.unibo.alchemist.model.observation.lifecycle.LifecycleRegistry +import it.unibo.alchemist.model.observation.lifecycle.LifecycleState.DESTROYED +import it.unibo.alchemist.model.observation.lifecycle.LifecycleState.STARTED +import it.unibo.alchemist.model.observation.lifecycle.bindTo class LifecycleTest : FunSpec({ diff --git a/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/reactions/AbstractReaction.java b/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/reactions/AbstractReaction.java index d580226b27..150260eac1 100644 --- a/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/reactions/AbstractReaction.java +++ b/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/reactions/AbstractReaction.java @@ -24,11 +24,11 @@ import it.unibo.alchemist.model.TimeDistribution; import it.unibo.alchemist.model.observation.Disposable; import it.unibo.alchemist.model.observation.EventObservable; -import it.unibo.alchemist.model.observation.Lifecycle; -import it.unibo.alchemist.model.observation.LifecycleKt; -import it.unibo.alchemist.model.observation.LifecycleOwner; -import it.unibo.alchemist.model.observation.LifecycleRegistry; -import it.unibo.alchemist.model.observation.LifecycleState; +import it.unibo.alchemist.model.observation.lifecycle.Lifecycle; +import it.unibo.alchemist.model.observation.lifecycle.LifecycleOwner; +import it.unibo.alchemist.model.observation.lifecycle.LifecycleOwnerKt; +import it.unibo.alchemist.model.observation.lifecycle.LifecycleRegistry; +import it.unibo.alchemist.model.observation.lifecycle.LifecycleState; import it.unibo.alchemist.model.observation.MutableObservable; import it.unibo.alchemist.model.observation.Observable; import it.unibo.alchemist.model.observation.ObservableExtensions; @@ -366,7 +366,7 @@ protected final void initializeObservableConditions() { final var merged = ObservableExtensions.ObservableSetExtensions.mergeObservables( condition.observeInboundDependencies() ); - subscriptions.add(LifecycleKt.bindTo(merged, this, it -> { + subscriptions.add(LifecycleOwnerKt.bindTo(merged, this, it -> { rescheduleRequest.emit(); return Unit.INSTANCE; })); @@ -378,7 +378,7 @@ protected final void initializeObservableConditions() { it -> it.stream().allMatch(b -> b) ).map(it -> getOrElse(it, () -> true)); - subscriptions.add(LifecycleKt.bindTo(validity, this, it -> { + subscriptions.add(LifecycleOwnerKt.bindTo(validity, this, it -> { canExecute = Option.fromNullable(it); return Unit.INSTANCE; })); diff --git a/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/nodes/GenericNode.kt b/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/nodes/GenericNode.kt index b0be62a93f..2a7bc79aba 100644 --- a/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/nodes/GenericNode.kt +++ b/alchemist-implementationbase/src/main/kotlin/it/unibo/alchemist/model/nodes/GenericNode.kt @@ -19,10 +19,10 @@ import it.unibo.alchemist.model.NodeProperty import it.unibo.alchemist.model.Reaction import it.unibo.alchemist.model.Time import it.unibo.alchemist.model.observation.Disposable -import it.unibo.alchemist.model.observation.LifecycleRegistry -import it.unibo.alchemist.model.observation.LifecycleState import it.unibo.alchemist.model.observation.Observable import it.unibo.alchemist.model.observation.ObservableMutableMap +import it.unibo.alchemist.model.observation.lifecycle.LifecycleRegistry +import it.unibo.alchemist.model.observation.lifecycle.LifecycleState import java.util.Collections import java.util.Spliterator import java.util.concurrent.Semaphore diff --git a/alchemist-incarnation-protelis/src/main/kotlin/it/unibo/alchemist/model/protelis/ProtelisIncarnation.kt b/alchemist-incarnation-protelis/src/main/kotlin/it/unibo/alchemist/model/protelis/ProtelisIncarnation.kt index e54cc86b57..6f2396a053 100644 --- a/alchemist-incarnation-protelis/src/main/kotlin/it/unibo/alchemist/model/protelis/ProtelisIncarnation.kt +++ b/alchemist-incarnation-protelis/src/main/kotlin/it/unibo/alchemist/model/protelis/ProtelisIncarnation.kt @@ -29,9 +29,9 @@ import it.unibo.alchemist.model.Time import it.unibo.alchemist.model.TimeDistribution import it.unibo.alchemist.model.molecules.SimpleMolecule import it.unibo.alchemist.model.nodes.GenericNode -import it.unibo.alchemist.model.observation.LifecycleRegistry import it.unibo.alchemist.model.observation.Observable import it.unibo.alchemist.model.observation.ObservableMap +import it.unibo.alchemist.model.observation.lifecycle.LifecycleRegistry import it.unibo.alchemist.model.protelis.actions.RunProtelisProgram import it.unibo.alchemist.model.protelis.actions.SendToNeighbor import it.unibo.alchemist.model.protelis.conditions.ComputationalRoundComplete diff --git a/alchemist-physics/src/main/kotlin/it/unibo/alchemist/model/physics/reactions/PhysicsUpdate.kt b/alchemist-physics/src/main/kotlin/it/unibo/alchemist/model/physics/reactions/PhysicsUpdate.kt index 792e766267..ec997b46c7 100644 --- a/alchemist-physics/src/main/kotlin/it/unibo/alchemist/model/physics/reactions/PhysicsUpdate.kt +++ b/alchemist-physics/src/main/kotlin/it/unibo/alchemist/model/physics/reactions/PhysicsUpdate.kt @@ -20,13 +20,13 @@ import it.unibo.alchemist.model.Time import it.unibo.alchemist.model.TimeDistribution import it.unibo.alchemist.model.observation.Disposable import it.unibo.alchemist.model.observation.EventObservable -import it.unibo.alchemist.model.observation.LifecycleRegistry -import it.unibo.alchemist.model.observation.LifecycleState import it.unibo.alchemist.model.observation.MutableObservable.Companion.observe import it.unibo.alchemist.model.observation.Observable import it.unibo.alchemist.model.observation.ObservableExtensions.ObservableSetExtensions.merge import it.unibo.alchemist.model.observation.ObservableExtensions.combineLatest -import it.unibo.alchemist.model.observation.bindTo +import it.unibo.alchemist.model.observation.lifecycle.LifecycleRegistry +import it.unibo.alchemist.model.observation.lifecycle.LifecycleState +import it.unibo.alchemist.model.observation.lifecycle.bindTo import it.unibo.alchemist.model.physics.PhysicsDependency import it.unibo.alchemist.model.physics.environments.Dynamics2DEnvironment import it.unibo.alchemist.model.timedistributions.DiracComb diff --git a/alchemist-test/src/main/kotlin/it/unibo/alchemist/test/GlobalTestReaction.kt b/alchemist-test/src/main/kotlin/it/unibo/alchemist/test/GlobalTestReaction.kt index 8553617d7a..f606fe594e 100644 --- a/alchemist-test/src/main/kotlin/it/unibo/alchemist/test/GlobalTestReaction.kt +++ b/alchemist-test/src/main/kotlin/it/unibo/alchemist/test/GlobalTestReaction.kt @@ -18,11 +18,11 @@ import it.unibo.alchemist.model.GlobalReaction import it.unibo.alchemist.model.Time import it.unibo.alchemist.model.TimeDistribution import it.unibo.alchemist.model.observation.EventObservable -import it.unibo.alchemist.model.observation.LifecycleRegistry -import it.unibo.alchemist.model.observation.LifecycleState import it.unibo.alchemist.model.observation.Observable import it.unibo.alchemist.model.observation.ObservableExtensions.ObservableSetExtensions.combineLatest import it.unibo.alchemist.model.observation.ObservableMutableSet +import it.unibo.alchemist.model.observation.lifecycle.LifecycleRegistry +import it.unibo.alchemist.model.observation.lifecycle.LifecycleState import org.danilopianini.util.ListSet import org.danilopianini.util.ListSets From 38227e4fa99ac83d2c5fe674be4a4ef9303b1782 Mon Sep 17 00:00:00 2001 From: S-furi Date: Tue, 10 Feb 2026 12:00:29 +0100 Subject: [PATCH 6/6] fix: fix line length --- .../alchemist/model/observation/lifecycle/LifecycleOwner.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleOwner.kt b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleOwner.kt index bedff28e11..07f0c80082 100644 --- a/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleOwner.kt +++ b/alchemist-api/src/main/kotlin/it/unibo/alchemist/model/observation/lifecycle/LifecycleOwner.kt @@ -37,7 +37,8 @@ interface LifecycleOwner { * * @param lifecycleOwner The object controlling the lifecycle of this subscription. * @param callback The action to perform when the observable emits a value. - * @return a [it.unibo.alchemist.model.observation.Disposable] to manually dispose the subscription outside owner's lifecycle. + * @return a [it.unibo.alchemist.model.observation.Disposable] + * to manually dispose the subscription outside owner's lifecycle. */ fun Observable.bindTo(lifecycleOwner: LifecycleOwner, callback: (T) -> Unit): Disposable? = lifecycleOwner.takeIf { it.lifecycle.currentState != DESTROYED }?.let {