• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mobalazs / rotor-framework / 28952313605

08 Jul 2026 02:54PM UTC coverage: 90.675% (-0.2%) from 90.883%
28952313605

push

github

mobalazs
feat: enhance cross-thread dispatching with bidirectional intent routing and improved documentation

5 of 12 new or added lines in 3 files covered. (41.67%)

2217 of 2445 relevant lines covered (90.67%)

1.25 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.26
/src/source/RotorFrameworkTask.bs
1
' =========================================================================
2
' ▗▄▄▖  ▗▄▖▗▄▄▄▖▗▄▖ ▗▄▄▖     ▗▄▄▄▖▗▄▄▖  ▗▄▖ ▗▖  ▗▖▗▄▄▄▖▗▖ ▗▖ ▗▄▖ ▗▄▄▖ ▗▖ ▗▖
3
' ▐▌ ▐▌▐▌ ▐▌ █ ▐▌ ▐▌▐▌ ▐▌    ▐▌   ▐▌ ▐▌▐▌ ▐▌▐▛▚▞▜▌▐▌   ▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌▗▞▘
4
' ▐▛▀▚▖▐▌ ▐▌ █ ▐▌ ▐▌▐▛▀▚▖    ▐▛▀▀▘▐▛▀▚▖▐▛▀▜▌▐▌  ▐▌▐▛▀▀▘▐▌ ▐▌▐▌ ▐▌▐▛▀▚▖▐▛▚▖
5
' ▐▌ ▐▌▝▚▄▞▘ █ ▝▚▄▞▘▐▌ ▐▌    ▐▌   ▐▌ ▐▌▐▌ ▐▌▐▌  ▐▌▐▙▄▄▖▐▙█▟▌▝▚▄▞▘▐▌ ▐▌▐▌ ▐▌
6
' Rotor Framework™
7
' Version 0.9.1
8
' © 2025-2026 Balázs Molnár — Apache License 2.0
9
' =========================================================================
10

11
' constants
12
import "engine/Constants.bs"
13

14
' engine
15
import "engine/providers/DispatcherProvider.bs"
16
import "engine/providers/Dispatcher.bs"
17

18
' base classes
19
import "base/DispatcherOriginal.bs"
20
import "base/DispatcherCrossThread.bs"
21
import "base/BaseReducer.bs"
22
import "base/CombinedReducer.bs"
23
import "base/BaseModel.bs"
24
import "base/BaseStack.bs"
25

26
' utils
27
import "utils/GeneralUtils.bs"
28
import "utils/NodeUtils.bs"
29
import "utils/ArrayUtils.bs"
30

31
namespace Rotor
32
    ' =====================================================================
33
    ' FrameworkTask - Task thread version of Rotor Framework for MVI
34
    '
35
    ' Task thread version of the Rotor Framework that enables cross-thread MVI
36
    ' (Model-View-Intent) architecture. This class manages state and dispatchers
37
    ' on a separate task thread, allowing heavy computations and state management
38
    ' to run off the render thread for better performance.
39
    '
40
    ' Configuration:
41
    '   - tasks (array, optional): List of additional task node names to synchronize with.
42
    '                             Allows multiple task threads to communicate and share
43
    '                             dispatchers across different threads.
44
    '
45
    ' USAGE NOTES:
46
    ' The FrameworkTask must be instantiated in the task's init() function and the sync()
47
    ' method MUST be called at the end of your task function to establish the message loop.
48
    '
49
    ' IMPORTANT: The sync() method creates an infinite loop that handles cross-thread
50
    ' communication and dispatcher synchronization. This call should be the LAST statement
51
    ' in your task function, after all dispatcher initialization.
52
    '
53
    ' Example:
54
    '   File: MyTask.task.bs
55
    '   import "pkg:/source/RotorFrameworkTask.bs"
56
    '   import "pkg:/source/MyDispatcher.bs"
57
    '
58
    '   sub init()
59
    '       m.top.functionName = "task"
60
    '       m.appFw = new Rotor.FrameworkTask({
61
    '           tasks: ["AnotherTask"]
