Run Attachment Ifc
Plugin slot for observers that need lifecycle-safe access to a running model.
Implementations are supplied on a RunRequest and managed by Runner:
onAttach is called once, on the simulation thread, before
model.initializeReplications(). The attachment has full access to the model at this point and should register anyModelElementObserverinstances and acquire resources (open files, channels, etc.) it needs.If onAttach is called, onDetach is called in a
finallyblock, guaranteed to execute regardless of whether the run completed normally, was cancelled, or threw an exception. Implementations should release all resources and detach any observers registered in onAttach. A run cancelled before worker setup begins may skip both methods.
Why this is needed
Runner owns the model's lifecycle during a run. Without this interface, a caller who manually attaches observers before calling Runner.submit has no safe hook to clean them up if the run fails or is cancelled. onDetach solves that by delegating the cleanup responsibility to Runner's finally block.
Using ModelElementObserver inside an attachment
The KSL model already supports attaching ksl.observers.ModelElementObserver instances via model.attachModelElementObserver(obs). These observers fire synchronously on the simulation thread at fine-grained lifecycle boundaries (beforeReplication, afterReplication, warmUp, afterExperiment, etc.). An attachment typically creates one or more observers in onAttach, attaches them to the model or to specific model elements, and detaches them in onDetach.
Using the scope parameter for background work
The scope passed to onAttach is the coroutine scope of the run itself. Child coroutines launched in this scope are automatically cancelled when the run ends (for any reason), preventing resource leaks. For example, an animation trace attachment that writes events asynchronously can create a buffered kotlinx.coroutines.channels.Channel and launch a Dispatchers.IO writer coroutine in scope:
override fun onAttach(model: Model, scope: CoroutineScope) {
val writeChannel = Channel<AnimationEvent>(capacity = Channel.BUFFERED)
scope.launch(Dispatchers.IO) {
for (event in writeChannel) traceWriter.write(event)
}
// register observer that sends to writeChannel
}Zero overhead when no attachments are wired
RunRequest.SingleRun.attachments defaults to an empty list. When the list is empty, Runner skips the attachment loop entirely — no objects are allocated and no observer callbacks fire beyond the model's own existing lifecycle machinery.