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

mobalazs / rotor-framework / 25910768822

15 May 2026 09:32AM UTC coverage: 90.133% (+0.2%) from 89.933%
25910768822

push

github

web-flow
Feat/rsg 1.3 os15 upgrade (#25)

Summary
This PR updates the dispatcher architecture to support RSG 1.3, introducing improved task synchronization and state management across threads.

Key Changes
Dispatcher refactor – Updated dispatcher methods and routing logic for RSG 1.3 compatibility
New queue system – Implemented syncQueue and renderQueue for better cross-thread task coordination
State management – Enhanced state handling in the cross-thread dispatcher
Documentation – Updated framework initialization docs, pitfalls guide, injectedMethods reference, and widget API (erase method signature)
CI fix – Updated workflow condition to skip tag pushes
Why
The previous dispatcher implementation wasn't compatible with RSG 1.3's threading model. The new queue-based approach ensures proper task synchronization between render and task threads.

48 of 49 new or added lines in 4 files covered. (97.96%)

4 existing lines in 3 files now uncovered.

2165 of 2402 relevant lines covered (90.13%)

2.5 hits per line

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

86.92
/src/source/RotorFrameworkTask.bs
1
' =========================================================================
2
' ▗▄▄▖  ▗▄▖▗▄▄▄▖▗▄▖ ▗▄▄▖     ▗▄▄▄▖▗▄▄▖  ▗▄▖ ▗▖  ▗▖▗▄▄▄▖▗▖ ▗▖ ▗▄▖ ▗▄▄▖ ▗▖ ▗▖
3
' ▐▌ ▐▌▐▌ ▐▌ █ ▐▌ ▐▌▐▌ ▐▌    ▐▌   ▐▌ ▐▌▐▌ ▐▌▐▛▚▞▜▌▐▌   ▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌▗▞▘
4
' ▐▛▀▚▖▐▌ ▐▌ █ ▐▌ ▐▌▐▛▀▚▖    ▐▛▀▀▘▐▛▀▚▖▐▛▀▜▌▐▌  ▐▌▐▛▀▀▘▐▌ ▐▌▐▌ ▐▌▐▛▀▚▖▐▛▚▖
5
' ▐▌ ▐▌▝▚▄▞▘ █ ▝▚▄▞▘▐▌ ▐▌    ▐▌   ▐▌ ▐▌▐▌ ▐▌▐▌  ▐▌▐▙▄▄▖▐▙█▟▌▝▚▄▞▘▐▌ ▐▌▐▌ ▐▌
6
' Rotor Framework™
7
' Version 0.8.2
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/BaseModel.bs"
23
import "base/BaseStack.bs"
24

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

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

72
        name = "Rotor Framework"
73
        version = "0.8.2"
74

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

81
        threadType = Rotor.Const.ThreadType.TASK
82

83
        keepAlive = true
84

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

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

106
            Rotor.Utils.deepExtendAA(m.config, config)
2✔
107

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

116
            m.dispatcherProvider = new Rotor.DispatcherProvider(m.threadType)
2✔
117

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

122
        end sub
123

124
        ' =====================================================================
125
        ' PUBLIC API
126
        ' =====================================================================
127

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

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

149
        ' ---------------------------------------------------------------------
150
        ' connectDispatcher - Creates a new dispatcher facade connection by ID
151
        '
152
        ' Each call creates a unique facade with its own listenerId and scope binding.
153
        ' Use facade.release() when done to clean up listeners.
154
        '
155
        ' @param {string} dispatcherId - Dispatcher identifier
156
        ' @returns {object} Dispatcher facade instance
157
        '
158
        public function connectDispatcher(dispatcherId as string) as object
159
            return m.dispatcherProvider.getFacade(dispatcherId, GetGlobalAA())
×
160
        end function
161

162
        ' ---------------------------------------------------------------------
163
        ' dispatchTo - Dispatches an intent to a specific dispatcher by ID
164
        '
165
        ' @param {string} dispatcherId - Target dispatcher identifier
166
        ' @param {object} dispatchObject - Intent object to dispatch
167
        '
168
        public sub dispatchTo(dispatcherId as string, dispatchObject as object)
169
            dispatcherFacade = m.connectDispatcher(dispatcherId)
×
170
            dispatcherFacade.dispatch(dispatchObject)
×
171
        end sub
172

173
        ' ---------------------------------------------------------------------
174
        ' getStateFrom - Gets current state from a specific dispatcher by ID
175
        '
176
        ' @param {string} dispatcherId - Target dispatcher identifier
177
        ' @param {dynamic} [mapStateToProps=invalid] - Optional state mapping function
178
        ' @returns {object} Current state from the target dispatcher
179
        '
180
        public function getStateFrom(dispatcherId as string, mapStateToProps = invalid as dynamic) as object
181
            dispatcherFacade = m.connectDispatcher(dispatcherId)
×
182
            return dispatcherFacade.getState(mapStateToProps)
×
183
        end function
184

185
        ' ---------------------------------------------------------------------
186
        ' registerSourceObject - Creates and registers a source object for event routing
187
        '
188
        ' Creates a Roku source object by type name and auto-detects routing mode:
189
        '   - Identity-based: If sourceObject implements GetIdentity, each call creates
190
        '     a new instance with unique routing (roUrlTransfer, roChannelStore).
191
        '   - Broadcast (singleton): If sourceObject does NOT implement GetIdentity,
192
        '     first call creates the instance, subsequent calls return the shared instance
193
        '     and add the dispatcher as a subscriber (roDeviceInfo, roInput, roAppManager).
194
        '
195
        ' @param {string} typeName - Roku object type name (e.g. "roUrlTransfer", "roDeviceInfo")
196
        ' @param {string} dispatcherId - Dispatcher ID that will handle events
197
        ' @param {function} eventFilter - Optional filter function. Receives msg, returns boolean.
198
        ' @returns {object} The created (or shared) source object
199
        '
200
        public function registerSourceObject(typeName as string, dispatcherId as string, eventFilter = invalid as dynamic) as object
201
            sourceObject = CreateObject(typeName)
2✔
202
            if FindMemberFunction(sourceObject, "GetIdentity") <> invalid
6✔
203
                ' Identity-based: unique per call
204
                sourceObject.SetMessagePort(m.port)
2✔
205
                identity = sourceObject.GetIdentity().ToStr()
2✔
206
                objectId = `${dispatcherId}_${identity}`
2✔
207
                m.sourceObjectRegistry[identity] = {
2✔
208
                    dispatcherId: dispatcherId,
209
                    objectId: objectId,
210
                    eventFilter: eventFilter
211
                }
212
                m.sourceObjectIdIndex[objectId] = identity
2✔
213
            else
214
                ' Broadcast: singleton per type
6✔
215
                if m.sharedSourceObjects.DoesExist(typeName)
4✔
216
                    ' Existing — add subscriber, discard new object
217
                    m.sharedSourceObjects[typeName].subscribers.push({
×
218
                        dispatcherId: dispatcherId,
219
                        eventFilter: eventFilter
220
                    })
221
                    return m.sharedSourceObjects[typeName].sourceObject
×
222
                else
223
                    ' First — store as shared
6✔
224
                    sourceObject.SetMessagePort(m.port)
2✔
225
                    m.sharedSourceObjects[typeName] = {
2✔
226
                        sourceObject: sourceObject,
227
                        subscribers: [{
228
                            dispatcherId: dispatcherId,
229
                            eventFilter: eventFilter
230
                        }]
231
                    }
232
                end if
233
            end if
234
            return sourceObject
2✔
235
        end function
236

237
        ' ---------------------------------------------------------------------
238
        ' unregisterSourceObject - Unregisters a source object
239
        '
240
        ' Identity-based objects: removes by identity from registry.
241
        ' Broadcast objects: removes dispatcher subscriber, cleans up if no subscribers remain.
242
        '
243
        ' @param {object} sourceObject - The source object to unregister
244
        ' @param {string} dispatcherId - Dispatcher ID that owns this registration
245
        '
246
        public sub unregisterSourceObject(sourceObject as object, dispatcherId as string)
247
            if FindMemberFunction(sourceObject, "GetIdentity") <> invalid
6✔
248
                ' Identity-based: remove by identity
249
                identity = sourceObject.GetIdentity().ToStr()
2✔
250
                if m.sourceObjectRegistry.DoesExist(identity)
6✔
251
                    m.sourceObjectRegistry.Delete(identity)
2✔
252
                end if
253
                objectId = `${dispatcherId}_${identity}`
2✔
254
                if m.sourceObjectIdIndex.DoesExist(objectId)
6✔
255
                    m.sourceObjectIdIndex.Delete(objectId)
2✔
256
                end if
257
            else
258
                ' Broadcast: remove subscriber, cleanup if empty
6✔
259
                typeName = type(sourceObject)
2✔
260
                if m.sharedSourceObjects.DoesExist(typeName)
6✔
261
                    shared = m.sharedSourceObjects[typeName]
2✔
262
                    for i = shared.subscribers.count() - 1 to 0 step -1
2✔
263
                        if shared.subscribers[i].dispatcherId = dispatcherId
6✔
264
                            shared.subscribers.Delete(i)
2✔
265
                        end if
266
                    end for
267
                    if shared.subscribers.count() = 0
6✔
268
                        m.sharedSourceObjects.Delete(typeName)
2✔
269
                    end if
270
                end if
271
            end if
272
        end sub
273

274
        ' ---------------------------------------------------------------------
275
        ' sync - Starts the message loop for cross-thread communication
276
        '
277
        ' IMPORTANT: This method creates an infinite loop that handles:
278
        '   - Intent dispatching from render thread
279
        '   - External dispatcher registration
280
        '   - State change notifications
281
        '   - Async reducer callbacks
282
        '
283
        ' This method MUST be the last call in your task function, as it
284
        ' blocks execution until the framework is destroyed.
285
        '
286
        sub sync(waitMs = 0 as integer, onTick = invalid as dynamic)
287
            m.notifySyncStatus(Rotor.Const.ThreadSyncType.TASK_SYNCING)
2✔
288

289
            keepAlive = true
2✔
290

291
            ' Initialize tick timer if waitMs > 0
292
            lastTickTime = invalid
2✔
293
            if waitMs > 0
6✔
294
                lastTickTime = CreateObject("roTimespan")
2✔
295
                lastTickTime.Mark()
2✔
296
            end if
297

298
            while true and keepAlive = true
2✔
299
                msg = wait(waitMs, m.port)
2✔
300

301
                if msg = invalid
4✔
302
                    ' Timeout - check if tick interval elapsed
303
                    if waitMs > 0 and onTick <> invalid and lastTickTime <> invalid
6✔
304
                        elapsedMs = lastTickTime.TotalMilliseconds()
2✔
305
                        if elapsedMs >= waitMs
6✔
306
                            ' Tick interval elapsed - call callback
307
                            Rotor.Utils.callbackScoped(onTick, GetGlobalAA())
2✔
308
                            ' Reset tick timer
309
                            lastTickTime.Mark()
2✔
310
                        end if
311
                    end if
312
                else if msg <> invalid
6✔
313
                    msgType = type(msg)
2✔
314
                    if msgType = "roSGNodeEvent"
6✔
315
                        fieldId = msg.getField()
2✔
316

317
                        if fieldId = "rotorSync"
6✔
318

319
                            sync = msg.getData() ' @type:AA
2✔
320

321
                            if sync.type = Rotor.Const.ThreadSyncType.DISPATCH
4✔
322

323

324
                                dispatcherId = sync.payload.dispatcherId
2✔
325
                                intent = sync.payload.intent
2✔
326
                                dispatcherInstance = m.dispatcherProvider.stack.LookupCI(dispatcherId)
2✔
327

328
                                ' taskIntent = Rotor.Utils.deepCopy(intent)
329
                                dispatcherInstance.dispatch(intent)
2✔
330

331
                            else if sync.type = Rotor.Const.ThreadSyncType.REGISTER_CROSS_THREAD_DISPATCHER
4✔
332

333
                                for each item in sync.crossThreadDispatcherList
2✔
334
                                    m.dispatcherProvider.registerCrossThreadDispatchers(item.dispatcherId, item.stateNode)
2✔
335
                                end for
336

337
                                m.notifySyncStatus(Rotor.Const.ThreadSyncType.TASK_SYNCED)
2✔
338

339
                            else if sync.type = Rotor.Const.ThreadSyncType.DESTROY
6✔
340

341
                                keepAlive = false
2✔
342

343
                            end if
344
                        else
345
                            ' Cross-thread state change: notify task-side listeners
×
346
                            data = msg.getData()
×
347
                            dispatcherId = fieldId
×
348
                            dispatcherInstance = m.dispatcherProvider.get(dispatcherId)
×
349
                            if dispatcherInstance <> invalid
×
350
                                dispatcherInstance.notifyListeners(data)
×
351
                            end if
352

353
                        end if
354
                    else
355
                        ' Generic source object routing
6✔
356
                        routed = false
2✔
357

358
                        ' Try identity-based routing
359
                        if m.sourceObjectRegistry.count() > 0
4✔
360
                            try
361
                                sourceIdentity = msg.GetSourceIdentity().ToStr()
2✔
362
                                if m.sourceObjectRegistry.DoesExist(sourceIdentity)
4✔
363
                                    entry = m.sourceObjectRegistry[sourceIdentity]
2✔
364

365
                                    ' Apply event filter if provided
366
                                    allowed = true
2✔
367
                                    if entry.eventFilter <> invalid
4✔
368
                                        m._eventFilterFn = entry.eventFilter
×
369
                                        allowed = m._eventFilterFn(msg)
×
370
                                    end if
371

372
                                    if allowed
6✔
373
                                        dispatcherInstance = m.dispatcherProvider.get(entry.dispatcherId)
2✔
374
                                        if dispatcherInstance <> invalid
6✔
375
                                            dispatcherInstance.onSourceEvent(msg)
2✔
376
                                        end if
377
                                    end if
378
                                    routed = true
2✔
379
                                end if
380
                            catch e
381
                                ' Event doesn't support GetSourceIdentity - fall through to broadcast
382
                            end try
383
                        end if
384

385
                        ' Broadcast to shared source object subscribers
386
                        if not routed
6✔
387
                            for each typeName in m.sharedSourceObjects
2✔
388
                                shared = m.sharedSourceObjects[typeName]
2✔
389
                                for each subscriber in shared.subscribers
2✔
390
                                    allowed = true
2✔
391
                                    if subscriber.eventFilter <> invalid
6✔
392
                                        m._eventFilterFn = subscriber.eventFilter
2✔
393
                                        allowed = m._eventFilterFn(msg)
2✔
394
                                    end if
395

396
                                    if allowed
6✔
397
                                        dispatcherInstance = m.dispatcherProvider.get(subscriber.dispatcherId)
2✔
398
                                        if dispatcherInstance <> invalid
6✔
399
                                            dispatcherInstance.onSourceEvent(msg)
2✔
400
                                        end if
401
                                    end if
402
                                end for
403
                            end for
404
                        end if
405
                    end if
406
                end if
407
            end while
408
            m.destroy()
2✔
409
        end sub
410

411
        ' =====================================================================
412
        ' INTERNAL METHODS
413
        ' =====================================================================
414

415
        ' ---------------------------------------------------------------------
416
        ' notifySyncStatus - Notifies render thread of sync status
417
        '
418
        ' Sends sync status message to render thread via rotorSync field.
419
        '
420
        ' @param {string} status - Sync status type (TASK_SYNCING or TASK_SYNCED)
421
        '
422
        sub notifySyncStatus(status as string)
423

424
            payload = {
2✔
425
                type: Rotor.Const.RouterMsgType.SYNC,
426
                syncType: status,
427
                taskNode: m.taskNode
428
            }
429

430
            if status = Rotor.Const.ThreadSyncType.TASK_SYNCING
4✔
431
                dispatcherIds = m.dispatcherProvider.stack.Keys()
2✔
432
                initialStates = {}
2✔
433
                for each id in dispatcherIds
2✔
434
                    initialStates[id] = m.dispatcherProvider.get(id).getState()
2✔
435
                end for
436
                payload.append({
2✔
437
                    dispatcherIds: dispatcherIds,
438
                    initialStates: initialStates,
439
                    tasks: m.config.tasks
440
                })
441
            end if
442

443
            m.renderQueue.CopyMessage(Rotor.Const.ROTOR_QUEUE_ID, payload)
2✔
444

445
        end sub
446

447
        ' ---------------------------------------------------------------------
448
        ' addObserver - Adds field observer to task thread message port
449
        '
450
        ' @param {string} fieldId - Field name to observe
451
        ' @param {object} node - SceneGraph node to observe
452
        '
453
        sub addObserver(fieldId as string, node)
NEW
454
            node.observeFieldScopedEx(fieldId, m.port)
×
455
        end sub
456

457
        ' ---------------------------------------------------------------------
458
        ' removeObserver - Removes field observer from node
459
        '
460
        ' @param {string} fieldId - Field name to stop observing
461
        ' @param {object} node - SceneGraph node to unobserve
462
        '
463
        sub removeObserver(fieldId as string, node)
464
            node.unobserveFieldScoped(fieldId)
×
465
        end sub
466

467
        ' =====================================================================
468
        ' CLEANUP
469
        ' =====================================================================
470

471
        ' ---------------------------------------------------------------------
472
        ' destroy - Cleans up task thread resources
473
        '
474
        ' Destroys dispatcher provider and clears global framework helper.
475
        '
476
        public sub destroy()
477
            m.sourceObjectRegistry.clear()
2✔
478
            m.sourceObjectIdIndex.clear()
2✔
479
            m.sharedSourceObjects.clear()
2✔
480
            m.dispatcherProvider.destroy()
2✔
481

482
            globalScope = GetGlobalAA()
2✔
483
            globalScope.rotor_framework_helper = {
2✔
484
                frameworkInstance: invalid
485
            }
486

487
            m.renderQueue = invalid
2✔
488
            m.taskNode.rootNode = invalid
2✔
489
            m.taskNode = invalid
2✔
490
        end sub
491

492
    end class
493

494
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