62
    '       })
63
    '   end sub
64
    '
65
    '   sub task()
66
    '       m.fooDispatcher = createFooDispatcher()
67
    '       m.barDispatcher = createBarDispatcher()
68
    '       m.appFw.sync()
69
    '   end sub
70
    ' =====================================================================
71
    class FrameworkTask
72

73
        name = "Rotor Framework"
74
        version = "0.9.1"
75

76
        config = {
77
            tasks: invalid, ' optional
78
            debug: {
79
            }
80
        }
81

82
        threadType = Rotor.Const.ThreadType.TASK
83

84
        keepAlive = true
85

86
        ' helper vars
87
        taskNode as object
88
        renderQueue as object
89
        dispatcherProvider as object
90
        port as object
91
        sourceObjectRegistry = {} ' identity -> { dispatcherId, objectId, eventFilter }
92
        sourceObjectIdIndex = {} ' objectId -> identity (reverse index for unregistration)
93
        sharedSourceObjects = {} ' typeName -> { sourceObject, subscribers: [{ dispatcherId, eventFilter }] }
94
        private _eventFilterFn = invalid as dynamic
95
        onTick as function
96

97
        ' ---------------------------------------------------------------------
98
        ' new - Initializes the FrameworkTask instance
99
        '
100
        ' Sets up the task thread dispatcher provider, message port, and
101
        ' global framework helper for cross-thread communication.
102
        '
103
        ' @param {object} config - Configuration object (see class documentation)
104
        '
105
        sub new(config = {} as object)
106

107
            Rotor.Utils.deepExtendAA(m.config, config)
1✔
108

109
            globalScope = GetGlobalAA()
1✔
110
            globalScope.rotor_framework_helper = { ' this give to dispatcher instance the possibility to self-register
1✔
111
                threadType: m.threadType,
112
                frameworkInstance: m
113
            }
114
            m.taskNode = globalScope.top
1✔
115
            m.renderQueue = CreateObject("roRenderThreadQueue")
1✔
116

117
            m.dispatcherProvider = new Rotor.DispatcherProvider(m.threadType)
1✔
118

119
            m.taskNode.addField("rotorSync", "assocarray", true)
1✔
120
            m.port = CreateObject("roMessagePort")
1✔
121
            m.taskNode.observeFieldScopedEx("rotorSync", m.port)
1✔
122

123
        end sub
124

125
        ' =====================================================================
126
        ' PUBLIC API
127
        ' =====================================================================
128

129
        ' =====================================================================
130
        ' ROUTER — Task → Render
131
        ' =====================================================================
132

133
        ' ---------------------------------------------------------------------
134
        ' sendStateToRender - Routes dispatcher state update to render thread
135
        '
136
        ' Called by DispatcherOriginal.exposeState(). Sends via the single
137
        ' __rotor__ channel on roRenderThreadQueue — zero rendezvous on task thread.
138
        '
139
        ' @param {string} dispatcherId - Dispatcher identifier
140
        ' @param {object} state - Current state AA
141
        '
142
        public sub sendStateToRender(dispatcherId as string, state as object)
143
            m.renderQueue.CopyMessage(Rotor.Const.ROTOR_QUEUE_ID, {
1✔
144
                type: Rotor.Const.RouterMsgType.STATE,
145
                dispatcherId: dispatcherId,
146
                state: state
147
            })
148
        end sub
149

150
        ' ---------------------------------------------------------------------
151
        ' sendIntent - Routes a cross-thread intent dispatch (task → render thread)
152
        '
153
        ' Called by DispatcherCrossThread.dispatch() on the task thread. A task cannot
154
        ' write a foreign (render-owned) node's rotorSync field without a blocking
155
        ' rendezvous, so the intent is relayed to the render thread via the
156
        ' rendezvous-free __rotor__ channel (CopyMessage), symmetric with
157
        ' sendStateToRender. The render thread resolves the target dispatcher by id and
158
        ' re-dispatches locally (see RotorFramework.handleIntentMessage). Enables
159
        ' task → render and task → task intent dispatch.
160
        '
