docs(CompositeControlComponentSpec): improved comments and code

This commit is contained in:
Максим Колпаков 2025-02-01 03:42:23 +03:00
parent 7d11650cab
commit 37e357a404
2 changed files with 282 additions and 101 deletions
controls-core/src
commonMain/kotlin/space/kscience/controls/spec
commonTest/kotlin/space/kscience/controls/spec

@ -19,6 +19,120 @@ import kotlin.reflect.KProperty
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Extension function to safely get the completed value of a Deferred or return null.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private fun <T> Deferred<T>.getCompletedOrNull(): T? =
if (isCompleted && !isCancelled) getCompleted() else null
private val globalExceptionHandler = CoroutineExceptionHandler { _, throwable ->
println("Unhandled exception in global scope: ${throwable.message}")
}
/**
* EventBus interface for publishing and subscribing to application-level events.
*/
public interface EventBus {
public val events: SharedFlow<Any>
public suspend fun publish(event: Any)
}
/**
* Default implementation of EventBus using a MutableSharedFlow.
*/
public class DefaultEventBus(
replay: Int = 100,
onBufferOverflow: BufferOverflow = BufferOverflow.DROP_OLDEST
) : EventBus {
private val _events = MutableSharedFlow<Any>(replay = replay, onBufferOverflow = onBufferOverflow)
override val events: SharedFlow<Any> get() = _events
override suspend fun publish(event: Any) {
_events.emit(event)
}
}
/**
* TransportAdapter interface for distributed communications.
* This abstraction allows plugging in different transport mechanisms.
*/
public interface TransportAdapter {
public suspend fun send(message: DeviceMessage)
public fun subscribe(): Flow<DeviceMessage>
}
/**
* Default stub implementation of TransportAdapter for in-process communication.
*/
public class DefaultTransportAdapter(
private val eventBus: EventBus
) : TransportAdapter {
private val _messages = MutableSharedFlow<DeviceMessage>(replay = 100, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override suspend fun send(message: DeviceMessage) {
_messages.emit(message)
eventBus.publish("Message sent: ${message.sourceDevice}")
}
override fun subscribe(): Flow<DeviceMessage> = _messages.asSharedFlow()
}
/**
* TransactionManager interface for executing a block of operations.
*/
public interface TransactionManager {
/**
* Executes [block] within a transaction. If an exception occurs, a rollback is performed.
*/
public suspend fun <T> withTransaction(block: suspend () -> T): T
}
/**
* Default implementation of TransactionManager.
* This implementation wraps the block in a try/catch and publishes transaction events.
*/
public class DefaultTransactionManager(
private val eventBus: EventBus,
private val logger: Logger = DefaultLogManager()
) : TransactionManager {
override suspend fun <T> withTransaction(block: suspend () -> T): T {
eventBus.publish(TransactionEvent.TransactionStarted)
return try {
val result = block()
eventBus.publish(TransactionEvent.TransactionCommitted)
result
} catch (ex: Exception) {
logger.error(ex) { "Transaction failed, rolling back." }
eventBus.publish(TransactionEvent.TransactionRolledBack)
throw ex
}
}
}
/**
* Transaction events.
*/
public sealed class TransactionEvent {
public object TransactionStarted : TransactionEvent()
public object TransactionCommitted : TransactionEvent()
public object TransactionRolledBack : TransactionEvent()
}
/**
* Interface for publishing metrics.
*/
public interface MetricPublisher {
public fun publishMetric(name: String, value: Double, tags: Map<String, String> = emptyMap())
}
/**
* Default stub implementation of MetricPublisher which logs metrics.
*/
public class DefaultMetricPublisher : MetricPublisher {
override fun publishMetric(name: String, value: Double, tags: Map<String, String>) {
println("Metric published: $name = $value, tags: $tags")
}
}
/**
* Defines different modes of how a child device is coupled to its parent device.
*
@ -476,7 +590,7 @@ public interface CompositeDeviceSpec<D : ConfigurableCompositeControlComponent<D
converter: MetaConverter<T>,
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
name: String? = null,
read: suspend D.(propertyName: String) -> T?,
read: suspend D.(propertyName: String) -> T?
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, T>>>
/**
@ -487,7 +601,7 @@ public interface CompositeDeviceSpec<D : ConfigurableCompositeControlComponent<D
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
name: String? = null,
read: suspend D.(propertyName: String) -> T?,
write: suspend D.(propertyName: String, value: T) -> Unit,
write: suspend D.(propertyName: String, value: T) -> Unit
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, MutableDevicePropertySpec<D, T>>>
/**
@ -498,7 +612,7 @@ public interface CompositeDeviceSpec<D : ConfigurableCompositeControlComponent<D
outputConverter: MetaConverter<O>,
descriptorBuilder: ActionDescriptorBuilder.() -> Unit = {},
name: String? = null,
execute: suspend D.(I) -> O,
execute: suspend D.(I) -> O
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DeviceActionSpec<D, I, O>>>
}
@ -537,7 +651,7 @@ public open class CompositeControlComponentSpec<D : ConfigurableCompositeControl
}
override fun validate(device: D) {
// Verify that all declared properties and actions are indeed in the device's descriptors
// Verify that all declared properties and actions are registered in the device.
properties.values.forEach { prop ->
check(prop.descriptor in device.propertyDescriptors) {
"Property ${prop.descriptor.name} not registered in ${device.id}"
@ -598,7 +712,7 @@ public open class CompositeControlComponentSpec<D : ConfigurableCompositeControl
converter: MetaConverter<T>,
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit,
name: String?,
read: suspend D.(propertyName: String) -> T?,
read: suspend D.(propertyName: String) -> T?
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, T>>> =
PropertyDelegateProvider { _, property ->
val propertyName = name ?: property.name
@ -652,7 +766,7 @@ public open class CompositeControlComponentSpec<D : ConfigurableCompositeControl
specKeyInRegistry: Name? = null,
childDeviceName: Name? = null,
metaBuilder: (MutableMeta.() -> Unit)? = null,
configBuilder: DeviceLifecycleConfigBuilder.() -> Unit = {},
configBuilder: DeviceLifecycleConfigBuilder.() -> Unit = {}
): PropertyDelegateProvider<CompositeControlComponentSpec<D>, ReadOnlyProperty<CompositeControlComponentSpec<D>, CompositeControlComponentSpec<CD>>> =
PropertyDelegateProvider { thisRef, property ->
val registryKey = specKeyInRegistry ?: property.name.asName()
@ -711,7 +825,7 @@ public open class CompositeControlComponentSpec<D : ConfigurableCompositeControl
public fun <D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.unitAction(
descriptorBuilder: ActionDescriptorBuilder.() -> Unit = {},
name: String? = null,
execute: suspend D.() -> Unit,
execute: suspend D.() -> Unit
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DeviceActionSpec<D, Unit, Unit>>> =
action(MetaConverter.unit, MetaConverter.unit, descriptorBuilder, name) { execute() }
@ -725,28 +839,38 @@ public fun <D : ConfigurableCompositeControlComponent<D>> CompositeControlCompon
public fun <D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.metaAction(
descriptorBuilder: ActionDescriptorBuilder.() -> Unit = {},
name: String? = null,
execute: suspend D.(Meta) -> Meta,
execute: suspend D.(Meta) -> Meta
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DeviceActionSpec<D, Meta, Meta>>> =
action(MetaConverter.meta, MetaConverter.meta, descriptorBuilder, name) { execute(it) }
/**
* An abstract manager for devices, handling lifecycle, error policies, transactions, etc.
* An abstract manager for devices, handling lifecycle, error policies, transactions, distributed transport,
* structured concurrency, and event/metric publishing.
*
* Typically, you extend or instantiate this to manage a set of child [Device]s.
* This class uses a global exception handler and SupervisorJob for centralized error handling.
*
* All coroutines are launched with the context: parentJob + dispatcher + globalExceptionHandler.
*
* @param context The [Context] for logging and plugin management.
* @param dispatcher A [CoroutineDispatcher] for concurrency; default is [Dispatchers.Default].
*/
public abstract class AbstractDeviceHubManager(
public val context: Context,
private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
private val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
/**
* The supervisor job for all child devices.
* Global exception handler for all coroutines in this manager.
*/
protected val exceptionHandler: CoroutineExceptionHandler = CoroutineExceptionHandler { _, ex ->
context.logger.error(ex) { "Unhandled exception in global scope" }
}
/**
* SupervisorJob ensures that child coroutines are isolated.
*/
protected val parentJob: Job = SupervisorJob()
protected val childLock = Mutex()
protected val childLock: Mutex = Mutex()
/**
* Internal map that keeps track of each child's [ChildJob].
@ -782,6 +906,21 @@ public abstract class AbstractDeviceHubManager(
*/
public abstract val deviceChanges: MutableSharedFlow<DeviceStateEvent>
/**
* Additional EventBus for application-level events.
*/
public abstract val eventBus: EventBus
/**
* Metric publisher for logging and monitoring.
*/
public open val metricPublisher: MetricPublisher = DefaultMetricPublisher()
/**
* Transaction manager for wrapping critical operations.
*/
public abstract val transactionManager: TransactionManager
/**
* Tracks the number of restart attempts per device.
*/
@ -794,7 +933,7 @@ public abstract class AbstractDeviceHubManager(
private val restartingDevices: MutableSet<Name> = mutableSetOf()
/**
* Represents a running child device along with its job, config, and flows.
* Represents a running child device along with its job, configuration, and flows.
*
* @property device The managed device instance.
* @property collectorJob The coroutine job collecting messages from [device.messageFlow].
@ -817,10 +956,13 @@ public abstract class AbstractDeviceHubManager(
}
/**
* Called when an error is thrown from a child's message-collecting coroutine or any other child logic.
*
* By default, logs the error.
* Override to provide custom reaction (before applying the [onError] policy).
* Global function for launching coroutines with the combined context.
*/
internal fun launchGlobal(block: suspend CoroutineScope.() -> Unit): Job =
CoroutineScope(parentJob + dispatcher + exceptionHandler).launch { block() }
/**
* Called when an error is thrown from a child's coroutine.
*/
protected open suspend fun onChildErrorCaught(ex: Throwable, childName: Name, config: DeviceLifecycleConfig) {
context.logger.error(ex) { "Error in child device $childName with policy ${config.onError}" }
@ -894,7 +1036,7 @@ public abstract class AbstractDeviceHubManager(
replay = config.messageBuffer,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val childScope = config.coroutineScope ?: CoroutineScope(parentJob + dispatcher)
val childScope = config.coroutineScope ?: CoroutineScope(parentJob + dispatcher + exceptionHandler)
val collectorJob = childScope.launch(CoroutineName("Collect device $name")) {
try {
@ -904,9 +1046,7 @@ public abstract class AbstractDeviceHubManager(
messageBus.emit(wrapped)
}
} catch (ex: Exception) {
if (ex is CancellationException) {
throw ex
}
if (ex is CancellationException) throw ex
onChildErrorCaught(ex, name, config)
deviceChanges.emit(DeviceStateEvent.DeviceFailed(name, ex))
messageBus.emit(DeviceMessage.error(ex, name))
@ -966,9 +1106,8 @@ public abstract class AbstractDeviceHubManager(
private fun calculateDelay(policy: RestartPolicy, attempts: Int): Duration {
return when (policy.strategy) {
RestartStrategy.LINEAR -> policy.delayBetweenAttempts
RestartStrategy.EXPONENTIAL_BACKOFF ->
policy.delayBetweenAttempts * 2.0.pow((attempts - 1).toDouble())
RestartStrategy.CUSTOM -> Duration.ZERO // custom strategy can be overridden
RestartStrategy.EXPONENTIAL_BACKOFF -> policy.delayBetweenAttempts * 2.0.pow((attempts - 1).toDouble())
RestartStrategy.CUSTOM -> Duration.ZERO // Custom strategy can be overridden.
}
}
@ -982,18 +1121,21 @@ public abstract class AbstractDeviceHubManager(
device: Device,
bus: MutableSharedFlow<DeviceMessage>
) {
childLock.withLock {
val shouldRemove = childLock.withLock {
val current = childrenJobs[name]
if (current?.device == device) {
childrenJobs.remove(name)
restartAttemptsMap.remove(name)
}
true
} else false
}
bus.resetReplayCache()
deviceChanges.emit(DeviceStateEvent.DeviceDetached(name))
systemBus.emit(SystemLogMessage("Device $name physically removed.", sourceDevice = name))
if (device is ConfigurableCompositeControlComponent<*>) {
device.onChildStop()
if (shouldRemove) {
bus.resetReplayCache()
deviceChanges.emit(DeviceStateEvent.DeviceDetached(name))
systemBus.emit(SystemLogMessage("Device $name physically removed.", sourceDevice = name))
if (device is ConfigurableCompositeControlComponent<*>) {
device.onChildStop()
}
}
}
@ -1029,15 +1171,11 @@ public abstract class AbstractDeviceHubManager(
}
deviceChanges.emit(DeviceStateEvent.DeviceAdded(name))
systemBus.emit(SystemLogMessage("Device $name attached, startMode=$startMode", sourceDevice = name))
metricPublisher.publishMetric("device.attach", 1.0, mapOf("device" to name.toString()))
if (config.lifecycleMode == LifecycleMode.INDEPENDENT) return
when (startMode) {
StartMode.NONE -> {}
StartMode.ASYNC -> {
CoroutineScope(parentJob + dispatcher).launch {
doStartDevice(name, config, device)
}
}
StartMode.ASYNC -> launchGlobal { doStartDevice(name, config, device) }
StartMode.SYNC -> doStartDevice(name, config, device)
}
}
@ -1055,6 +1193,7 @@ public abstract class AbstractDeviceHubManager(
onStartTimeout(name, config)
} else {
deviceChanges.emit(DeviceStateEvent.DeviceStarted(name))
metricPublisher.publishMetric("device.start", 1.0, mapOf("device" to name.toString()))
if (config.restartPolicy.resetOnSuccess) restartAttemptsMap[name] = 0
}
}
@ -1064,7 +1203,21 @@ public abstract class AbstractDeviceHubManager(
* If [waitStop] = true, waits until the device fully stops.
*/
public suspend fun detachDevice(name: Name, waitStop: Boolean = false) {
childLock.withLock { removeDeviceUnlocked(name, waitStop = waitStop) }
val child = childLock.withLock {
childrenJobs.remove(name)?.also {
restartAttemptsMap.remove(name)
}
}
if (child != null) {
deviceChanges.emit(DeviceStateEvent.DeviceRemoved(name))
systemBus.emit(SystemLogMessage("Device $name removed (waitStop=$waitStop)", sourceDevice = name))
metricPublisher.publishMetric("device.detach", 1.0, mapOf("device" to name.toString()))
if (waitStop) {
performStop(child)
} else {
launchGlobal { performStop(child) }
}
}
}
/**
@ -1078,6 +1231,7 @@ public abstract class AbstractDeviceHubManager(
onStartTimeout(name, config)
} else {
deviceChanges.emit(DeviceStateEvent.DeviceStarted(name))
metricPublisher.publishMetric("device.start", 1.0, mapOf("device" to name.toString()))
if (config.restartPolicy.resetOnSuccess) restartAttemptsMap[name] = 0
}
}
@ -1101,6 +1255,7 @@ public abstract class AbstractDeviceHubManager(
)
childrenJobs[name] = newChild
systemBus.emit(SystemLogMessage("Device $name restarted", sourceDevice = name))
metricPublisher.publishMetric("device.restart", 1.0, mapOf("device" to name.toString()))
}
val deviceRef = childrenJobs[name]?.device ?: return
if (childrenJobs[name]?.config?.lifecycleMode != LifecycleMode.INDEPENDENT) {
@ -1113,20 +1268,25 @@ public abstract class AbstractDeviceHubManager(
* The device is removed (stopped), then re-added with the new mode.
*/
public suspend fun changeLifecycleMode(name: Name, newMode: LifecycleMode) {
childLock.withLock {
val old = childrenJobs[name] ?: error("Device $name not found")
val newConfig = old.config.copy(lifecycleMode = newMode)
removeDeviceUnlocked(name, waitStop = true)
val newChild = launchChild(
name,
old.device,
newConfig,
old.meta,
reuseBus = if (old.reuseBus) old.messageBus else null
)
childrenJobs[name] = newChild
systemBus.emit(SystemLogMessage("Device $name lifecycle changed to $newMode", sourceDevice = name))
val old = childLock.withLock {
val existing = childrenJobs[name] ?: error("Device $name not found")
val newConfig = existing.config.copy(lifecycleMode = newMode)
childrenJobs.remove(name)
restartAttemptsMap.remove(name)
Triple(existing.device, newConfig, existing.meta)
}
val newChild = launchChild(
name,
old.first,
old.second,
old.third,
reuseBus = null
)
childLock.withLock {
childrenJobs[name] = newChild
}
systemBus.emit(SystemLogMessage("Device $name lifecycle changed to $newMode", sourceDevice = name))
metricPublisher.publishMetric("device.lifecycle.change", 1.0, mapOf("device" to name.toString(), "newMode" to newMode.name))
val deviceRef = childrenJobs[name]?.device ?: return
if (newMode != LifecycleMode.INDEPENDENT) {
finalizeDeviceStart(name, childrenJobs[name]!!.config, deviceRef)
@ -1149,43 +1309,46 @@ public abstract class AbstractDeviceHubManager(
meta: Meta? = null,
reuseMessageBus: Boolean = false
) {
childLock.withLock {
val old = childrenJobs[name]
val oldBus = if (reuseMessageBus) old?.messageBus else null
transactionManager.withTransaction {
val oldBus = childLock.withLock { childrenJobs[name]?.messageBus }
removeDeviceUnlocked(name, waitStop = true)
val newChild = launchChild(name, newDevice, config, meta, oldBus)
childrenJobs[name] = newChild
systemBus.emit(SystemLogMessage("Device $name hot-swapped", sourceDevice = name))
}
val deviceRef = childrenJobs[name]?.device ?: return
if (config.lifecycleMode != LifecycleMode.INDEPENDENT) {
finalizeDeviceStart(name, config, deviceRef)
childLock.withLock {
val newChild = launchChild(name, newDevice, config, meta, oldBus)
childrenJobs[name] = newChild
systemBus.emit(SystemLogMessage("Device $name hot-swapped", sourceDevice = name))
metricPublisher.publishMetric("device.hotswap", 1.0, mapOf("device" to name.toString()))
}
val deviceRef = childLock.withLock { childrenJobs[name]?.device }
if (deviceRef != null && config.lifecycleMode != LifecycleMode.INDEPENDENT) {
finalizeDeviceStart(name, config, deviceRef)
}
}
}
/**
* Internal function to remove (and optionally wait-stop) a device by [name].
* This method must be called from within [childLock].
*
* @param waitStop If true, waits for stopping within [DeviceLifecycleConfig.stopTimeout].
*/
private suspend fun removeDeviceUnlocked(name: Name, waitStop: Boolean) {
val child = childrenJobs[name] ?: return
childrenJobs.remove(name)
restartAttemptsMap.remove(name)
val child = childLock.withLock { childrenJobs[name] } ?: return
childLock.withLock {
childrenJobs.remove(name)
restartAttemptsMap.remove(name)
}
deviceChanges.emit(DeviceStateEvent.DeviceRemoved(name))
systemBus.emit(SystemLogMessage("Device $name removed (waitStop=$waitStop)", sourceDevice = name))
if (waitStop) {
performStop(child)
} else {
CoroutineScope(parentJob + dispatcher).launch { performStop(child) }
launchGlobal { performStop(child) }
}
}
/**
* Performs the actual stopping sequence:
* 1) Attempt `device.stop()` with [stopTimeout]
* 2) Cancel and join the collector job
* 2) Cancel and join the collector job.
*/
private suspend fun performStop(child: ChildJob) {
val timeout = child.config.stopTimeout ?: Duration.INFINITE
@ -1196,7 +1359,10 @@ public abstract class AbstractDeviceHubManager(
if (result == null) {
onStopTimeout(deviceName, child.config)
}
child.collectorJob.cancelAndJoin()
withContext(NonCancellable) {
child.collectorJob.cancelAndJoin()
}
metricPublisher.publishMetric("device.stop", 1.0, mapOf("device" to deviceName.toString()))
}
/**
@ -1236,12 +1402,11 @@ public abstract class AbstractDeviceHubManager(
} else null
}
}
return@coroutineScope try {
try {
deferredList.awaitAll()
true
} catch (ex: Exception) {
context.logger.error(ex) { "Failed to start device batch. Rolling back." }
// rollback: stop those that were started
deferredList.mapNotNull { it.getCompletedOrNull() }.forEach { dn ->
childrenJobs[dn]?.let { job ->
try {
@ -1280,12 +1445,11 @@ public abstract class AbstractDeviceHubManager(
} else null
}
}
return@coroutineScope try {
try {
deferredList.awaitAll()
true
} catch (ex: Exception) {
context.logger.error(ex) { "Failed to stop device batch. Rolling back." }
// rollback: start those that were stopped
deferredList.mapNotNull { it.getCompletedOrNull() }.forEach { dn ->
childrenJobs[dn]?.let { job ->
try {
@ -1300,15 +1464,12 @@ public abstract class AbstractDeviceHubManager(
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun <T> Deferred<T>.getCompletedOrNull(): T? = if (isCompleted && !isCancelled) getCompleted() else null
/**
* Optionally set up a distributed transport or message broker for the managed devices.
* By default, this is a stub that logs an informational message.
*/
public open fun installDistributedTransport() {
context.logger.info { "installDistributedTransport: implement or override for custom broker." }
context.logger.info { "installDistributedTransport: Implement or override for custom broker." }
}
/**
@ -1322,6 +1483,10 @@ public abstract class AbstractDeviceHubManager(
}
}
}
public suspend fun shutdown() {
parentJob.cancelAndJoin()
}
}
/**
@ -1339,7 +1504,15 @@ private class DeviceHubManagerImpl(context: Context, dispatcher: CoroutineDispat
replay = 50,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val deviceChanges: MutableSharedFlow<DeviceStateEvent> = MutableSharedFlow(replay = 1)
override val deviceChanges: MutableSharedFlow<DeviceStateEvent> = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val eventBus: DefaultEventBus = DefaultEventBus(
replay = 100,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val transactionManager: TransactionManager = DefaultTransactionManager(eventBus)
}
/**
@ -1374,7 +1547,7 @@ public open class ConfigurableCompositeControlComponent<D : ConfigurableComposit
meta: Meta = Meta.EMPTY,
config: DeviceLifecycleConfig = DeviceLifecycleConfig(),
registry: ComponentRegistry? = null,
public val hubManager: AbstractDeviceHubManager = DeviceHubManagerImpl(context, config.dispatcher ?: Dispatchers.Default),
public val hubManager: AbstractDeviceHubManager = DeviceHubManagerImpl(context, config.dispatcher ?: Dispatchers.Default)
) : DeviceBase<D>(context, meta), CompositeControlComponent {
public val effectiveRegistry: ComponentRegistry? = registry ?: context.componentRegistry
@ -1400,26 +1573,24 @@ public open class ConfigurableCompositeControlComponent<D : ConfigurableComposit
private val childConfigs: List<ChildComponentConfig<*>> = spec.childSpecs.values.toList()
init {
// Setup action execution: wrap execute block with error handling
spec.actions.values.forEach { actionSpec ->
launch {
val actionName = actionSpec.name
hubManager.launchGlobal {
spec.actions.values.forEach { actionSpec ->
messageFlow
.filterIsInstance<ActionExecuteMessage>()
.filter { it.action == actionName }
.filter { it.action == actionSpec.name }
.onEach { msg ->
try {
val result = execute(actionName, msg.argument)
val result = execute(actionSpec.name, msg.argument)
messageBus.emit(
ActionResultMessage(
action = actionName,
action = actionSpec.name,
result = result,
requestId = msg.requestId,
sourceDevice = id.asName()
)
)
} catch (ex: Exception) {
logger.error(ex) { "Error executing action $actionName on device $id" }
logger.error(ex) { "Error executing action ${actionSpec.name} on device $id" }
messageBus.emit(DeviceMessage.error(ex, id.asName()))
}
}
@ -1462,7 +1633,6 @@ public open class ConfigurableCompositeControlComponent<D : ConfigurableComposit
self.onOpen()
validate(self)
}
// Start child devices that are in INITIAL state and not marked as LAZY
hubManager.devices.values.filter { it.lifecycleState == LifecycleState.INITIAL }.forEach { child ->
val mode = hubManager.childrenJobs[child.id.parseAsName()]?.lifecycleMode
if (mode != LifecycleMode.LAZY) {
@ -1472,18 +1642,14 @@ public open class ConfigurableCompositeControlComponent<D : ConfigurableComposit
}
/**
* Called when this device is stopping.
* Default logic: attempt to stop each child device (if started), then call [spec.onClose].
* Called when the device stops.
*/
override suspend fun onStop() {
// Stop each child device concurrently with proper error handling
hubManager.devices.values.forEach { child ->
if (child.lifecycleState == LifecycleState.STARTED) {
launch(child.coroutineContext) {
val stopTimeout = hubManager.childrenJobs[child.id.parseAsName()]?.config?.stopTimeout ?: Duration.INFINITE
val stopped = withTimeoutOrNull(stopTimeout) {
child.stop()
}
val stopped = withTimeoutOrNull(stopTimeout) { child.stop() }
if (stopped == null) {
hubManager.childrenJobs[child.id.parseAsName()]?.let { job ->
hubManager.onStopTimeout(child.id.parseAsName(), job.config)

@ -3,7 +3,9 @@
package space.kscience.controls.spec
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.test.runTest
import space.kscience.controls.api.*
import space.kscience.dataforge.context.Context
@ -13,18 +15,23 @@ import space.kscience.dataforge.meta.int
import space.kscience.dataforge.names.asName
import space.kscience.dataforge.names.parseAsName
import kotlin.test.*
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.StandardTestDispatcher
/**
* A simple [AbstractDeviceHubManager] implementation for tests, providing
* message flows and basic logging of events.
* message flows, event bus, and basic logging of events.
*/
private class TestDeviceHubManager(
context: Context,
dispatcher: CoroutineDispatcher = Dispatchers.Default
) : AbstractDeviceHubManager(context, dispatcher) {
override val messageBus = MutableSharedFlow<DeviceMessage>(replay = 100)
override val systemBus = MutableSharedFlow<SystemLogMessage>(replay = 50)
override val deviceChanges = MutableSharedFlow<DeviceStateEvent>(replay = 50)
override val messageBus = MutableSharedFlow<DeviceMessage>(replay = 100, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val systemBus = MutableSharedFlow<SystemLogMessage>(replay = 50, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val deviceChanges = MutableSharedFlow<DeviceStateEvent>(replay = 50, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val eventBus: EventBus = DefaultEventBus(replay = 100, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val transactionManager: TransactionManager = DefaultTransactionManager(eventBus)
}
/**
@ -658,7 +665,7 @@ class CompositeControlTest {
val pump = SyringePumpDevice(context, Meta { "maxVolume" put 5.0 })
pump.setVolume(10.0)
assertEquals(0.0, pump.getVolume(), "Volume should not change on invalid value")
assertEquals(0.0, pump.getVolume(), "Volume should remain 0 on invalid value")
}
@Test
@ -764,6 +771,7 @@ class CompositeControlTest {
manager.detachDevice(name, true)
assertFalse(name in manager.devices.keys, "The device should be removed from the manager.")
manager.shutdown()
}
@Test
@ -793,8 +801,9 @@ class CompositeControlTest {
@Test
fun `test hot swap device`() = runTest {
val testDispatcher = StandardTestDispatcher(testScheduler)
val context = createTestContext()
val manager = TestDeviceHubManager(context)
val manager = TestDeviceHubManager(context, testDispatcher)
val oldDevice = StepperMotorDevice(context, Meta { "maxPosition" put 100 })
val name = "motorSwap".asName()
@ -814,8 +823,14 @@ class CompositeControlTest {
val current = manager.devices[name]
assertNotNull(current, "New device should be present after hot swap")
assertTrue(current === newDevice, "Manager should reference the new device instance")
assertEquals(999, newDevice.maxPosition)
val events = manager.eventBus.events.take(2).toList()
assertTrue(events.any { it is TransactionEvent.TransactionStarted }, "TransactionStarted event expected")
assertTrue(events.any { it is TransactionEvent.TransactionCommitted }, "TransactionCommitted event expected")
manager.detachDevice(name, waitStop = true)
manager.shutdown()
}
}