161
        ' Thread-neutral counterpart of Framework.sendIntent (render → task). Same
162
        ' signature so DispatcherCrossThread.dispatch() works unchanged from either
163
        ' thread. The node parameter is unused on the task side — the render thread
164
        ' resolves the target from its own registry by dispatcherId.
165
        '
166
        ' @param {object} node - Target dispatcher state node (unused on task side)
167
        ' @param {string} dispatcherId - Target dispatcher identifier
168
        ' @param {object} intent - Intent to dispatch
169
        '
170
        public sub sendIntent(node as object, dispatcherId as string, intent as object)
NEW
171
            m.renderQueue.CopyMessage(Rotor.Const.ROTOR_QUEUE_ID, {
×
172
                type: Rotor.Const.RouterMsgType.INTENT,
173
                dispatcherId: dispatcherId,
174
                intent: intent
175
            })
176
        end sub
177

178
        ' ---------------------------------------------------------------------
179
        ' connectDispatcher - Creates a new dispatcher facade connection by ID
180
        '
181
        ' Each call creates a unique facade with its own listenerId and scope binding.
182
        ' Use facade.release() when done to clean up listeners.
183
        '
184
        ' @param {string} dispatcherId - Dispatcher identifier
185
        ' @returns {object} Dispatcher facade instance
186
        '
187
        public function connectDispatcher(dispatcherId as string) as object
188
            return m.dispatcherProvider.getFacade(dispatcherId, GetGlobalAA())
×
189
        end function
190

191
        ' ---------------------------------------------------------------------
192
        ' dispatchTo - Dispatches an intent to a specific dispatcher by ID
193
        '
194
        ' @param {string} dispatcherId - Target dispatcher identifier
195
        ' @param {object} dispatchObject - Intent object to dispatch
196
        '
197
        public sub dispatchTo(dispatcherId as string, dispatchObject as object)
198
            dispatcherFacade = m.connectDispatcher(dispatcherId)
×
199
            dispatcherFacade.dispatch(dispatchObject)
×
200
        end sub
201

202
        ' ---------------------------------------------------------------------
203
        ' getStateFrom - Gets current state from a specific dispatcher by ID
204
        '
205
        ' @param {string} dispatcherId - Target dispatcher identifier
206
        ' @param {dynamic} [mapStateToProps=invalid] - Optional state mapping function
207
        ' @returns {object} Current state from the target dispatcher
208
        '
209
        public function getStateFrom(dispatcherId as string, mapStateToProps = invalid as dynamic) as object
210
            dispatcherFacade = m.connectDispatcher(dispatcherId)
×
211
            return dispatcherFacade.getState(mapStateToProps)
×
212
        end function
213

214
        ' ---------------------------------------------------------------------
215
        ' registerSourceObject - Creates and registers a source object for event routing
216
        '
217
        ' Creates a Roku source object by type name and auto-detects routing mode:
218
        '   - Identity-based: If sourceObject implements GetIdentity, each call creates
219
        '     a new instance with unique routing (roUrlTransfer, roChannelStore).
220
        '   - Broadcast (singleton): If sourceObject does NOT implement GetIdentity,
221
        '     first call creates the instance, subsequent calls return the shared instance
222
        '     and add the dispatcher as a subscriber (roDeviceInfo, roInput, roAppManager).
223
        '
224
        ' @param {string} typeName - Roku object type name (e.g. "roUrlTransfer", "roDeviceInfo")
225
        ' @param {string} dispatcherId - Dispatcher ID that will handle events
226
        ' @param {function} eventFilter - Optional filter function. Receives msg, returns boolean.
227
        ' @returns {object} The created (or shared) source object
228
        '
229
        public function registerSourceObject(typeName as string, dispatcherId as string, eventFilter = invalid as dynamic) as object
230
            sourceObject = CreateObject(typeName)
1✔
231
            if FindMemberFunction(sourceObject, "GetIdentity") <> invalid
3✔
232
                ' Identity-based: unique per call
233
                sourceObject.SetMessagePort(m.port)
1✔
234
                identity = sourceObject.GetIdentity().ToStr()
1✔
235
                objectId = `${dispatcherId}_${identity}`
1✔
236
                m.sourceObjectRegistry[identity] = {
1✔
237
                    dispatcherId: dispatcherId,
238
                    objectId: objectId,
239
                    eventFilter: eventFilter
240
                }
241
                m.sourceObjectIdIndex[objectId] = identity
1✔
242
            else
243
                ' Broadcast: singleton per type
3✔
244
                if m.sharedSourceObjects.DoesExist(typeName)
2✔
245
                    ' Existing — add subscriber, discard new object
246
                    m.sharedSourceObjects[typeName].subscribers.push({
×
247
                        dispatcherId: dispatcherId,
248
                        eventFilter: eventFilter
249
                    })
250
                    return m.sharedSourceObjects[typeName].sourceObject
×
251
                else
252
                    ' First — store as shared
3✔
253
                    sourceObject.SetMessagePort(m.port)
1✔
254
                    m.sharedSourceObjects[typeName] = {
1✔
255
                        sourceObject: sourceObject,
256
                        subscribers: [{
257
                            dispatcherId: dispatcherId,
258
                            eventFilter: eventFilter
259
                        }]
260
                    }
261
                end if
262
            end if
263
            return sourceObject
1✔
264
        end function
265

266
        ' ---------------------------------------------------------------------
267
        ' unregisterSourceObject - Unregisters a source object
268
        '
269
        ' Identity-based objects: removes by identity from registry.
270
        ' Broadcast objects: removes dispatcher subscriber, cleans up if no subscribers remain.
271
        '
272
        ' @param {object} sourceObject - The source object to unregister
273
        ' @param {string} dispatcherId - Dispatcher ID that owns this registration
274
        '
275
        public sub unregisterSourceObject(sourceObject as object, dispatcherId as string)
276
            if FindMemberFunction(sourceObject, "GetIdentity") <> invalid
3✔
277
                ' Identity-based: remove by identity
278
                identity = sourceObject.GetIdentity().ToStr()
1✔
279
                if m.sourceObjectRegistry.DoesExist(identity)
3✔
280
                    m.sourceObjectRegistry.Delete(identity)
1✔
281
                end if
282
                objectId = `${dispatcherId}_${identity}`
1✔
283
                if m.sourceObjectIdIndex.DoesExist(objectId)
3✔
284
                    m.sourceObjectIdIndex.Delete(objectId)
1✔
285
                end if
286
            else
287
                ' Broadcast: remove subscriber, cleanup if empty
3✔
288
                typeName = type(sourceObject)
1✔
289
                if m.sharedSourceObjects.DoesExist(typeName)
3✔
290
                    shared = m.sharedSourceObjects[typeName]
1✔
291
                    for i = shared.subscribers.count() - 1 to 0 step -1
1✔
292
                        if shared.subscribers[i].dispatcherId = dispatcherId
3✔
293
                            shared.subscribers.Delete(i)
1✔
294
                        end if
295
                    end for
296
                    if shared.subscribers.count() = 0
3✔
297
                        m.sharedSourceObjects.Delete(typeName)
1✔
298
                    end if
299
                end if
300
            end if
301
        end sub
302

303
        ' ---------------------------------------------------------------------
304
        ' sync - Starts the message loop for cross-thread communication
305
        '
306
        ' IMPORTANT: This method creates an infinite loop that handles:
307
        '   - Intent dispatching from render thread
308
        '   - External dispatcher registration
309
        '   - State change notifications
310
        '   - Async reducer callbacks
311
        '
312
        ' This method MUST be the last call in your task function, as it
313
        ' blocks execution until the framework is destroyed.
314
        '
315
        sub sync(waitMs = 0 as integer, onTick = invalid as dynamic)
316
            m.notifySyncStatus(Rotor.Const.ThreadSyncType.TASK_SYNCING)
1✔
317

318
            keepAlive = true
1✔
319

320
            ' Initialize tick timer if waitMs > 0
321
            lastTickTime = invalid
1✔
322
            if waitMs > 0
3✔
323
                lastTickTime = CreateObject("roTimespan")
1✔
324
                lastTickTime.Mark()
1✔
325
            end if
326

327
            while true and keepAlive = true
1✔
328
                msg = wait(waitMs, m.port)
1✔
329

330
                if msg = invalid
2✔
331
                    ' Timeout - check if tick interval elapsed
332
                    if waitMs > 0 and onTick <> invalid and lastTickTime <> invalid
3✔
333
                        elapsedMs = lastTickTime.TotalMilliseconds()
1✔
334
                        if elapsedMs >= waitMs
3✔
335
                            ' Tick interval elapsed - call callback
336
                            Rotor.Utils.callbackScoped(onTick, GetGlobalAA())
1✔
337
                            ' Reset tick timer
338
                            lastTickTime.Mark()
1✔
339
                        end if
340
                    end if
341
                else if msg <> invalid
3✔
342
                    msgType = type(msg)
1✔
343
                    if msgType = "roSGNodeEvent"
3✔
344
                        fieldId = msg.getField()
1✔
345

346
                        if fieldId = "rotorSync"
3✔
347

348
                            sync = msg.getData() ' @type:AA
1✔
349

350
                            if sync.type = Rotor.Const.ThreadSyncType.DISPATCH
2✔
351

352

353
                                dispatcherId = sync.payload.dispatcherId
1✔
354
                                intent = sync.payload.intent
1✔
355
                                dispatcherInstance = m.dispatcherProvider.stack.LookupCI(dispatcherId)
1✔
356

357
                                ' taskIntent = Rotor.Utils.deepCopy(intent)
358
                                dispatcherInstance.dispatch(intent)
1✔
359

360
                            else if sync.type = Rotor.Const.ThreadSyncType.REGISTER_CROSS_THREAD_DISPATCHER
2✔
361

362
                                for each item in sync.crossThreadDispatcherList
1✔
363
                                    m.dispatcherProvider.registerCrossThreadDispatchers(item.dispatcherId, item.stateNode)
1✔
364
                                end for
365

366
                                m.notifySyncStatus(Rotor.Const.ThreadSyncType.TASK_SYNCED)
1✔
367

368
                            else if sync.type = Rotor.Const.ThreadSyncType.DESTROY
3✔
369

370
                                keepAlive = false
1✔
371

372
                            end if
373
                        else
374
                            ' Cross-thread state change: notify task-side listeners
×
375
                            data = msg.getData()
×
376
                            dispatcherId = fieldId
×
377
                            dispatcherInstance = m.dispatcherProvider.get(dispatcherId)
×
378
                            if dispatcherInstance <> invalid
×
379
                                dispatcherInstance.notifyListeners(data)
×
380
                            end if
381

382
                        end if
383
                    else
384
                        ' Generic source object routing
3✔
385
                        routed = false
1✔
386

387
                        ' Try identity-based routing
388
                        if m.sourceObjectRegistry.count() > 0
2✔
389
                            try
390
                                sourceIdentity = msg.GetSourceIdentity().ToStr()
1✔
391
                                if m.sourceObjectRegistry.DoesExist(sourceIdentity)
2✔
392
                                    entry = m.sourceObjectRegistry[sourceIdentity]
1✔
393

394
                                    ' Apply event filter if provided
395
                                    allowed = true
1✔
396
                                    if entry.eventFilter <> invalid
2✔
397
                                        m._eventFilterFn = entry.eventFilter
×
398
                                        allowed = m._eventFilterFn(msg)
×
399
                                    end if
400

401
                                    if allowed
3✔
402
                                        dispatcherInstance = m.dispatcherProvider.get(entry.dispatcherId)
1✔
403
                                        if dispatcherInstance <> invalid
3✔
404
                                            dispatcherInstance.onSourceEvent(msg)
1✔
405
                                        end if
406
                                    end if
407
                                    routed = true
1✔
408
                                end if
409
                            catch e
410
                                ' Event doesn't support GetSourceIdentity - fall through to broadcast
411
                            end try
412
                        end if
413

414
                        ' Broadcast to shared source object subscribers
415
                        if not routed
3✔
416
                            for each typeName in m.sharedSourceObjects
1✔
417
                                shared = m.sharedSourceObjects[typeName]
1✔
418
                                for each subscriber in shared.subscribers
1✔
419
                                    allowed = true
1✔
420
                                    if subscriber.eventFilter <> invalid
3✔
421
                                        m._eventFilterFn = subscriber.eventFilter
1✔
422
                                        allowed = m._eventFilterFn(msg)
1✔
423
                                    end if
424

425
                                    if allowed
3✔
426
                                        dispatcherInstance = m.dispatcherProvider.get(subscriber.dispatcherId)
1✔
427
                                        if dispatcherInstance <> invalid
3✔
428
                                            dispatcherInstance.onSourceEvent(msg)
1✔
429
                                        end if
430
                                    end if
431
                                end for
432
                            end for
433
                        end if
434
                    end if
435
                end if
436
            end while
437
            m.destroy()
1✔
438
        end sub
439

440
        ' =====================================================================
441
        ' INTERNAL METHODS
442
        ' =====================================================================
443

444
        ' ---------------------------------------------------------------------
445
        ' notifySyncStatus - Notifies render thread of sync status
446
        '
447
        ' Sends sync status message to render thread via rotorSync field.
448
        '
449
        ' @param {string} status - Sync status type (TASK_SYNCING or TASK_SYNCED)
450
        '
451
        sub notifySyncStatus(status as string)
452

453
            payload = {
1✔
454
                type: Rotor.Const.RouterMsgType.SYNC,
455
                syncType: status,
456
                taskNode: m.taskNode
457
            }
458

459
            if status = Rotor.Const.ThreadSyncType.TASK_SYNCING
2✔
460
                dispatcherIds = m.dispatcherProvider.stack.Keys()
1✔
461
                initialStates = {}
1✔
462
                for each id in dispatcherIds
1✔
463
                    initialStates[id] = m.dispatcherProvider.get(id).getState()
1✔
464
                end for
465
                payload.append({
1✔
466
                    dispatcherIds: dispatcherIds,
467
                    initialStates: initialStates,
468
                    tasks: m.config.tasks
469
                })
470
            end if
471

472
            m.renderQueue.CopyMessage(Rotor.Const.ROTOR_QUEUE_ID, payload)
1✔
473

474
        end sub
475

476
        ' ---------------------------------------------------------------------
477
        ' addObserver - Adds field observer to task thread message port
478
        '
479
        ' @param {string} fieldId - Field name to observe
480
        ' @param {object} node - SceneGraph node to observe
481
        '
482
        sub addObserver(fieldId as string, node)
483
            node.observeFieldScopedEx(fieldId, m.port)
×
484
        end sub
485

486
        ' ---------------------------------------------------------------------
487
        ' removeObserver - Removes field observer from node
488
        '
489
        ' @param {string} fieldId - Field name to stop observing
490
        ' @param {object} node - SceneGraph node to unobserve
491
        '
492
        sub removeObserver(fieldId as string, node)
493
            node.unobserveFieldScoped(fieldId)
×
494
        end sub
495

496
        ' =====================================================================
497
        ' CLEANUP
498
        ' =====================================================================
499

500
        ' ---------------------------------------------------------------------
501
        ' destroy - Cleans up task thread resources
502
        '
503
        ' Destroys dispatcher provider and clears global framework helper.
504
        '
505
        public sub destroy()
506
            m.sourceObjectRegistry.clear()
1✔
507
            m.sourceObjectIdIndex.clear()
1✔
508
            m.sharedSourceObjects.clear()
1✔
509
            m.dispatcherProvider.destroy()
1✔
510

511
            globalScope = GetGlobalAA()
1✔
512
            globalScope.rotor_framework_helper = {
1✔
513
                frameworkInstance: invalid
514
            }
515

516
            m.renderQueue = invalid
1✔
517
            m.taskNode.rootNode = invalid
1✔
518
            m.taskNode = invalid
1✔
519
        end sub
520

521
    end class
522

523
end namespace
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc